Skip to content

Fix contains() for polygon edges spanning exactly 180° of longitude#29

Open
gistrec wants to merge 1 commit into
masterfrom
fix/intersects-180deg-edge
Open

Fix contains() for polygon edges spanning exactly 180° of longitude#29
gistrec wants to merge 1 commit into
masterfrom
fix/intersects-180deg-edge

Conversation

@gistrec

@gistrec gistrec commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Bug

detail::intersects() dropped a guard that upstream PolyUtil has:

if (lng2 <= -PI) { return false; }

contains() normalizes every edge so its first endpoint is at longitude 0 and the second at lng2 = wrap(lng2 - lng1, -π, π). When the endpoints are exactly 180° of longitude apart (e.g. 0 → 180, -90 → 90, 20 → -160 — any pair of "clean" coordinates X and X ± 180), wrap yields exactly . Such an edge is ambiguous — two equal great-circle arcs connect its endpoints — and upstream's convention is to never count it as an intersection of the South-Pole test ray.

Without the guard the port fell through:

  • geodesic: tan_lat_gc divides by sin(-π) ≈ -1.2e-16, producing ±1.5e15 garbage whose comparison flips the ray-casting parity arbitrarily;
  • rhumb: mercator_lat_rhumb returns a deterministic value for a direction upstream never evaluates — silent divergence.

Impact

Sweep of 35×72 query points against polygons containing one 180°-spanning edge, port vs upstream semantics:

Polygon geodesic mismatches rhumb mismatches
{(10,0), (-20,180), (40,90)} 1246 / 2448 671 / 2448
{(0,-90), (30,90), (60,0)} 11 516
{(-10,20), (15,-160), (5,-60)} 14 608

Minimal repro: contains({0,-90}, {{10,0},{-20,180},{40,90}}) returned true; upstream (and now this port) returns false.

Fix

One early return in detail::intersects() after the pole checks, mirroring upstream. Behavior on polygons without 180°-spanning edges is unchanged — verified bit-identical on 3000 benchmark queries (regular polygons of 10/100/1000 vertices).

Performance

Paired benchmarks (10 interleaved base/fixed runs, median of per-pair deltas, Apple M1):

Harness Mode base fixed delta
10 queries × 8-vertex polygon geodesic 504.5 ns 527.4 ns +5.6% (≈ +2.9 ns/call)
10 queries × 8-vertex polygon rhumb 510.5 ns 540.2 ns +6.0% (≈ +3.1 ns/call)
BM_GeoUtils_Contains/10 (repo harness) rhumb 95.1 µs 75.2 µs -20.3%
BM_GeoUtils_Contains/100 rhumb 580.8 µs 446.6 µs -23.5%
BM_GeoUtils_Contains/1000 rhumb 4.05 ms 3.95 ms -2.2%

The real arithmetic cost is one well-predicted compare per candidate edge (sub-ns); the swings in both directions are compiler inlining/code-layout effects, not algorithmic changes (results are bit-identical). Polygons that do contain 180° edges get strictly faster (early out instead of trig).

Tests

New TEST(Poly, contains_180deg_edge): the regression triangle above (both modes) plus a degenerate two-point "hemisphere" {(0,0),(0,180)} where every edge spans 180°. Suite: 35/35 green.

Note

Behavior change for affected inputs — worth bundling into the next minor release together with #24#27.

Port the missing upstream guard (PolyUtil: `if (lng2 <= -PI) return false`)
in detail::intersects(). Without it, an edge whose endpoints are exactly
180 degrees of longitude apart (wrap yields -pi) fell through to
tan_lat_gc, dividing by sin(-pi) ~ -1.2e-16 and flipping the ray-casting
parity arbitrarily; rhumb mode silently diverged from upstream.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant