#![forbid(unsafe_code)]
use thiserror::Error;
#[derive(Clone, Debug, PartialEq)]
pub struct SphericalPoint<const D: usize> {
coords: Vec<f64>,
radius: f64,
}
impl<const D: usize> SphericalPoint<D> {
pub fn try_new<const N: usize>(coords: [f64; N]) -> Result<Self, SphericalPointError> {
Self::try_new_with_radius(coords, 1.0)
}
pub fn try_new_with_radius<const N: usize>(
coords: [f64; N],
radius: f64,
) -> Result<Self, SphericalPointError> {
Self::try_from_slice_with_radius(&coords, radius)
}
pub fn try_from_slice(coords: &[f64]) -> Result<Self, SphericalPointError> {
Self::try_from_slice_with_radius(coords, 1.0)
}
pub fn try_from_slice_with_radius(
coords: &[f64],
radius: f64,
) -> Result<Self, SphericalPointError> {
validate_radius(radius)?;
let expected = Self::ambient_dimension_for_intrinsic();
if coords.len() != expected {
return Err(SphericalPointError::InvalidAmbientCoordinateCount {
dimension: D,
expected,
actual: coords.len(),
});
}
let mut normalized = coords.to_vec();
normalize_coordinates(&mut normalized, radius)?;
Ok(Self {
coords: normalized,
radius,
})
}
#[must_use]
pub const fn intrinsic_dimension(&self) -> usize {
D
}
#[must_use]
pub const fn ambient_dimension(&self) -> usize {
Self::ambient_dimension_for_intrinsic()
}
#[must_use]
pub const fn ambient_dimension_for_intrinsic() -> usize {
D + 1
}
#[must_use]
pub const fn radius(&self) -> f64 {
self.radius
}
#[must_use]
pub fn coords(&self) -> &[f64] {
&self.coords
}
#[must_use]
pub fn squared_norm(&self) -> f64 {
squared_norm_slice(&self.coords)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SphericalMetric<const D: usize> {
radius: f64,
}
impl<const D: usize> SphericalMetric<D> {
#[must_use]
pub const fn unit() -> Self {
Self { radius: 1.0 }
}
pub fn try_new(radius: f64) -> Result<Self, SphericalPointError> {
validate_radius(radius)?;
Ok(Self { radius })
}
#[must_use]
pub const fn radius(self) -> f64 {
self.radius
}
#[must_use]
pub const fn ambient_dimension(self) -> usize {
D + 1
}
pub fn canonicalize<const N: usize>(
self,
coords: [f64; N],
) -> Result<SphericalPoint<D>, SphericalPointError> {
SphericalPoint::try_new_with_radius(coords, self.radius)
}
pub fn canonicalize_slice(
self,
coords: &[f64],
) -> Result<SphericalPoint<D>, SphericalPointError> {
SphericalPoint::try_from_slice_with_radius(coords, self.radius)
}
pub fn try_distance(
self,
a: &SphericalPoint<D>,
b: &SphericalPoint<D>,
) -> Result<f64, SphericalPointError> {
for point in [a, b] {
if point.radius().to_bits() != self.radius.to_bits() {
return Err(SphericalPointError::MismatchedRadius {
expected: self.radius,
actual: point.radius(),
});
}
}
let inverse_radius = 1.0 / self.radius;
let cosine = a
.coords()
.iter()
.zip(b.coords().iter())
.fold(0.0, |acc, (&left, &right)| {
(left * inverse_radius).mul_add(right * inverse_radius, acc)
});
let distance = self.radius * cosine.clamp(-1.0, 1.0).acos();
if distance.is_finite() {
Ok(distance)
} else {
Err(SphericalPointError::NonFiniteDistance { distance })
}
}
}
#[derive(Clone, Debug, Error, PartialEq)]
#[non_exhaustive]
pub enum SphericalPointError {
#[error("spherical S^{dimension} points require {expected} ambient coordinates, got {actual}")]
InvalidAmbientCoordinateCount {
dimension: usize,
expected: usize,
actual: usize,
},
#[error("spherical radius must be finite and positive, got {radius:?}")]
InvalidRadius {
radius: f64,
},
#[error("non-finite spherical coordinate {value:?} at ambient axis {axis}")]
NonFiniteCoordinate {
axis: usize,
value: f64,
},
#[error("cannot normalize zero-length vector onto a sphere")]
ZeroNorm,
#[error("cannot normalize vector with non-finite scaled norm {norm:?}")]
NonFiniteNorm {
norm: f64,
},
#[error("spherical point radius {actual:?} does not match expected radius {expected:?}")]
MismatchedRadius {
expected: f64,
actual: f64,
},
#[error("spherical geodesic distance is not finite: {distance:?}")]
NonFiniteDistance {
distance: f64,
},
}
pub(crate) fn ambient_array_from_slice<const A: usize>(
coords: &[f64],
) -> Result<[f64; A], SphericalPointError> {
if coords.len() != A {
return Err(SphericalPointError::InvalidAmbientCoordinateCount {
dimension: A.saturating_sub(1),
expected: A,
actual: coords.len(),
});
}
let mut out = [0.0; A];
out.copy_from_slice(coords);
Ok(out)
}
fn validate_radius(radius: f64) -> Result<(), SphericalPointError> {
if radius.is_finite() && radius > 0.0 {
return Ok(());
}
Err(SphericalPointError::InvalidRadius { radius })
}
fn normalize_coordinates(coords: &mut [f64], radius: f64) -> Result<(), SphericalPointError> {
let mut max_abs = 0.0_f64;
for (axis, coord) in coords.iter().copied().enumerate() {
if !coord.is_finite() {
return Err(SphericalPointError::NonFiniteCoordinate { axis, value: coord });
}
max_abs = max_abs.max(coord.abs());
}
if max_abs == 0.0 {
return Err(SphericalPointError::ZeroNorm);
}
let sum_scaled_squares = coords.iter().fold(0.0, |acc, &coord| {
let scaled = coord / max_abs;
scaled.mul_add(scaled, acc)
});
let scale = sum_scaled_squares.sqrt();
if !scale.is_finite() {
return Err(SphericalPointError::NonFiniteNorm { norm: scale });
}
for coord in coords {
*coord = ((*coord / max_abs) / scale) * radius;
}
Ok(())
}
fn squared_norm_slice(coords: &[f64]) -> f64 {
coords
.iter()
.fold(0.0, |acc, &coord| coord.mul_add(coord, acc))
}
pub(crate) fn normalize_unit_sphere_coordinates(coords: &mut [f64]) -> bool {
normalize_coordinates(coords, 1.0).is_ok()
}
#[cfg(test)]
mod tests {
use std::{assert_matches, f64::consts::FRAC_1_SQRT_2};
use approx::assert_relative_eq;
use super::*;
fn squared_norm(coords: &[f64]) -> f64 {
coords
.iter()
.fold(0.0, |acc, &coord| coord.mul_add(coord, acc))
}
fn assert_unit_norm(coords: &[f64]) {
assert_relative_eq!(squared_norm(coords), 1.0, epsilon = 1e-12);
}
#[test]
fn spherical_point_handles_near_zero_vector() {
let point = SphericalPoint::<2>::try_new([f64::MIN_POSITIVE, f64::MIN_POSITIVE, 0.0])
.expect("finite nonzero ambient vector should normalize");
let coords = point.coords();
assert_relative_eq!(coords[0], FRAC_1_SQRT_2);
assert_relative_eq!(coords[1], FRAC_1_SQRT_2);
assert_relative_eq!(coords[2], 0.0);
assert_unit_norm(coords);
}
#[test]
fn spherical_point_handles_large_vector_without_overflow() {
let point = SphericalPoint::<2>::try_new([f64::MAX, -f64::MAX, 0.0])
.expect("finite nonzero ambient vector should normalize");
let coords = point.coords();
assert_relative_eq!(coords[0], FRAC_1_SQRT_2);
assert_relative_eq!(coords[1], -FRAC_1_SQRT_2);
assert_relative_eq!(coords[2], 0.0);
assert_unit_norm(coords);
}
#[test]
fn spherical_distance_uses_scaled_coordinates_for_large_radius() {
let radius = f64::MAX / 4.0;
let metric = SphericalMetric::<2>::try_new(radius)
.expect("large finite radius should define a metric");
let x = SphericalPoint::<2>::try_new_with_radius([1.0, 0.0, 0.0], radius)
.expect("axis point should normalize onto large radius");
let diagonal = SphericalPoint::<2>::try_new_with_radius([1.0, 1.0, 0.0], radius)
.expect("diagonal point should normalize onto large radius");
let distance = metric
.try_distance(&x, &diagonal)
.expect("large-radius dot product should avoid overflow");
assert_relative_eq!(distance / radius, std::f64::consts::FRAC_PI_4);
}
#[test]
fn spherical_distance_rejects_unrepresentable_arc_length() {
let metric = SphericalMetric::<2>::try_new(f64::MAX)
.expect("maximum finite radius should define a metric");
let x = SphericalPoint::<2>::try_new_with_radius([1.0, 0.0, 0.0], f64::MAX)
.expect("axis point should normalize onto maximum radius");
let opposite = SphericalPoint::<2>::try_new_with_radius([-1.0, 0.0, 0.0], f64::MAX)
.expect("opposite point should normalize onto maximum radius");
assert_matches!(
metric.try_distance(&x, &opposite),
Err(SphericalPointError::NonFiniteDistance { distance }) if distance.is_infinite()
);
}
#[test]
fn ambient_array_from_slice_checks_fixed_ambient_arity() {
let coords = ambient_array_from_slice::<3>(&[1.0, 2.0, 3.0])
.expect("matching ambient arity should copy into a fixed array");
assert_relative_eq!(coords[0], 1.0);
assert_relative_eq!(coords[1], 2.0);
assert_relative_eq!(coords[2], 3.0);
let err = ambient_array_from_slice::<3>(&[1.0, 2.0])
.expect_err("wrong ambient arity should remain typed");
assert_eq!(
err,
SphericalPointError::InvalidAmbientCoordinateCount {
dimension: 2,
expected: 3,
actual: 2,
}
);
}
#[test]
fn normalize_unit_sphere_coordinates_preserves_failed_inputs() {
let mut zero = [0.0, 0.0, 0.0];
assert!(!normalize_unit_sphere_coordinates(&mut zero));
assert_relative_eq!(zero[0], 0.0);
assert_relative_eq!(zero[1], 0.0);
assert_relative_eq!(zero[2], 0.0);
let mut coords = [1.0, f64::INFINITY, 0.0];
assert!(!normalize_unit_sphere_coordinates(&mut coords));
assert_relative_eq!(coords[0], 1.0);
assert!(coords[1].is_infinite());
assert_relative_eq!(coords[2], 0.0);
let mut coords = [1.0, f64::NAN, -2.0];
assert!(!normalize_unit_sphere_coordinates(&mut coords));
assert_relative_eq!(coords[0], 1.0);
assert!(coords[1].is_nan());
assert_relative_eq!(coords[2], -2.0);
}
}