Skip to content

Commit ac24597

Browse files
committed
Merge branch 'as/antimeridian' into as/trees-and-antimeridian
2 parents be0dd9c + 1295643 commit ac24597

File tree

5 files changed

+367
-11
lines changed

5 files changed

+367
-11
lines changed

src/GeometryOps.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ include("transformations/forcedims.jl")
8686
include("transformations/correction/geometry_correction.jl")
8787
include("transformations/correction/closed_ring.jl")
8888
include("transformations/correction/intersecting_polygons.jl")
89+
include("transformations/correction/cut_at_antimeridian.jl")
8990

9091
# Import all names from GeoInterface and Extents, so users can do `GO.extent` or `GO.trait`.
9192
for name in names(GeoInterface)

src/transformations/correction/closed_ring.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ See also [`GeometryCorrection`](@ref).
4646
"""
4747
struct ClosedRing <: GeometryCorrection end
4848

49-
application_level(::ClosedRing) = GI.PolygonTrait
49+
application_level(::ClosedRing) = TraitTarget(GI.PolygonTrait())
5050

5151
function (::ClosedRing)(::GI.PolygonTrait, polygon)
5252
exterior = _close_linear_ring(GI.getexterior(polygon))
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
#=
2+
# Antimeridian Cutting
3+
=#
4+
export CutAtAntimeridianAndPoles # TODO: too wordy?
5+
6+
#=
7+
This correction cuts the geometry at the antimeridian and the poles, and returns a fixed geometry.
8+
9+
The implementation is translated from the implementation in https://github.com/gadomski/antimeridian,
10+
which is a Python package. Several ports of that algorithm exist in e.g. Go, Rust, etc.
11+
12+
At some point we will have to go in and clean up the implementation, remove all the hardcoded code,
13+
and make it more efficient by using raw geointerface, and not allocating so much (perhaps, by passing allocs around).
14+
15+
But for right now it works, that's all I need.
16+
=#
17+
18+
"""
19+
CutAtAntimeridianAndPoles(; kwargs...) <: GeometryCorrection
20+
21+
This correction cuts the geometry at the antimeridian and the poles, and returns a fixed geometry.
22+
"""
23+
Base.@kwdef struct CutAtAntimeridianAndPoles <: GeometryCorrection
24+
"The value at the north pole, in your angular units"
25+
northpole::Float64 = 90.0 # TODO not used!!!
26+
"The value at the south pole, in your angular units"
27+
southpole::Float64 = -90.0 # TODO not used!!!
28+
"The value at the left edge of the antimeridian, in your angular units"
29+
left::Float64 = -180.0 # TODO not used!!!
30+
"The value at the right edge of the antimeridian, in your angular units"
31+
right::Float64 = 180.0 # TODO not used!!!
32+
"The period of the cyclic / cylindrical coordinate system's x value, usually computed automatically so you don't have to provide it."
33+
period::Float64 = right - left # TODO not used!!!
34+
"If the polygon is known to enclose the north pole, set this to true"
35+
force_north_pole::Bool=false # TODO not used!!!
36+
"If the polygon is known to enclose the south pole, set this to true"
37+
force_south_pole::Bool=false # TODO not used!!!
38+
"If true, use the great circle method to find the antimeridian crossing, otherwise use the flat projection method. There is no reason to set this to be off."
39+
great_circle = true
40+
end
41+
42+
function Base.show(io::IO, cut::CutAtAntimeridian)
43+
print(io, "CutAtAntimeridian(left=$(cut.left), right=$(cut.right))")
44+
end
45+
Base.show(io::IO, ::MIME"text/plain", cut::CutAtAntimeridian) = Base.show(io, cut)
46+
47+
application_level(::CutAtAntimeridian) = TraitTarget(GI.PolygonTrait(), GI.LineStringTrait(), GI.MultiLineStringTrait(), GI.MultiPolygonTrait())
48+
49+
function (c::CutAtAntimeridianAndPoles)(trait::GI.AbstractTrait, geom)
50+
return _AntimeridianHelpers.cut_at_antimeridian(trait, geom)
51+
end
52+
53+
module _AntimeridianHelpers
54+
55+
import GeoInterface as GI
56+
import ..GeometryOps as GO
57+
58+
# Custom cross product for 3D tuples
59+
function _cross(a::Tuple{Float64,Float64,Float64}, b::Tuple{Float64,Float64,Float64})
60+
return (
61+
a[2]*b[3] - a[3]*b[2],
62+
a[3]*b[1] - a[1]*b[3],
63+
a[1]*b[2] - a[2]*b[1]
64+
)
65+
end
66+
67+
# Convert spherical degrees to cartesian coordinates
68+
function spherical_degrees_to_cartesian(point::Tuple{Float64,Float64})::Tuple{Float64,Float64,Float64}
69+
lon, lat = point
70+
slon, clon = sincosd(lon)
71+
slat, clat = sincosd(lat)
72+
return (
73+
clon * clat,
74+
slon * clat,
75+
slat
76+
)
77+
end
78+
79+
# Calculate crossing latitude using great circle method
80+
function crossing_latitude_great_circle(start::Tuple{Float64,Float64}, endpoint::Tuple{Float64,Float64})::Float64
81+
# Convert points to 3D vectors
82+
p1 = spherical_degrees_to_cartesian(start)
83+
p2 = spherical_degrees_to_cartesian(endpoint)
84+
85+
# Cross product defines plane through both points
86+
n1 = _cross(p1, p2)
87+
88+
# Unit vector -Y defines meridian plane
89+
n2 = (0.0, -1.0, 0.0)
90+
91+
# Intersection of planes defined by cross product
92+
intersection = _cross(n1, n2)
93+
norm = sqrt(sum(intersection .^ 2))
94+
intersection = intersection ./ norm
95+
96+
# Convert back to spherical coordinates (just need latitude)
97+
round(asind(intersection[3]), digits=7)
98+
end
99+
100+
# Calculate crossing latitude using flat projection method
101+
function crossing_latitude_flat(start::Tuple{Float64,Float64}, endpoint::Tuple{Float64,Float64})::Float64
102+
lat_delta = endpoint[2] - start[2]
103+
if endpoint[1] > 0
104+
round(
105+
start[2] + (180.0 - start[1]) * lat_delta / (endpoint[1] + 360.0 - start[1]),
106+
digits=7
107+
)
108+
else
109+
round(
110+
start[2] + (start[1] + 180.0) * lat_delta / (start[1] + 360.0 - endpoint[1]),
111+
digits=7
112+
)
113+
end
114+
end
115+
116+
# Main crossing latitude calculation function
117+
function crossing_latitude(start::Tuple{Float64,Float64}, endpoint::Tuple{Float64,Float64}, great_circle::Bool)::Float64
118+
abs(start[1]) == 180 && return start[2]
119+
abs(endpoint[1]) == 180 && return endpoint[2]
120+
121+
return great_circle ? crossing_latitude_great_circle(start, endpoint) : crossing_latitude_flat(start, endpoint)
122+
end
123+
124+
# Normalize coordinates to ensure longitudes are between -180 and 180
125+
function normalize_coords(coords::Vector{Tuple{Float64,Float64}})::Vector{Tuple{Float64,Float64}}
126+
normalized = deepcopy(coords)
127+
all_on_antimeridian = true
128+
129+
for i in eachindex(normalized)
130+
point = normalized[i]
131+
prev_point = normalized[mod1(i-1, length(normalized))]
132+
133+
if isapprox(point[1], 180)
134+
if abs(point[2]) != 90 && isapprox(prev_point[1], -180)
135+
normalized[i] = (-180.0, point[2])
136+
else
137+
normalized[i] = (180.0, point[2])
138+
end
139+
elseif isapprox(point[1], -180)
140+
if abs(point[2]) != 90 && isapprox(prev_point[1], 180)
141+
normalized[i] = (180.0, point[2])
142+
else
143+
normalized[i] = (-180.0, point[2])
144+
end
145+
else
146+
normalized[i] = (((point[1] + 180) % 360) - 180, point[2])
147+
all_on_antimeridian = false
148+
end
149+
end
150+
151+
return all_on_antimeridian ? coords : normalized
152+
end
153+
154+
# Segment a ring of coordinates at antimeridian crossings
155+
function segment_ring(coords::Vector{Tuple{Float64,Float64}}, great_circle::Bool)::Vector{Vector{Tuple{Float64,Float64}}}
156+
segments = Vector{Vector{Tuple{Float64,Float64}}}()
157+
current_segment = Tuple{Float64,Float64}[]
158+
159+
for i in 1:length(coords)-1
160+
start = coords[i]
161+
endpoint = coords[i+1]
162+
push!(current_segment, start)
163+
164+
# Check for antimeridian crossing
165+
if (endpoint[1] - start[1] > 180) && (endpoint[1] - start[1] != 360) # left crossing
166+
lat = crossing_latitude(start, endpoint, great_circle)
167+
push!(current_segment, (-180.0, lat))
168+
push!(segments, current_segment)
169+
current_segment = [(180.0, lat)]
170+
elseif (start[1] - endpoint[1] > 180) && (start[1] - endpoint[1] != 360) # right crossing
171+
lat = crossing_latitude(endpoint, start, great_circle)
172+
push!(current_segment, (180.0, lat))
173+
push!(segments, current_segment)
174+
current_segment = [(-180.0, lat)]
175+
end
176+
end
177+
178+
# Handle last point and segment
179+
if isempty(segments)
180+
return Vector{Vector{Tuple{Float64,Float64}}}() # No crossings
181+
elseif coords[end] == segments[1][1]
182+
# Join polygons
183+
segments[1] = vcat(current_segment, segments[1])
184+
else
185+
push!(current_segment, coords[end])
186+
push!(segments, current_segment)
187+
end
188+
189+
return normalize_coords.(segments)
190+
end
191+
192+
# Check if a segment is self-closing
193+
function is_self_closing(segment::Vector{Tuple{Float64,Float64}})::Bool
194+
is_right = segment[end][1] == 180
195+
return segment[1][1] == segment[end][1] && (
196+
(is_right && segment[1][2] > segment[end][2]) ||
197+
(!is_right && segment[1][2] < segment[end][2])
198+
)
199+
end
200+
201+
# Build polygons from segments
202+
function build_polygons(segments::Vector{Vector{Tuple{Float64,Float64}}})::Vector{GI.Polygon}
203+
isempty(segments) && return GI.Polygon[]
204+
205+
segment = pop!(segments)
206+
is_right = segment[end][1] == 180
207+
candidates = Tuple{Union{Nothing,Int},Float64}[]
208+
209+
if is_self_closing(segment)
210+
push!(candidates, (nothing, segment[1][2]))
211+
end
212+
213+
for (i, s) in enumerate(segments)
214+
if s[1][1] == segment[end][1]
215+
if (is_right && s[1][2] > segment[end][2] &&
216+
(!is_self_closing(s) || s[end][2] < segment[1][2])) ||
217+
(!is_right && s[1][2] < segment[end][2] &&
218+
(!is_self_closing(s) || s[end][2] > segment[1][2]))
219+
push!(candidates, (i, s[1][2]))
220+
end
221+
end
222+
end
223+
224+
# Sort candidates
225+
sort!(candidates, by=x->x[2], rev=!is_right)
226+
227+
index = isempty(candidates) ? nothing : candidates[1][1]
228+
229+
if !isnothing(index)
230+
# Join segments and recurse
231+
segment = vcat(segment, segments[index])
232+
segments[index] = segment
233+
return build_polygons(segments)
234+
else
235+
# Handle self-joining segment
236+
polygons = build_polygons(segments)
237+
if !all(p == segment[1] for p in segment)
238+
push!(polygons, GI.Polygon([segment]))
239+
end
240+
return polygons
241+
end
242+
end
243+
244+
# Main function to cut a polygon at the antimeridian
245+
cut_at_antimeridian(x) = cut_at_antimeridian(GI.trait(x), x)
246+
247+
function cut_at_antimeridian(
248+
::GI.PolygonTrait,
249+
polygon::T;
250+
force_north_pole::Bool=false,
251+
force_south_pole::Bool=false,
252+
fix_winding::Bool=true,
253+
great_circle::Bool=true
254+
) where {T}
255+
# Get exterior ring
256+
exterior = GO.tuples(GI.getexterior(polygon)).geom
257+
exterior = normalize_coords(exterior)
258+
259+
# Segment the exterior ring
260+
segments = segment_ring(exterior, great_circle)
261+
262+
if isempty(segments)
263+
# No antimeridian crossing
264+
if fix_winding && GO.isclockwise(GI.LinearRing(exterior))
265+
coord_vecs = reverse.(getproperty.(GO.tuples.(GI.getring(polygon)), :geom))
266+
return GI.Polygon(normalize_coords.(coord_vecs))
267+
end
268+
return polygon
269+
end
270+
271+
# Handle holes
272+
holes = Vector{Vector{Tuple{Float64,Float64}}}()
273+
for hole_idx in 1:GI.nhole(polygon)
274+
hole = GO.tuples(GI.gethole(polygon, hole_idx)).geom
275+
hole_segments = segment_ring(hole, great_circle)
276+
277+
if !isempty(hole_segments)
278+
if fix_winding
279+
unwrapped = [(x % 360, y) for (x, y) in hole]
280+
if !GO.isclockwise(GI.LineString(unwrapped))
281+
hole_segments = segment_ring(reverse(hole), great_circle)
282+
end
283+
end
284+
append!(segments, hole_segments)
285+
else
286+
push!(holes, hole)
287+
end
288+
end
289+
290+
# Build final polygons
291+
result_polygons = build_polygons(segments)
292+
293+
# Add holes to appropriate polygons
294+
for hole in holes
295+
for (i, poly) in enumerate(result_polygons)
296+
if GO.contains(poly, GI.Point(hole[1]))
297+
rings = GI.getring(poly)
298+
push!(rings, hole)
299+
result_polygons[i] = GI.Polygon(rings)
300+
break
301+
end
302+
end
303+
end
304+
305+
return length(result_polygons) == 1 ? result_polygons[1] : GI.MultiPolygon(result_polygons)
306+
end
307+
308+
function cut_at_antimeridian(::GI.AbstractCurveTrait, line::T; great_circle::Bool=true) where {T}
309+
coords = GO.tuples(line).geom
310+
segments = segment_ring(coords, great_circle)
311+
312+
if isempty(segments)
313+
return line
314+
else
315+
return GI.MultiLineString(segments)
316+
end
317+
end
318+
319+
320+
function cut_at_antimeridian(::GI.MultiPolygonTrait, x; kwargs...)
321+
results = GI.Polygon[]
322+
for poly in GI.getgeom(x)
323+
result = cut_at_antimeridian(GI.PolygonTrait(), poly; kwargs...)
324+
if result isa GI.Polygon
325+
push!(results, result)
326+
elseif result isa GI.MultiPolygon
327+
append!(results, result.geom)
328+
end
329+
end
330+
return GI.MultiPolygon(results)
331+
end
332+
333+
function cut_at_antimeridian(::GI.MultiLineStringTrait, multiline::T; great_circle::Bool=true) where {T}
334+
linestrings = Vector{Vector{Tuple{Float64,Float64}}}()
335+
336+
for line in GI.getgeom(multiline)
337+
fixed = cut_at_antimeridian(GI.LineStringTrait(), line; great_circle)
338+
if fixed isa GI.LineString
339+
push!(linestrings, GO.tuples(fixed).geom)
340+
else
341+
append!(linestrings, GO.tuples.(GI.getgeom(fixed)).geom)
342+
end
343+
end
344+
345+
return GI.MultiLineString(linestrings)
346+
end
347+
348+
end

0 commit comments

Comments
 (0)