Skip to content

Commit 7ed2002

Browse files
asinghvi17claude
andauthored
Harden spherical routines for near-antipodal inputs (S2 parity) (#399)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6fadd17 commit 7ed2002

6 files changed

Lines changed: 657 additions & 43 deletions

File tree

src/utils/UnitSpherical/point.jl

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,14 @@ GI.crs(::UnitSphericalPoint) = GFT.ProjString("+proj=cart +R=1 +type=crs") # TOD
114114
"""
115115
spherical_distance(x::UnitSphericalPoint, y::UnitSphericalPoint)
116116
117-
Compute the spherical distance between two points on the unit sphere.
118-
Returns a `Number`, usually Float64 but that depends on the input type.
117+
Compute the great-circle distance (central angle, in radians) between two
118+
points on the unit sphere. Returns a `Number` whose type follows from the
119+
inputs.
120+
121+
Uses the `atan2(‖x × y‖, x · y)` form, which is numerically stable across
122+
the full `[0, π]` range — unlike `acos(x · y)`, which loses precision for
123+
nearly-identical points. Adapted from Google's S2 geometry library
124+
(see [`Vector3::Angle`](https://github.com/google/s2geometry/blob/a4f0cf58a9cfc214585c39de6e3682384fac0917/src/s2/util/math/vector.h#L492)).
119125
120126
# Extended help
121127
@@ -135,7 +141,7 @@ julia> spherical_distance(UnitSphericalPoint(1, 0, 0), UnitSphericalPoint(1, 0,
135141
0.0
136142
```
137143
"""
138-
spherical_distance(x::UnitSphericalPoint, y::UnitSphericalPoint) = acos(clamp(x y, -1.0, 1.0))
144+
spherical_distance(x::UnitSphericalPoint, y::UnitSphericalPoint) = atan(norm(cross(x, y)), x y)
139145

140146
# ## Random points
141147
Random.rand(rng::Random.AbstractRNG, ::Random.SamplerType{UnitSphericalPoint}) = rand(rng, UnitSphericalPoint{Float64})

src/utils/UnitSpherical/robustcrossproduct/RobustCrossProduct.jl

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,13 @@ function exact_cross_product(a::AbstractVector{T}, b::AbstractVector{T}) where T
211211
big_b = BigFloat.(b; precision=512)
212212
result_xf = cross(big_a, big_b)
213213

214-
# Check if we got a non-zero result
215-
# This is equivalent to s2's `s2pred::IsZero`.
216-
if !all(<=(1e-300), abs.(result_xf))
214+
# Check if we got a non-zero result. Matches S2's s2pred::IsZero at
215+
# s2predicates_internal.h:77-79, which is a literal sign == 0 test on
216+
# each component (i.e., all three components exactly zero). We must NOT
217+
# use a magnitude threshold here; any non-zero BigFloat result — even
218+
# deeply subnormal by Float64 standards — should go through the exact
219+
# rescaling path in `normalizableFromExact`.
220+
if !(iszero(result_xf[1]) && iszero(result_xf[2]) && iszero(result_xf[3]))
217221
return normalizableFromExact(result_xf)
218222
end
219223

@@ -322,9 +326,11 @@ function symbolic_cross_product_sorted(a::AbstractVector{T}, b::AbstractVector{T
322326
@assert b[1] == 0 && b[2] == 0 "Expected both b[1] and b[2] to be zero"
323327

324328
if a[1] != 0 || a[2] != 0
325-
# Fix: This needs to match C++ code which returns (-a[1], a[0], 0) in 0-based indexing
326-
# In Julia's 1-based indexing, this is (-a[2], a[1], 0)
327-
return UnitSphericalPoint{T}(-a[2], a[1], 0)
329+
# S2's SymbolicCrossProdSorted at s2edge_crossings.cc:251-253 returns
330+
# `Vector3_d(a[1], -a[0], 0)` in C++ 0-based indexing.
331+
# In Julia's 1-based indexing, that's (a[2], -a[1], 0). Bug fixed at
332+
# src/utils/UnitSpherical/robustcrossproduct/RobustCrossProduct.jl:327.
333+
return UnitSphericalPoint{T}(a[2], -a[1], 0)
328334
end
329335

330336
# This is always non-zero in the S2 implementation

src/utils/UnitSpherical/robustcrossproduct/utils.jl

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,27 +100,45 @@ loss of precision due to floating-point underflow.
100100
This matches S2's NormalizableFromExact function.
101101
"""
102102
function normalizableFromExact(xf::AbstractVector{BigFloat})
103-
# First try a simple conversion
103+
# Mirrors S2's NormalizableFromExact at s2edge_crossings.cc:318-336.
104+
#
105+
# First try a simple conversion to Float64; if that already has a
106+
# component >= 2^-242 it needs no rescaling. Otherwise we must rescale
107+
# *in BigFloat* (or equivalently compute the shift from BigFloat
108+
# exponents) — we cannot convert to Float64 first, because components
109+
# like 5e-324 would flush to the subnormal range or zero, destroying
110+
# the axis information before the scaling multiply can fix it.
111+
# (Bug fixed at src/utils/UnitSpherical/robustcrossproduct/utils.jl:102.)
104112
x = Float64.(xf)
105-
113+
106114
if isNormalizable(x)
107115
return x
108116
end
109-
110-
# Find the largest exponent
111-
max_exp = -1000000 # Very small initial value
117+
118+
# Find the largest BigFloat exponent among nonzero components.
119+
found = false
120+
max_exp = 0
112121
for i in 1:3
113122
if !iszero(xf[i])
114-
max_exp = max(max_exp, exponent(xf[i]))
123+
e = exponent(xf[i])
124+
if !found || e > max_exp
125+
max_exp = e
126+
found = true
127+
end
115128
end
116129
end
117-
118-
if max_exp < -1000000 # No non-zero components
119-
return zero(xf)
130+
131+
if !found
132+
return zero(x) # The exact result is (0, 0, 0).
120133
end
121-
122-
# Scale to get components in a good range
123-
return Float64.(ldexp.(Float64.(xf), -max_exp))
134+
135+
# Scale in BigFloat so the largest component is in [0.5, 1), then
136+
# convert. This matches S2's `ldexp(xf[i], -exp)` inside ExactFloat.
137+
return UnitSphericalPoint(
138+
Float64(ldexp(xf[1], -max_exp)),
139+
Float64(ldexp(xf[2], -max_exp)),
140+
Float64(ldexp(xf[3], -max_exp)),
141+
)
124142
end
125143

126144

src/utils/UnitSpherical/slerp.jl

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,37 +20,53 @@ interpolates along that path.
2020
"""
2121
slerp(a::UnitSphericalPoint, b::UnitSphericalPoint, i01::Number)
2222
23-
Interpolate between `a` and `b`, at a proportion `i01`
23+
Interpolate between `a` and `b`, at a proportion `i01`
2424
between 0 and 1 along the path from `a` to `b`.
2525
26+
Uses the tangent-vector form `cos(r)·a + sin(r)·dir` — where `r = i01 ·
27+
spherical_distance(a, b)` and `dir = normalize(robust_cross_product(a, b) × a)`
28+
is the unit tangent at `a` pointing toward `b`. This avoids the `1/sin(Ω)`
29+
divisor of the classic `sin((1-t)Ω)/sin(Ω) · a + sin(tΩ)/sin(Ω) · b`
30+
formulation, which collapses for near- and exactly-antipodal inputs.
31+
Adapted from Google's S2 geometry library (see [`S2::Interpolate`](https://github.com/google/s2geometry/blob/a4f0cf58a9cfc214585c39de6e3682384fac0917/src/s2/s2edge_distances.cc#L77)
32+
and [`S2::GetPointOnLine`](https://github.com/google/s2geometry/blob/a4f0cf58a9cfc214585c39de6e3682384fac0917/src/s2/s2edge_distances.cc#L47)).
33+
34+
For exactly antipodal `a` and `b` the great circle is mathematically
35+
ambiguous; `robust_cross_product` returns a deterministic perpendicular via
36+
its symbolic-perturbation branch, so the result is still a well-defined unit
37+
vector on *some* great circle through both points.
38+
2639
## Examples
2740
2841
```jldoctest
2942
julia> using GeometryOps.UnitSpherical
3043
3144
julia> slerp(UnitSphericalPoint(1, 0, 0), UnitSphericalPoint(0, 1, 0), 0.5)
3245
3-element UnitSphericalPoint{Float64} with indices SOneTo(3):
33-
0.7071067811865475
46+
0.7071067811865476
3447
0.7071067811865475
3548
0.0
3649
```
3750
"""
3851
function slerp(a::UnitSphericalPoint, b::UnitSphericalPoint, i01::Number)
39-
if a == b
40-
return a
41-
end
52+
i01 == 0 && return a
53+
i01 == 1 && return b
54+
a == b && return a
4255
Ω = spherical_distance(a, b)
43-
sinΩ = sin(Ω)
44-
return (sin((1-i01)*Ω) / sinΩ) * a + (sin(i01*Ω)/sinΩ) * b
56+
dir = normalize(cross(robust_cross_product(a, b), a))
57+
r = i01 * Ω
58+
return normalize(cos(r) * a + sin(r) * dir)
4559
end
4660

4761
function slerp(a::UnitSphericalPoint, b::UnitSphericalPoint, i01s::AbstractVector{<: Number})
48-
if a == b
49-
return fill(a, i01s)
50-
end
62+
a == b && return fill(a, size(i01s))
5163
Ω = spherical_distance(a, b)
52-
sinΩ = sin(Ω)
53-
return @. (sin((1 - i01s) * Ω) / sinΩ) * a + (sin(i01s * Ω) / sinΩ) * b
64+
dir = normalize(cross(robust_cross_product(a, b), a))
65+
return [begin
66+
t == 0 ? a :
67+
t == 1 ? b :
68+
normalize(cos(t * Ω) * a + sin(t * Ω) * dir)
69+
end for t in i01s]
5470
end
5571

5672
#=

0 commit comments

Comments
 (0)