Skip to content

Commit 659d6b0

Browse files
committed
implement an antimeridian cutting fix ported from the python package
1 parent f33c8e4 commit 659d6b0

File tree

7 files changed

+620
-11
lines changed

7 files changed

+620
-11
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import GeoInterface as GI, GeometryOps as GO
2+
using SortTileRecursiveTree: STRtree
3+
using SparseArrays: spzeros
4+
using Extents
5+
6+
using CairoMakie, GeoInterfaceMakie
7+
8+
include("sphericalpoints.jl")
9+
10+
function area_of_intersection_operator(grid1, grid2; nodecapacity1 = 10, nodecapacity2 = 10)
11+
area_of_intersection_operator(GO.Planar(), grid1, grid2; nodecapacity1 = nodecapacity1, nodecapacity2 = nodecapacity2)
12+
end
13+
14+
function area_of_intersection_operator(m::GO.Manifold, grid1, grid2; nodecapacity1 = 10, nodecapacity2 = 10) # grid1 and grid2 are both vectors of polygons
15+
A = spzeros(Float64, length(grid1), length(grid2))
16+
# Prepare STRtrees for the two grids, to speed up intersection queries
17+
# we may want to separately tune nodecapacity if one is much larger than the other.
18+
# specifically we may want to tune leaf node capacity via Hilbert packing while still
19+
# constraining inner node capacity. But that can come later.
20+
tree1 = STRtree(grid1; nodecapacity = nodecapacity1)
21+
tree2 = STRtree(grid2; nodecapacity = nodecapacity2)
22+
# Do the dual query, which is the most efficient way to do this,
23+
# by iterating down both trees simultaneously, rejecting pairs of nodes that do not intersect.
24+
# when we find an intersection, we calculate the area of the intersection and add it to the result matrix.
25+
GO.SpatialTreeInterface.do_dual_query(Extents.intersects, tree1, tree2) do i1, i2
26+
p1, p2 = grid1[i1], grid2[i2]
27+
# may want to check if the polygons intersect first,
28+
# to avoid antimeridian-crossing multipolygons viewing a scanline.
29+
intersection_polys = try # can remove this now, got all the errors cleared up in the fix.
30+
# At some future point, we may want to add the manifold here
31+
# but for right now, GeometryOps only supports planar polygons anyway.
32+
GO.intersection(p1, p2; target = GI.PolygonTrait())
33+
catch e
34+
@error "Intersection failed!" i1 i2
35+
rethrow(e)
36+
end
37+
38+
area_of_intersection = GO.area(m, intersection_polys)
39+
if area_of_intersection > 0
40+
A[i1, i2] += area_of_intersection
41+
end
42+
end
43+
44+
return A
45+
end
46+
47+
grid1 = begin
48+
gridpoints = [(i, j) for i in 0:2, j in 0:2]
49+
[GI.Polygon([GI.LinearRing([gridpoints[i, j], gridpoints[i, j+1], gridpoints[i+1, j+1], gridpoints[i+1, j], gridpoints[i, j]])]) for i in 1:size(gridpoints, 1)-1, j in 1:size(gridpoints, 2)-1] |> vec
50+
end
51+
52+
grid2 = begin
53+
diamondpoly = GI.Polygon([GI.LinearRing([(0, 1), (1, 2), (2, 1), (1, 0), (0, 1)])])
54+
trianglepolys = GI.Polygon.([
55+
[GI.LinearRing([(0, 0), (1, 0), (0, 1), (0, 0)])],
56+
[GI.LinearRing([(0, 1), (0, 2), (1, 2), (0, 1)])],
57+
[GI.LinearRing([(1, 2), (2, 1), (2, 2), (1, 2)])],
58+
[GI.LinearRing([(2, 1), (2, 0), (1, 0), (2, 1)])],
59+
])
60+
[diamondpoly, trianglepolys...]
61+
end
62+
63+
A = area_of_intersection_operator(grid1, grid2)
64+
65+
# Now, let's perform some interpolation!
66+
area1 = vec(sum(A, dims=2))
67+
# test: @assert area1 == GO.area.(grid1)
68+
area2 = vec(sum(A, dims=1))
69+
# test: @assert area2 == GO.area.(grid2)
70+
71+
values_on_grid2 = [0, 0, 5, 0, 0]
72+
poly(grid2; color = values_on_grid2, strokewidth = 2, strokecolor = :red)
73+
74+
values_on_grid1 = A * values_on_grid2 ./ area1
75+
@assert sum(values_on_grid1 .* area1) == sum(values_on_grid2 .* area2)
76+
poly(grid1; color = values_on_grid1, strokewidth = 2, strokecolor = :blue)
77+
78+
values_back_on_grid2 = A' * values_on_grid1 ./ area2
79+
@assert sum(values_back_on_grid2 .* area2) == sum(values_on_grid2 .* area2)
80+
poly(grid2; color = values_back_on_grid2, strokewidth = 2, strokecolor = :green)
81+
# We can see here that some data has diffused into the central diamond cell of grid2,
82+
# since it was overlapped by the top left cell of grid1.
83+
84+
85+
using SpeedyWeather
86+
using GeoMakie
87+
88+
SpeedyWeatherGeoMakieExt = Base.get_extension(SpeedyWeather, :SpeedyWeatherGeoMakieExt)
89+
90+
grid1 = rand(OctaHEALPixGrid, 5 + 100)
91+
grid2 = rand(FullGaussianGrid, 4 + 100)
92+
93+
faces1 = SpeedyWeatherGeoMakieExt.get_faces(grid1)
94+
faces2 = SpeedyWeatherGeoMakieExt.get_faces(grid2)
95+
96+
polys1 = GI.Polygon.(GI.LinearRing.(eachcol(faces1))) .|> GO.fix
97+
polys2 = GI.Polygon.(GI.LinearRing.(eachcol(faces2))) .|> GO.fix
98+
99+
A = @time area_of_intersection_operator(polys1, polys2)
100+
101+
p1 = polys1[93]
102+
p2 = polys2[105]
103+
104+
f, a, p = poly(p1)
105+
poly!(a, p2)
106+
f
107+
108+
# bug found in Foster Hormann tracing
109+
# but geos also does the same thing
110+
boxpoly = GI.Polygon([GI.LinearRing([(0, 0), (2, 0), (2, 2), (0, 2), (0, 0)])])
111+
diamondpoly = GI.Polygon([GI.LinearRing([(0, 1), (1, 2), (2, 1), (1, 0), (0, 1)])])
112+
113+
diffpoly = GO.difference(boxpoly, diamondpoly; target = GI.PolygonTrait()) |> only
114+
cutpolys = GO.cut(diffpoly, GI.Line([(0, 0), (4, 0)])) # even cut misbehaves!
115+
116+
117+
118+
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import GeoInterface as GI
2+
import GeometryOps as GO
3+
import LinearAlgebra
4+
import LinearAlgebra: dot, cross
5+
6+
## Get the area of a LinearRing with coordinates in radians
7+
struct SphericalPoint{T <: Real}
8+
data::NTuple{3, T}
9+
end
10+
SphericalPoint(x, y, z) = SphericalPoint((x, y, z))
11+
12+
# define the 4 basic mathematical operators elementwise on the data tuple
13+
Base.:+(p::SphericalPoint, q::SphericalPoint) = SphericalPoint(p.data .+ q.data)
14+
Base.:-(p::SphericalPoint, q::SphericalPoint) = SphericalPoint(p.data .- q.data)
15+
Base.:*(p::SphericalPoint, q::SphericalPoint) = SphericalPoint(p.data .* q.data)
16+
Base.:/(p::SphericalPoint, q::SphericalPoint) = SphericalPoint(p.data ./ q.data)
17+
# Define sum on a SphericalPoint to sum across its data
18+
Base.sum(p::SphericalPoint) = sum(p.data)
19+
20+
# define dot and cross products
21+
LinearAlgebra.dot(p::SphericalPoint, q::SphericalPoint) = sum(p * q)
22+
function LinearAlgebra.cross(a::SphericalPoint, b::SphericalPoint)
23+
a1, a2, a3 = a.data
24+
b1, b2, b3 = b.data
25+
SphericalPoint((a2*b3-a3*b2, a3*b1-a1*b3, a1*b2-a2*b1))
26+
end
27+
28+
# Using Eriksson's formula for the area of spherical triangles: https://www.jstor.org/stable/2691141
29+
# This melts down only when two points are antipodal, which in our case will not happen.
30+
function _unit_spherical_triangle_area(a, b, c)
31+
#t = abs(dot(a, cross(b, c)))
32+
#t /= 1 + dot(b,c) + dot(c, a) + dot(a, b)
33+
t = abs(dot(a, (cross(b - a, c - a))) / dot(b + a, c + a))
34+
2*atan(t)
35+
end
36+
37+
_lonlat_to_sphericalpoint(p) = _lonlat_to_sphericalpoint(GI.x(p), GI.y(p))
38+
function _lonlat_to_sphericalpoint(lon, lat)
39+
lonsin, loncos = sincosd(lon)
40+
latsin, latcos = sincosd(lat)
41+
x = latcos * loncos
42+
y = latcos * lonsin
43+
z = latsin
44+
return SphericalPoint(x,y,z)
45+
end
46+
47+
48+
49+
# Extend area to spherical
50+
51+
# TODO: make this the other way around, but that can wait.
52+
GO.area(m::GO.Planar, geoms) = GO.area(geoms)
53+
54+
function GO.area(m::GO.Spherical, geoms)
55+
return GO.applyreduce(+, GI.PolygonTrait(), geoms) do poly
56+
GO.area(m, GI.PolygonTrait(), poly)
57+
end * ((-m.radius^2)/ 2) # do this after the sum, to increase accuracy and minimize calculations.
58+
end
59+
60+
function GO.area(m::GO.Spherical, ::GI.PolygonTrait, poly)
61+
area = abs(_ring_area(m, GI.getexterior(poly)))
62+
for interior in GI.gethole(poly)
63+
area -= abs(_ring_area(m, interior))
64+
end
65+
return area
66+
end
67+
68+
function _ring_area(m::GO.Spherical, ring)
69+
# convert ring to a sequence of SphericalPoints
70+
points = _lonlat_to_sphericalpoint.(GI.getpoint(ring))[1:end-1] # deliberately drop the closing point
71+
p1, p2, p3 = points[1], points[2], points[3]
72+
# For a spherical polygon, we can compute the area by splitting it into triangles
73+
# and summing their areas. We use the first point as a common vertex for all triangles.
74+
area = 0.0
75+
# Sum areas of triangles formed by first point and consecutive pairs of points
76+
np = length(points)
77+
for i in 1:np
78+
p1, p2, p3 = p2, p3, points[i]
79+
area += _unit_spherical_triangle_area(p1, p2, p3)
80+
end
81+
return area
82+
end
83+
84+
function _ring_area(m::GO.Spherical, ring)
85+
# Convert ring points to spherical coordinates
86+
points = GO.tuples(ring).geom
87+
88+
# Remove last point if it's the same as first (closed ring)
89+
if points[end] == points[1]
90+
points = points[1:end-1]
91+
end
92+
93+
n = length(points)
94+
if n < 3
95+
return 0.0
96+
end
97+
98+
area = 0.0
99+
100+
# Use L'Huilier's formula to sum the areas of spherical triangles
101+
# formed by first point and consecutive pairs of points
102+
for i in 1:n
103+
p1, p2, p3 = points[mod1(i-1, n)], points[mod1(i, n)], points[mod1(i+1, n)]
104+
area += sind(GI.y(p2)) * (GI.x(p3) - GI.x(p1))
105+
end
106+
107+
return area
108+
end
109+
110+
111+
112+
113+
# Test the area calculation
114+
p1 = GI.Polygon([GI.LinearRing(Point2f[(0, 0), (1, 0), (0, 1), (0, 0)] .- (Point2f(0.5, 0.5),))])
115+
116+
GO.area(GO.Spherical(), p1)

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))

0 commit comments

Comments
 (0)