Skip to content

Add spherical arc extents and manifold-aware Extents.extent#434

Open
asinghvi17 wants to merge 6 commits into
mainfrom
spherical-arc-extent
Open

Add spherical arc extents and manifold-aware Extents.extent#434
asinghvi17 wants to merge 6 commits into
mainfrom
spherical-arc-extent

Conversation

@asinghvi17

@asinghvi17 asinghvi17 commented Jul 6, 2026

Copy link
Copy Markdown
Member

Makes spherical extents first-class, in three layers. Follow-up to #432.

spherical_arc_extent — new public API in UnitSpherical

spherical_arc_extent(a, b) returns the 3D Cartesian Extent{(:X, :Y, :Z)} of the shorter great-circle arc between two points. An arc bulges out of its endpoints' bounding box (two points at z = 0.9 joined over the pole reach z = 1), so unioning endpoint extents is not a valid bound.

The computation is trig-free: with t̂ₐ = normalize(n × a) the unit tangent along the arc (normal from robust_cross_product, so nearly-degenerate/antipodal arcs stay stable), each coordinate along the arc is a sinusoid with amplitude hypot(aᵢ, t̂ₐᵢ); an interior extremum on axis i exists iff the coordinate rises at a and falls at b. Bounds get a 4-ulp pad guaranteeing containment, in the spirit of S2's S2LatLngRectBounder. (Unlike S2, no error-budget branch is needed on the extremum sign test: the amplitude is computed from the endpoint itself, so a sign misjudged within fp noise costs only a quadratically small sliver, already inside the pad — S2's max_lat is a whole-great-circle bound, which is why it needs the "latitude budget".)

Manifold-aware edge decomposition

eachedge, to_edgelist, and lazy_edgelist gain manifold-first methods: Spherical() yields edges as UnitSphericalPoint pairs (converting geographic input) whose GI.Lines carry arc extents. Rings already made of UnitSphericalPoints take this path with no manifold argument — previously they threw a MethodError from the 2D-hardcoded _lineedge.

Manifold-aware Extents.extent — the generic ring bbox

Extents.extent(m::Manifold, geom, [::Type{T} = Float64]). GO does not export extent (Extents.jl already exports that name); since the manifold is our type, the methods extend Extents.extent directly and are reachable as GO.extent:

cap = GI.Polygon([[(lon, 60.0) for lon in 0.0:30.0:360.0]])
GO.extent(GO.Spherical(), cap)   # Extent(X = …, Y = …, Z = (0.866…, 1.0)) — covers the pole
  • Planar() delegates to GI.extent.
  • Spherical(): points → degenerate boxes; LineStrings → union of edge arc extents (exact — a curve has no interior); rings and polygons → regions. An extremum of a coordinate over a region is attained on the boundary or at one of the six on-sphere critical points (±1,0,0), (0,±1,0), (0,0,±1) — a cell enclosing a pole extends past every edge extent (the caveat from the rtreeify sweep, and why odd-h_elem cubed-sphere panels break with edge-only extents: the six points are exactly the six panel centers).
  • Enclosure follows S2's loop convention (s2loop.h): "All loops are defined to have a CCW orientation, i.e. the interior of the loop is on the left side of the edges. This implies that a clockwise loop enclosing a small area is interpreted to be a CCW loop enclosing a very large area." A CW cap ring therefore yields the complement's extent, exactly per S2.
  • Enclosure of each axis point is decided by crossing parity, the way S2Loop::InitBound/Contains decide pole containment (s2loop.cc): pick an anchor edge whose great circle misses the query point q; which side of it q lies on is the side the arc from the anchor's midpoint to q departs into (left = interior; the arc can re-meet that great circle only at the midpoint's antipode, out of reach for an arc under a half turn), and each transversal boundary crossing along the arc flips it. Boundary points count as contained.
  • Where S2 resolves degeneracies with exact predicates + symbolic perturbation, this detects them through spherical_orient's tolerance band and retries with the next anchor edge; if every anchor is degenerate the axis extends to ±1 — possibly loose, never under-covering. Two systematic configurations resolve instead of retrying, since no anchor can fix them: a query on an edge's great circle but off the edge contributes zero crossings (lonlat meridian edges pass through ±eₓ/±e_y exactly), and a ring vertex exactly antipodal to the query contributes zero (polar lonlat cells, whose pole vertex sits on every test circle through the far pole).

Compared to the winding-number + signed-area heuristic this replaces, parity is a per-point answer with no preconditions: the two previously documented approximations (single edges sweeping more than a half turn about an axis; regions containing both poles at small area) are gone by construction — the new dumbbell test (both polar caps joined by a thin corridor) is exactly the case winding could not see.

Also fixed

to_unit_spherical_points reinterpreted the first two Cartesian coordinates of UnitSphericalPoint input as geographic lon/lat (its docstring claimed pass-through). It now maps points through the UnitSphericalPoint constructor, which actually passes them through.

Tests (~1,000 new assertions)

  • spherical_arc_extent: equatorial exactness, over-the-pole bulge, geographic parity, degenerate arcs, 100 random arcs × dense slerp containment incl. nearly-degenerate
  • Edge machinery: Spherical() and USP-ring paths agree, lazy variant agrees, NaturalIndex/RTree index in 3D, bulge-only R-tree query regression
  • extent: CCW cap covers the pole; CW cap = complement (X/Y extend to ±1, Z min to −1, Z max stays on boundary); geographic polar polygon; larger-than-hemisphere cap; pole-vertex cells; dumbbell region enclosing both poles through a thin corridor; all 12 polar lonlat cells (exact pole vertex + duplicate point, no far-pole leak); axis point on the boundary clamps that bound to exactly ±1; multis/collections; LineStrings stay curves; 50 random cells × dense boundary/interior samples + axis-point coverage via the star-shaped inradius criterion + reversed-ring duality (complement box covers outside axis points; the pair of boxes covers all six)

🤖 Generated with Claude Code

asinghvi17 and others added 4 commits July 6, 2026 16:27
A great-circle arc bulges out of its endpoints' bounding box, so edge
extents built from endpoints alone silently miss queries that touch only
the bulge.  `spherical_arc_extent(a, b)` computes the true 3D Cartesian
extent of the shorter arc: each coordinate along the arc is a sinusoid,
so an interior extremum exists iff the coordinate rises at one endpoint
and falls at the other, where it attains the sinusoid's amplitude — no
trigonometric calls, with tangents from `robust_cross_product` so
nearly-degenerate arcs stay stable.  Bounds are padded by a few ulps to
guarantee containment, as S2's `S2LatLngRectBounder` does.

`eachedge`, `to_edgelist`, and `lazy_edgelist` gain manifold-first
methods: `Spherical()` yields edges as `UnitSphericalPoint` pairs (converting
geographic input) whose `GI.Line`s carry arc extents, and rings that are
already `UnitSphericalPoint`s take that path with no manifold argument.
Previously `_lineedge` threw a `MethodError` for such rings.  Spatial
trees built over these edges (`NaturalIndex`, `RTree`) therefore index
spherical edges in 3D, correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`Extents.extent(m::Manifold, geom, [T])` computes extents on a
manifold: `Planar()` delegates to `GI.extent`; `Spherical()` returns 3D
Cartesian extents on the unit sphere.  Curves are covered by the union
of their edges' arc extents.  Rings and polygons are regions under
S2's loop convention (CCW, interior on the left): an extremum over a
region lies on the boundary or at one of the six axis points `±eᵢ`, so
the boundary extent is widened per axis by an enclosure check from the
ring's winding number and signed area.  GO does not export `extent`;
the methods extend `Extents.extent`, reachable as `GO.extent`.

Also fix `to_unit_spherical_points` to pass `UnitSphericalPoint`s
through unchanged instead of reinterpreting their first two Cartesian
coordinates as geographic longitude/latitude.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@asinghvi17 asinghvi17 changed the title Add spherical_arc_extent and spherical manifold edge decomposition Add spherical arc extents and manifold-aware Extents.extent Jul 7, 2026
Axis-point enclosure in `_spherical_region_extent` is now decided the way
`S2Loop::InitBound` decides pole containment: the side of an anchor edge
gives the departure side of the arc from the query point to the anchor's
midpoint, and the parity of transversal boundary crossings flips it.
Degenerate configurations retry with the next anchor and fall back to a
conservative extension, never an under-covering box.  This removes both
documented approximations of the winding approach: a region containing
both poles at small area (dumbbell) and edges sweeping near an axis are
now handled by construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@asinghvi17 asinghvi17 force-pushed the spherical-arc-extent branch from 46c76e2 to 5ee72be Compare July 8, 2026 00:35
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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