Skip to content

Spherical Predicates ​

This file contains geometric predicates for spherical geometry on the unit sphere. These predicates determine spatial relationships between points and arcs on the sphere.

julia
"""
    spherical_orient(a::UnitSphericalPoint, b::UnitSphericalPoint, c::UnitSphericalPoint) -> Int

Classify `c` against the directed great-circle arc `a → b`.

Returns:
- `1` if `c` is to the left of the arc (counter-clockwise)
- `-1` if `c` is to the right of the arc (clockwise)
- `0` if `c` is on the great circle (collinear)

Uses `robust_cross_product` for numerical stability with nearly identical or antipodal points.

Examples

julia
```jldoctest
using GeometryOps.UnitSpherical: UnitSphericalPoint, spherical_orient
a = UnitSphericalPoint(1.0, 0.0, 0.0)
b = UnitSphericalPoint(0.0, 1.0, 0.0)
c = UnitSphericalPoint(0.0, 0.0, 1.0)
spherical_orient(a, b, c)

output

julia
1
```

Extended help

julia
# Why this does not simply call `robust_cross_product`

Use unnormalized `cross(a - b, a + b)` and a squared degeneracy test to avoid normalization
and square roots. Boundary rounding may change zero to a sign, but cannot reverse a sign.

Nearly equal or antipodal endpoints use `robust_cross_product`.
"""
function spherical_orient(a::UnitSphericalPoint, b::UnitSphericalPoint, c::UnitSphericalPoint)

The orientation is determined by sign((a × b) · c).

Use the stable unnormalized cross(a - b, a + b) = 2(a × b), expanded componentwise.

julia
    d1 = a[1] - b[1]; d2 = a[2] - b[2]; d3 = a[3] - b[3]
    s1 = a[1] + b[1]; s2 = a[2] + b[2]; s3 = a[3] + b[3]
    n1 = d2 * s3 - d3 * s2
    n2 = d3 * s1 - d1 * s3
    n3 = d1 * s2 - d2 * s1
    nsqr = n1 * n1 + n2 * n2 + n3 * n3

Same stability criterion robust_cross_product applies internally.

julia
    kmin = min_stable_norm(promote_type(eltype(a), eltype(b)))
    if nsqr >= kmin * kmin
        dot_product = n1 * c[1] + n2 * c[2] + n3 * c[3]
        tol = eps(Float64) * 16  # Same tolerance as S2 geometry

abs(dot_product) / sqrt(nsqr) < tol, without the sqrt

julia
        dot_product * dot_product < (tol * tol) * nsqr && return 0
        return dot_product > 0 ? 1 : -1
    end

Recover the normal of nearly equal or antipodal endpoints with exact arithmetic and symbolic perturbation.

julia
    n = robust_cross_product(a, b)
    dot_product = n ⋅ c

Use a tolerance for near-zero values

julia
    tol = eps(Float64) * 16  # Same tolerance as S2 geometry
    if abs(dot_product) < tol
        return 0
    end
    return dot_product > 0 ? 1 : -1

return ExactPredicates.orient(a, b, UnitSphericalPoint((0., 0., 0.)), c)

julia
end

Convenience method for raw vectors

julia
function spherical_orient(a::AbstractVector, b::AbstractVector, c::AbstractVector)
    return spherical_orient(
        UnitSphericalPoint(a),
        UnitSphericalPoint(b),
        UnitSphericalPoint(c)
    )
end

exact_spherical_orient ​

julia
#= Expand the determinant because `ExactPredicates.det` is internal.
Pass `a` through two formal parameters to keep the accumulator multihomogeneous:
each input gate can belong to only one group.

The bound holds for all four input tuples, including the equal-parameter case.
The exact polynomial remains `det[a; b-a; c-a]`. =#
ExactPredicates.Codegen.@genpredicate function _exact_spherical_orient(
        a1 :: 3, a2 :: 3, b :: 3, c :: 3)
    u = b - a2
    v = c - a2
    ExactPredicates.Codegen.group!(a1...)
    ExactPredicates.Codegen.group!(u...)
    ExactPredicates.Codegen.group!(v...)
    a1[1] * (u[2]*v[3] - u[3]*v[2]) -
    a1[2] * (u[1]*v[3] - u[3]*v[1]) +
    a1[3] * (u[1]*v[2] - u[2]*v[1])
end

#= ExactPredicates requires `Float64`. Widening `Float16` or `Float32` is lossless,
so the result remains the exact sign of the input determinant. =#
@inline _ep_widen(p) = (Float64(p[1]), Float64(p[2]), Float64(p[3]))
@inline _ep_widen(p::UnitSphericalPoint{Float64}) = p
@inline _ep_widen(p::NTuple{3, Float64}) = p

"""
    exact_spherical_orient(a, b, c) -> Int

Return the exact sign of `(a × b) · c`: 1 for left, -1 for right, and 0 for coplanarity with
the origin. Inputs need not be unit vectors.

Extended help

julia
# Why this exists alongside `spherical_orient`

The `eps*16` band in `spherical_orient` can classify cell-scale crossings as
collinear. Exact signs preserve their topology.

# Why not `ExactPredicates.orient(a, b, c, (0, 0, 0))`

Grouping unit vectors separately gives a filter bound near `5e-15`, even for nearby points.
Instead use `det[a; b; c] == det[a; b-a; c-a]`, whose bound scales with separation squared.

Exact orientation costs about twice a plain triple product and 1.2 times
`spherical_orient`.

Unresolved signs use exact arithmetic. Non-finite coordinates are outside the contract and
throw.

# Narrower float widths

Widen `Float16` and `Float32` losslessly to `Float64`; preserve `Float64` inputs unchanged.
"""
@inline function exact_spherical_orient(a, b, c)
    a64 = _ep_widen(a)
    return _exact_spherical_orient(a64, a64, _ep_widen(b), _ep_widen(c))
end

"""
    point_on_spherical_arc(p::UnitSphericalPoint, a::UnitSphericalPoint, b::UnitSphericalPoint) -> Bool

Return whether `p` lies on the shorter great-circle arc from `a` to `b`, including endpoints.

Examples

julia
```jldoctest
using GeometryOps.UnitSpherical: UnitSphericalPoint, point_on_spherical_arc
a = UnitSphericalPoint(1.0, 0.0, 0.0)
b = UnitSphericalPoint(0.0, 1.0, 0.0)
mid = UnitSphericalPoint(1/√2, 1/√2, 0.0)
point_on_spherical_arc(mid, a, b)

output

julia
true
```
"""
function point_on_spherical_arc(p::UnitSphericalPoint, a::UnitSphericalPoint, b::UnitSphericalPoint)

First check: is p on the great circle through a and b?

julia
    if spherical_orient(a, b, p) != 0
        return false
    end

Test the cosine span conditions (a · p) ≥ (a · b) and (b · p) ≥ (a · b). Unit-vector dot products are angle cosines.

julia
    ab = a ⋅ b  # cos(angle between a and b)
    ap = a ⋅ p  # cos(angle between a and p)
    bp = b ⋅ p  # cos(angle between b and p)

    tol = eps(Float64) * 16

p is on arc if it's "closer" to both endpoints than they are to each other (in terms of angle, so larger dot product)

julia
    return (ap ≥ ab - tol) && (bp ≥ ab - tol)
end

"""
    spherical_ring_contains(pts, n, q; orient, on_arc, proper_crossing) -> Union{Bool, Nothing}

Test the closed region left of `pts[1:n]`, with implicit closure. Boundary points count as
contained. Clockwise rings contain the complement under the S2 convention.

Use anchor-edge side and crossing parity, as in `S2Loop::Contains` / `InitBound`. Skip
degenerate anchors; return `nothing` if none works, requiring conservative handling.

Injected predicates receive unchanged input points; only the reference midpoint is normalized.
Defaults require unit input; scale-invariant replacements may accept non-unit directions.

- `orient(a, b, c)`: sign-valued orientation of `c` against the oriented
  great circle through `a, b`; default `spherical_orient`.
- `on_arc(q, a, b)::Bool`: boundary membership; default
  `point_on_spherical_arc`.  Pass `Returns(false)` when boundary
  points are already classified.
- `proper_crossing(q, m, a, b)::Int`: 1 for a transversal interior crossing, 0 for none, or -1
  if undecidable. Called after strict orientation straddling; defaults to tolerance-banded
  `robust_cross_product`.
"""
function spherical_ring_contains(pts, n, q;
        orient = spherical_orient,
        on_arc = point_on_spherical_arc,
        proper_crossing = _hemisphere_proper_crossing)
    return _ring_contains(pts, n, q, orient, on_arc, proper_crossing)
end

#= Bind predicate types explicitly so forwarded callbacks specialize without
dynamic dispatch or per-edge allocation. =#
function _ring_contains(pts, n, q, orient::O, on_arc::OA, proper_crossing::PC) where {O, OA, PC}
    for j in 1:n
        on_arc(q, pts[j], pts[mod1(j + 1, n)]) && return true
    end
    nq = norm(q)
    for j in 1:n
        a, b = pts[j], pts[mod1(j + 1, n)]
        a == b && continue
        side = orient(a, b, q)
        side == 0 && continue
        mid = a + b

near-antipodal edge: the midpoint direction is unstable

julia
        norm(mid) < 1e-9 * (norm(a) + norm(b)) && continue
        m = UnitSphericalPoint(normalize(mid))

test arc q → m would span a half turn

julia
        dot(q, m) < (-1 + 1e-9) * nq && continue
        crossings = 0
        ok = true
        for k in 1:n
            k == j && continue
            c = _arc_crossing_parity(q, m, pts[k], pts[mod1(k + 1, n)]; orient, proper_crossing)
            if c == -1
                ok = false
                break
            end
            crossings += c
        end
        ok || continue

walking from m toward q departs onto q's side of the anchor edge (the arc meets that great circle again only at -m); positive side is the interior, and each crossing flips it

julia
        return isodd(crossings) ? side < 0 : side > 0
    end
    return nothing
end

"""
    spherical_exterior_anchor(pts, n) -> Union{UnitSphericalPoint{Float64}, Nothing}

Return the antipode of normalized vertex mass, the exterior reference for enclosed-region
semantics. For rings well below a hemisphere, it lies outside the vertex cap.

Return `nothing` when mass norm is below `1e-6` per vertex. Near-hemisphere or symmetric rings
then require the winding-based fallback in `spherical_ring_contains`.
"""
function spherical_exterior_anchor(pts, n)
    n == 0 && return nothing
    mass = normalize(SVector{3, Float64}(pts[1]))
    for i in 2:n
        mass += normalize(SVector{3, Float64}(pts[i]))
    end
    norm(mass) <= 1e-6 * n && return nothing
    return UnitSphericalPoint(-normalize(mass))
end

"""
    spherical_ring_encloses(pts, n, q;
        anchor, orient, on_arc, proper_crossing) -> Union{Bool, Nothing}

Test winding-independent even-odd containment in `pts[1:n]`, including the boundary and
implicit closing edge. Count crossings from `q` to the exterior `anchor`.

The default anchor is `spherical_exterior_anchor`. Parity gives even-odd semantics for
self-intersections, including both lobes of a figure-eight.

Return `nothing` in these cases; callers must fall back conservatively:

- `anchor === nothing` (degenerate vertex mass, see
  `spherical_exterior_anchor`);
- `q` is (nearly) antipodal to the anchor (the test arc is ill-defined:
  `q` sits at the center of the vertex mass);
- the anchor lies exactly ON a ring edge (the test arc ends on the ring);
  or
- `proper_crossing` reports a crossing as too close to call (`-1`; never
  with exact injected predicates).

Inject predicates as in `spherical_ring_contains`. `on_test_arc(v, a, b)` tests closed
minor-arc span for a point already known to be on the great circle.
"""
function spherical_ring_encloses(pts, n, q;
        anchor = spherical_exterior_anchor(pts, n),
        orient = spherical_orient,
        on_arc = point_on_spherical_arc,
        on_test_arc = point_on_spherical_arc,
        proper_crossing = _hemisphere_proper_crossing)
    _on_ring_boundary(pts, n, q, on_arc) && return true
    anchor === nothing && return nothing
    return _ring_encloses_parity(pts, n, q, anchor, orient, on_test_arc, proper_crossing)
end

Boundary scan, with on_arc bound to a type parameter so it specializes.

julia
function _on_ring_boundary(pts, n, q, on_arc::OA) where {OA}
    for j in 1:n
        on_arc(q, pts[j], pts[mod1(j + 1, n)]) && return true
    end
    return false
end

#= Pass the anchor positionally and bind predicate types to specialize the parity walk.
This avoids union-typed keyword values and dynamically dispatched callbacks per edge. =#
function _ring_encloses_parity(pts, n, q, z, orient::O, on_test_arc::OT,
        proper_crossing::PC) where {O, OT, PC}

test arc q → z would span (nearly) a half turn

julia
    dot(q, z) < (-1 + 1e-9) * norm(q) && return nothing
    crossings = 0
    for k in 1:n
        c = _anchor_crossing_parity(q, z, pts[k], pts[mod1(k + 1, n)];
            orient, on_test_arc, proper_crossing)
        c == -1 && return nothing
        crossings += c
    end
    return isodd(crossings)
end

Count crossings of the test arc q → z against edge a → b, resolving exact degeneracies with S2-style symbolic vertex crossing.

For sa == 0 or sb == 0, count the endpoint only if it lies on the closed test arc and its neighbor lies strictly positive. Incident edges then preserve parity.

For sm == 0, the circles meet at ±z; the test arc reaches only z. Return -1 if the edge contains the anchor, otherwise 0.

Return 0 when q lies on the edge's circle but outside the edge, or an edge vertex equals −q, as in _arc_crossing_parity.

julia
function _anchor_crossing_parity(q, z, a, b; orient::O, on_test_arc::OT,
        proper_crossing::PC) where {O, OT, PC}
    (a == -q || b == -q) && return 0
    a == b && return 0
    sa = orient(q, z, a)
    sb = orient(q, z, b)
    if sa == 0 || sb == 0
        if sa == 0 && sb == 0

Collinear edge: neighbors determine parity unless it contains the anchor.

julia
            return on_test_arc(z, a, b) ? -1 : 0
        end
        von, s_off = sa == 0 ? (a, sb) : (b, sa)
        return (s_off > 0 && on_test_arc(von, q, z)) ? 1 : 0
    end
    (sa > 0) == (sb > 0) && return 0
    sq = orient(a, b, q)
    sq == 0 && return 0
    sm = orient(a, b, z)
    if sm == 0
        return on_test_arc(z, a, b) ? -1 : 0
    end
    (sq > 0) == (sm > 0) && return 0
    return _proper_crossing_with_orients(proper_crossing, orient, q, z, a, b, sa, sb, sq, sm)
end

Return crossing parity of q → m against a → b: 1 for crossing, 0 for none, -1 if undecidable. Exact orientation makes only exact incidences undecidable.

julia
function _arc_crossing_parity(q, m, a, b; orient::O, proper_crossing::PC) where {O, PC}

a vertex at -q lies on every great circle through q; its edges can reach the test arc only at q itself, excluded by the on-boundary check

julia
    (a == -q || b == -q) && return 0
    sa = orient(q, m, a)
    sb = orient(q, m, b)
    (sa == 0 || sb == 0) && return -1
    (sa > 0) == (sb > 0) && return 0

If q lies on the edge's circle but outside the edge, the circles meet only at ±q and cannot cross inside the test arc.

julia
    sq = orient(a, b, q)
    sq == 0 && return 0
    sm = orient(a, b, m)
    sm == 0 && return -1
    (sq > 0) == (sm > 0) && return 0
    return _proper_crossing_with_orients(proper_crossing, orient, q, m, a, b, sa, sb, sq, sm)
end

Allow opt-in callbacks to reuse four orientation signs. Other callbacks retain the four-point protocol and its undecidable result.

julia
@inline function _proper_crossing_with_orients(proper_crossing::PC, orient::O,
        q, m, a, b, sa, sb, sq, sm) where {PC, O}
    return proper_crossing(q, m, a, b)
end

Require the circle intersection direction to lie in both arc hemispheres. Strict endpoint signs select one candidate per arc. Uses a tolerance band and unit input.

julia
function _hemisphere_proper_crossing(q, m, a, b)
    x = cross(normalize(robust_cross_product(q, m)),
              normalize(robust_cross_product(a, b)))
    d1 = dot(x, q + m)
    d2 = dot(x, a + b)
    tol = 16 * eps(Float64) * norm(x)
    (abs(d1) <= tol || abs(d2) <= tol) && return -1
    return (d1 > 0) == (d2 > 0) ? 1 : 0
end

This page was generated using Literate.jl.