Spherical Geometry in sf

Today

  1. SΒ² and RΒ² β€” two models of space, one hidden default
  2. When flat breaks β€” antimeridian rendering and predicate failures
  3. A polygon has two sides β€” the core challenge of spherical geometry
  4. Semi-open boundaries β€” why adjacent polygons share more than a border
  5. Discussion β€” when does it matter in practice?

SΒ² and RΒ²

Two models of space

RΒ² (flat)

  • Points are (x, y) on an infinite plane
  • Used by GEOS β€” the geometry engine behind QGIS, PostGIS, sf historically
  • Assumes straight lines, Euclidean distances
  • Appropriate for projected CRS

SΒ² (sphere)

  • Points are (lon, lat) on a unit sphere
  • Used by s2 β€” Google’s C++ spherical geometry library, wrapped by the s2 R package
  • Lines follow great circles; distances are geodesic
  • Appropriate for geographic CRS
  • Powers Google Maps, Earth Engine, and BigQuery GIS

The switch

  • Since sf version 1.0 (2021), geographic coordinates automatically use s2 instead of GEOS.
  • Same function call; different geometry engine; potentially very different results
  • The switch affects: st_area(), st_length(), st_distance(), st_buffer(), all predicates
sf_use_s2()        # check current engine
[1] TRUE
sf_use_s2(FALSE)   # switch to GEOS (flat, RΒ²)
sf_use_s2(TRUE)    # switch back to s2 (spherical, SΒ²)

When flat breaks: the antimeridian

Same two endpoints β€” completely different rendering:

When flat breaks: predicates

The same polygon, the same point β€” different answer:

sf_use_s2(FALSE)
st_contains(pol, pole)   # RΒ²: pole is outside
Sparse geometry binary predicate list of length 1, where the predicate
was `contains'
 1: (empty)
sf_use_s2(TRUE)
st_contains(pol, pole)   # SΒ²: pole is inside
Sparse geometry binary predicate list of length 1, where the predicate
was `contains'
 1: 1

A polygon has two sides

Two regions from one ring

In RΒ²: a polygon ring encloses a bounded area. The outside extends to infinity β€” obviously β€œoutside.”

In SΒ²: a ring on a sphere divides it into exactly two finite regions. There is no infinity. Which side is β€œinside” depends entirely on ring direction.

# Same 4 corners β€” different winding order
sq_ccw <- st_sfc(st_polygon(list(matrix(          # CCW β†’ small region
  c(-10,-10, 10,-10, 10,10, -10,10, -10,-10),
  ncol = 2, byrow = TRUE))), crs = 4326)

sq_cw  <- st_sfc(st_polygon(list(matrix(          # CW  β†’ large region
  c(-10,-10, -10,10, 10,10, 10,-10, -10,-10),
  ncol = 2, byrow = TRUE))), crs = 4326)
units::set_units(st_area(sq_ccw), "km^2")   # small square
4969697 [km^2]
units::set_units(st_area(sq_cw),  "km^2")   # rest of Earth
4969697 [km^2]

Ring direction

  • In SΒ²: interior is the region to the left when walking the boundary
  • CCW ring (counter-clockwise) β†’ interior is the small side β€” same as RΒ²
  • CW ring (clockwise) β†’ interior is the large side β€” the β€œrest of the globe”
Ring RΒ² behaviour SΒ² behaviour
CCW exterior Standard polygon Small-side interior
CW exterior Invalid / hole Large-side interior
  • st_as_s2(x, oriented = FALSE) auto-corrects ring direction, assuming the intended interior is less than half the sphere

Stop and think

You are building a global land cover database. One of the classes is ocean.

How would you represent the ocean as a geometry in a GIS?

Discuss in pairs. Consider:

  • What are its boundaries?
  • Can you define it as a simple polygon in RΒ²?
  • What about in SΒ²?

The ocean is the complement of all land polygons β€” it has no outer boundary, only inner ones.

In RΒ²: impossible to represent directly (unbounded exterior).

In SΒ²: the whole sphere is a valid polygon.

land  <- st_as_sf(s2_data_countries())
earth <- st_as_sfc("POLYGON FULL", crs = "EPSG:4326")
ocean <- st_difference(earth, st_union(land))
# Fraction of Earth's surface covered by ocean
as.numeric(st_area(ocean) / st_area(earth))
[1] 0.288699

Semi-open boundaries

The shared edge problem

Adjacent polygons share an edge. Under SΒ² (or GEOS with model = "closed"):

  • A point on the shared edge intersects both polygons
  • For spatial joins or point-in-polygon counts: double-counting
# Default: closed model β€” adjacent counties intersect each other
st_intersects(nc[1:3, ], nc[1:3, ], sparse = FALSE)
      [,1] [,2]  [,3]
[1,]  TRUE TRUE FALSE
[2,]  TRUE TRUE  TRUE
[3,] FALSE TRUE  TRUE
# Semi-open: each county only intersects itself
st_intersects(nc[1:3, ], nc[1:3, ], sparse = FALSE, model = "semi-open")
      [,1]  [,2]  [,3]
[1,]  TRUE FALSE FALSE
[2,] FALSE  TRUE FALSE
[3,] FALSE FALSE  TRUE

The model parameter controls boundary inclusion:

Model Boundary belongs to Use when
"closed" (default) Both polygons Spatial filters, overlap detection
"open" Neither polygon Points strictly inside only
"semi-open" Exactly one polygon Coverage operations, point-in-polygon counts, raster analogy

The semi-open model mirrors how raster grids work: each cell owns its upper-left corner and upper/left edges β€” no gaps, no double-counting.

Practical consequences

How different are measurements?

For typical geographic analyses: negligible.

Measure Relative difference (s2 vs flat) Absolute example
Area Β±0.007% ~7 kmΒ² per 100 000 kmΒ²
Length Β±0.013% ~13 m per 100 km
Distance up to ~0.5% ~1–2 km over 500 km

For continental or global scale: not negligible. The antimeridian is the clearest failure case.

When to use each engine

sf_use_s2(TRUE)   # default β€” always correct for geographic CRS
sf_use_s2(FALSE)  # fall back to GEOS when:
                  #  β€’ working with projected CRS (s2 adds no benefit)
                  #  β€’ an operation is not yet in s2 (e.g. st_voronoi())
                  #  β€’ debugging: compare results between engines

Rule of thumb:

  • Geographic CRS (degrees) β†’ leave s2 on
  • Projected CRS (metres) β†’ irrelevant which engine; GEOS is used anyway
  • Operation fails or warns β†’ check if the operation is implemented in s2; fall back if needed

Discussion

Discussion challenge

Your colleague downloads Swiss municipality boundaries in EPSG:4326 and runs:

municipalities |>
  st_join(cantons, join = st_intersects) |>
  group_by(canton_id) |>
  summarise(total_pop = sum(pop))

Some municipalities on cantonal borders appear in two cantons and their population is counted twice.

  • What is causing this?
  • How do you fix it?
  • Are there cases where model = "closed" is actually the right choice?

Discuss in groups

No external sources β€” reason from what you know about s2 boundary models

References