Skip to content

Commit de89cf7

Browse files
asinghvi17claude
andauthored
Fix spherical Sutherland-Hodgman intersection for adjacent polygons (#381)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3a5b9d8 commit de89cf7

File tree

2 files changed

+183
-0
lines changed

2 files changed

+183
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
/docs/src/source/
55
.vscode/
66
.DS_Store
7+
8+
benchmarks/Manifest.toml
9+
.worktrees/

test/methods/clipping/sutherland_hodgman.jl

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,3 +359,183 @@ import GeoInterface as GI
359359
end
360360
end
361361
end
362+
363+
# Spherical Sutherland-Hodgman Tests
364+
@testset "ConvexConvexSutherlandHodgman - Spherical" begin
365+
using GeometryOps.UnitSpherical: UnitSphereFromGeographic
366+
367+
# Transform lon/lat to UnitSphericalPoint
368+
_transform = UnitSphereFromGeographic()
369+
lonlat_to_point(lon, lat) = _transform((lon, lat))
370+
371+
alg = GO.ConvexConvexSutherlandHodgman(GO.Spherical())
372+
373+
@testset "Basic spherical intersection" begin
374+
# Two overlapping 2°×2° spherical cells
375+
poly_a = GI.Polygon([[
376+
lonlat_to_point(0.0, 0.0),
377+
lonlat_to_point(2.0, 0.0),
378+
lonlat_to_point(2.0, 2.0),
379+
lonlat_to_point(0.0, 2.0),
380+
lonlat_to_point(0.0, 0.0),
381+
]])
382+
383+
poly_b = GI.Polygon([[
384+
lonlat_to_point(1.0, 1.0),
385+
lonlat_to_point(3.0, 1.0),
386+
lonlat_to_point(3.0, 3.0),
387+
lonlat_to_point(1.0, 3.0),
388+
lonlat_to_point(1.0, 1.0),
389+
]])
390+
391+
result = GO.intersection(alg, poly_a, poly_b; target=GI.PolygonTrait())
392+
area = GO.area(GO.Spherical(), result)
393+
394+
# Should be approximately 1°×1° intersection area
395+
@test area > 0
396+
@test area < GO.area(GO.Spherical(), poly_a)
397+
end
398+
399+
@testset "Adjacent polygons (shared edge) - THE FIX" begin
400+
# Two 1°×1° cells sharing lon=126 edge
401+
# This is the main bug that was reported
402+
poly_a = GI.Polygon([[
403+
lonlat_to_point(125.0, 53.0),
404+
lonlat_to_point(126.0, 53.0),
405+
lonlat_to_point(126.0, 54.0),
406+
lonlat_to_point(125.0, 54.0),
407+
lonlat_to_point(125.0, 53.0),
408+
]])
409+
410+
poly_b = GI.Polygon([[
411+
lonlat_to_point(126.0, 53.0),
412+
lonlat_to_point(127.0, 53.0),
413+
lonlat_to_point(127.0, 54.0),
414+
lonlat_to_point(126.0, 54.0),
415+
lonlat_to_point(126.0, 53.0),
416+
]])
417+
418+
result_ab = GO.intersection(alg, poly_a, poly_b; target=GI.PolygonTrait())
419+
result_ba = GO.intersection(alg, poly_b, poly_a; target=GI.PolygonTrait())
420+
421+
area_ab = GO.area(GO.Spherical(), result_ab)
422+
area_ba = GO.area(GO.Spherical(), result_ba)
423+
424+
# Adjacent polygons should have zero/negligible intersection
425+
@test area_ab < 1e-10
426+
@test area_ba < 1e-10
427+
428+
# Operation should be symmetric
429+
@test area_ab area_ba atol=1e-10
430+
end
431+
432+
@testset "Vertex on edge (no overlap)" begin
433+
# Subject polygon (lon 125-126, lat 53-54)
434+
poly_a = GI.Polygon([[
435+
lonlat_to_point(125.0, 53.0),
436+
lonlat_to_point(126.0, 53.0),
437+
lonlat_to_point(126.0, 54.0),
438+
lonlat_to_point(125.0, 54.0),
439+
lonlat_to_point(125.0, 53.0),
440+
]])
441+
442+
# Polygon with vertex at (126.0, 53.5) - ON poly_a's lon=126 edge but outside
443+
poly_b = GI.Polygon([[
444+
lonlat_to_point(126.0, 53.5), # ON poly_a's lon=126 edge
445+
lonlat_to_point(127.0, 53.0),
446+
lonlat_to_point(127.0, 54.0),
447+
lonlat_to_point(126.0, 53.5),
448+
]])
449+
450+
result = GO.intersection(alg, poly_a, poly_b; target=GI.PolygonTrait())
451+
area = GO.area(GO.Spherical(), result)
452+
453+
# Should be zero, not the area of poly_b!
454+
@test area < 1e-10
455+
end
456+
457+
@testset "Overlapping spherical polygons" begin
458+
poly_a = GI.Polygon([[
459+
lonlat_to_point(125.0, 53.0),
460+
lonlat_to_point(127.0, 53.0),
461+
lonlat_to_point(127.0, 55.0),
462+
lonlat_to_point(125.0, 55.0),
463+
lonlat_to_point(125.0, 53.0),
464+
]])
465+
466+
poly_b = GI.Polygon([[
467+
lonlat_to_point(126.0, 54.0),
468+
lonlat_to_point(128.0, 54.0),
469+
lonlat_to_point(128.0, 56.0),
470+
lonlat_to_point(126.0, 56.0),
471+
lonlat_to_point(126.0, 54.0),
472+
]])
473+
474+
result = GO.intersection(alg, poly_a, poly_b; target=GI.PolygonTrait())
475+
area = GO.area(GO.Spherical(), result)
476+
477+
# Should be approximately 1°×1° = area of overlap region
478+
expected_poly = GI.Polygon([[
479+
lonlat_to_point(126.0, 54.0),
480+
lonlat_to_point(127.0, 54.0),
481+
lonlat_to_point(127.0, 55.0),
482+
lonlat_to_point(126.0, 55.0),
483+
lonlat_to_point(126.0, 54.0),
484+
]])
485+
expected_area = GO.area(GO.Spherical(), expected_poly)
486+
487+
@test area expected_area rtol=0.05
488+
end
489+
490+
@testset "Containment" begin
491+
outer = GI.Polygon([[
492+
lonlat_to_point(120.0, 50.0),
493+
lonlat_to_point(130.0, 50.0),
494+
lonlat_to_point(130.0, 60.0),
495+
lonlat_to_point(120.0, 60.0),
496+
lonlat_to_point(120.0, 50.0),
497+
]])
498+
499+
inner = GI.Polygon([[
500+
lonlat_to_point(124.0, 54.0),
501+
lonlat_to_point(126.0, 54.0),
502+
lonlat_to_point(126.0, 56.0),
503+
lonlat_to_point(124.0, 56.0),
504+
lonlat_to_point(124.0, 54.0),
505+
]])
506+
507+
result = GO.intersection(alg, outer, inner; target=GI.PolygonTrait())
508+
inner_area = GO.area(GO.Spherical(), inner)
509+
result_area = GO.area(GO.Spherical(), result)
510+
511+
@test result_area inner_area rtol=0.01
512+
end
513+
514+
@testset "Original bug report case" begin
515+
# Subject polygon (lon 125-126, lat 53-54)
516+
subject = GI.Polygon([[
517+
lonlat_to_point(125.0, 53.0),
518+
lonlat_to_point(126.0, 53.0),
519+
lonlat_to_point(126.0, 54.0),
520+
lonlat_to_point(125.0, 54.0),
521+
lonlat_to_point(125.0, 53.0),
522+
]])
523+
524+
# Clip polygon - adjacent, with vertex at (126.0, 53.23) ON subject's edge
525+
clip = GI.Polygon([[
526+
lonlat_to_point(126.0, 53.23), # ON subject's lon=126 edge!
527+
lonlat_to_point(126.86, 52.32),
528+
lonlat_to_point(127.86, 53.25),
529+
lonlat_to_point(126.95, 54.15),
530+
lonlat_to_point(126.0, 53.23),
531+
]])
532+
533+
result = GO.intersection(alg, subject, clip; target=GI.PolygonTrait())
534+
result_area = GO.area(GO.Spherical(), result)
535+
clip_area = GO.area(GO.Spherical(), clip)
536+
537+
# BUG FIX: result_area should be ~0, NOT clip_area
538+
# The ratio should be ~0, not ~1
539+
@test result_area < clip_area * 0.01 # Less than 1% of clip area
540+
end
541+
end

0 commit comments

Comments
 (0)