#![expect(
clippy::cast_sign_loss,
reason = "vertex index (i32) used as Vec indices"
)]
#![expect(
clippy::cast_possible_truncation,
reason = "vertex index (i32) <-> usize for loop vertex access"
)]
#![expect(
clippy::cast_possible_wrap,
reason = "usize -> i32 for vertex index — always in range"
)]
use std::f64::consts::PI;
use crate::s1::Angle;
use crate::s2::Point;
use crate::s2::centroids;
use crate::s2::point_measures;
#[derive(Clone, Debug, PartialEq)]
pub struct KahanSum {
sum: f64,
err: f64,
}
impl KahanSum {
pub fn new() -> Self {
KahanSum { sum: 0.0, err: 0.0 }
}
pub fn add(&mut self, value: f64) {
let tmp1 = value - self.err;
let tmp2 = self.sum + tmp1;
self.err = (tmp2 - self.sum) - tmp1;
self.sum = tmp2;
}
pub fn value(&self) -> f64 {
self.sum
}
}
impl Default for KahanSum {
fn default() -> Self {
Self::new()
}
}
const MAX_LENGTH: f64 = PI - 1e-5;
pub fn get_surface_integral(
loop_vertices: &[Point],
f_tri: impl Fn(Point, Point, Point) -> f64,
) -> f64 {
let mut sum = 0.0_f64;
get_surface_integral_acc(loop_vertices, &f_tri, &mut sum);
sum
}
pub fn get_surface_integral_kahan(
loop_vertices: &[Point],
f_tri: impl Fn(Point, Point, Point) -> f64,
) -> f64 {
let mut sum = KahanSum::new();
get_surface_integral_acc(loop_vertices, &f_tri, &mut sum);
sum.value()
}
trait Accumulator {
fn acc_add(&mut self, value: f64);
}
impl Accumulator for f64 {
fn acc_add(&mut self, value: f64) {
*self += value;
}
}
impl Accumulator for KahanSum {
fn acc_add(&mut self, value: f64) {
self.add(value);
}
}
fn get_surface_integral_acc(
loop_vertices: &[Point],
f_tri: &dyn Fn(Point, Point, Point) -> f64,
sum: &mut dyn Accumulator,
) {
let n = loop_vertices.len();
if n < 3 {
return;
}
let mut origin = loop_vertices[0];
for i in 1..n - 1 {
debug_assert!(i == 1 || origin.0.angle(loop_vertices[i].0) < MAX_LENGTH);
debug_assert!(origin == loop_vertices[0] || origin.0.dot(loop_vertices[0].0).abs() < 1e-15);
if loop_vertices[i + 1].0.angle(origin.0) > MAX_LENGTH {
let old_origin = origin;
if origin == loop_vertices[0] {
origin =
super::edge_crossings::robust_cross_prod(loop_vertices[0], loop_vertices[i])
.normalize();
} else if loop_vertices[i].0.angle(loop_vertices[0].0) < MAX_LENGTH {
origin = loop_vertices[0];
} else {
origin = Point(loop_vertices[0].0.cross(old_origin.0));
sum.acc_add(f_tri(loop_vertices[0], old_origin, origin));
}
sum.acc_add(f_tri(old_origin, loop_vertices[i], origin));
}
sum.acc_add(f_tri(origin, loop_vertices[i], loop_vertices[i + 1]));
}
if origin != loop_vertices[0] {
sum.acc_add(f_tri(origin, loop_vertices[n - 1], loop_vertices[0]));
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct LoopOrder {
pub first: i32,
pub dir: i32,
}
impl LoopOrder {
pub fn new(first: i32, dir: i32) -> Self {
LoopOrder { first, dir }
}
}
fn point_less(a: Point, b: Point) -> bool {
if a.0.x != b.0.x {
return a.0.x < b.0.x;
}
if a.0.y != b.0.y {
return a.0.y < b.0.y;
}
a.0.z < b.0.z
}
fn point_le(a: Point, b: Point) -> bool {
a == b || point_less(a, b)
}
fn loop_at(loop_vertices: &[Point], i: i32) -> Point {
let n = loop_vertices.len() as i32;
let j = i - n;
if j < 0 {
loop_vertices[i as usize]
} else {
loop_vertices[j as usize]
}
}
fn is_order_less(order1: LoopOrder, order2: LoopOrder, loop_vertices: &[Point]) -> bool {
if order1 == order2 {
return false;
}
let mut i1 = order1.first;
let mut i2 = order2.first;
let n = loop_vertices.len() as i32;
for _ in 1..n {
i1 += order1.dir;
i2 += order2.dir;
let p1 = loop_at(loop_vertices, i1);
let p2 = loop_at(loop_vertices, i2);
if point_less(p1, p2) {
return true;
}
if point_less(p2, p1) {
return false;
}
}
false
}
pub fn get_canonical_loop_order(loop_vertices: &[Point]) -> LoopOrder {
let n = loop_vertices.len() as i32;
if n == 0 {
return LoopOrder::new(0, 1);
}
let mut min_indices = vec![0i32];
for i in 1..n {
if point_le(
loop_vertices[i as usize],
loop_vertices[min_indices[0] as usize],
) {
if point_less(
loop_vertices[i as usize],
loop_vertices[min_indices[0] as usize],
) {
min_indices.clear();
}
min_indices.push(i);
}
}
let mut min_order = LoopOrder::new(min_indices[0], 1);
for &min_index in &min_indices {
let order1 = LoopOrder::new(min_index, 1);
let order2 = LoopOrder::new(min_index + n, -1);
if is_order_less(order1, min_order, loop_vertices) {
min_order = order1;
}
if is_order_less(order2, min_order, loop_vertices) {
min_order = order2;
}
}
min_order
}
pub fn prune_degeneracies(loop_vertices: &[Point]) -> Vec<Point> {
let mut vertices: Vec<Point> = Vec::with_capacity(loop_vertices.len());
for &v in loop_vertices {
if !vertices.is_empty() {
if v == vertices[vertices.len() - 1] {
continue;
}
if vertices.len() >= 2 && v == vertices[vertices.len() - 2] {
vertices.pop();
continue;
}
}
vertices.push(v);
}
if vertices.len() >= 2 && vertices[0] == vertices[vertices.len() - 1] {
vertices.pop();
}
if vertices.len() < 3 {
return Vec::new();
}
let mut k = 0usize;
while vertices[k + 1] == vertices[vertices.len() - 1 - k]
|| vertices[k] == vertices[vertices.len() - 2 - k]
{
k += 1;
}
if k > 0 {
vertices.drain(0..k);
vertices.truncate(vertices.len() - k);
}
vertices
}
pub fn get_perimeter(loop_vertices: &[Point]) -> Angle {
let n = loop_vertices.len();
if n <= 1 {
return Angle::from_radians(0.0);
}
let mut perimeter = 0.0;
for i in 0..n {
let next = if i + 1 < n { i + 1 } else { 0 };
perimeter += loop_vertices[i].distance(loop_vertices[next]).radians();
}
Angle::from_radians(perimeter)
}
pub fn get_area(loop_vertices: &[Point]) -> f64 {
let mut area = get_signed_area(loop_vertices);
debug_assert!(area.abs() <= 2.0 * PI + 1e-10);
if area < 0.0 {
area += 4.0 * PI;
}
area
}
pub fn get_signed_area(loop_vertices: &[Point]) -> f64 {
if loop_vertices.is_empty() {
return -f64::MIN_POSITIVE;
}
let area = get_surface_integral_kahan(loop_vertices, point_measures::signed_area);
let max_error = get_curvature_max_error(loop_vertices);
let mut area = area - (area / (4.0 * PI)).round() * (4.0 * PI);
if area == -2.0 * PI {
area = 2.0 * PI;
}
if area.abs() <= max_error {
let curvature = get_curvature(loop_vertices);
debug_assert!(
!(area == 0.0 && curvature == 0.0),
"zero area with zero curvature"
);
if curvature == 2.0 * PI {
return 0.0;
}
if area <= 0.0 && curvature > 0.0 {
return f64::MIN_POSITIVE;
}
if area >= 0.0 && curvature < 0.0 {
return -f64::MIN_POSITIVE;
}
}
area
}
pub fn get_approx_area(loop_vertices: &[Point]) -> f64 {
2.0 * PI - get_curvature(loop_vertices)
}
pub fn get_curvature(loop_vertices: &[Point]) -> f64 {
if loop_vertices.is_empty() {
return -2.0 * PI;
}
let pruned = prune_degeneracies(loop_vertices);
if pruned.is_empty() {
return 2.0 * PI;
}
let n = pruned.len() as i32;
let order = get_canonical_loop_order(&pruned);
let dir = order.dir;
let v = |raw_index: i32| -> Point {
let idx = ((raw_index % n) + n) % n;
pruned[idx as usize]
};
let mut i = order.first;
let mut sum = point_measures::turn_angle(v(i - dir), v(i), v(i + dir)).radians();
let mut compensation = 0.0_f64;
for _ in 1..n {
i += dir;
let angle = point_measures::turn_angle(v(i - dir), v(i), v(i + dir)).radians();
let old_sum = sum;
let corrected = angle + compensation;
sum += corrected;
compensation = (old_sum - sum) + corrected;
}
sum += compensation;
let k_max_curvature = 2.0 * PI - 4.0 * f64::EPSILON;
(f64::from(dir) * sum).clamp(-k_max_curvature, k_max_curvature)
}
pub fn get_curvature_max_error(loop_vertices: &[Point]) -> f64 {
let k_max_error_per_vertex = 11.25 * f64::EPSILON;
k_max_error_per_vertex * loop_vertices.len() as f64
}
pub fn get_centroid(loop_vertices: &[Point]) -> Point {
let n = loop_vertices.len();
if n < 3 {
return Point(crate::r3::Vector {
x: 0.0,
y: 0.0,
z: 0.0,
});
}
let mut cx = 0.0;
let mut cy = 0.0;
let mut cz = 0.0;
let mut add_tri = |a: Point, b: Point, c: Point| {
let p = centroids::true_centroid(a, b, c);
cx += p.0.x;
cy += p.0.y;
cz += p.0.z;
};
let mut origin = loop_vertices[0];
for i in 1..n - 1 {
if loop_vertices[i + 1].0.angle(origin.0) > MAX_LENGTH {
let old_origin = origin;
if origin == loop_vertices[0] {
origin =
super::edge_crossings::robust_cross_prod(loop_vertices[0], loop_vertices[i])
.normalize();
} else if loop_vertices[i].0.angle(loop_vertices[0].0) < MAX_LENGTH {
origin = loop_vertices[0];
} else {
origin = Point(loop_vertices[0].0.cross(old_origin.0));
add_tri(loop_vertices[0], old_origin, origin);
}
add_tri(old_origin, loop_vertices[i], origin);
}
add_tri(origin, loop_vertices[i], loop_vertices[i + 1]);
}
if origin != loop_vertices[0] {
add_tri(origin, loop_vertices[n - 1], loop_vertices[0]);
}
Point(crate::r3::Vector {
x: cx,
y: cy,
z: cz,
})
}
pub fn is_normalized(loop_vertices: &[Point]) -> bool {
get_curvature(loop_vertices) >= -get_curvature_max_error(loop_vertices)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::s2::text_format;
fn make_test_loop(s: &str) -> Vec<Point> {
s.bytes()
.map(|ch| {
Point(crate::r3::Vector {
x: f64::from(ch),
y: 0.0,
z: 0.0,
})
})
.collect()
}
fn to_test_string(pts: &[Point]) -> String {
pts.iter().map(|p| p.0.x as u8 as char).collect()
}
fn cyclic_canonicalize(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut best = s.to_string();
for i in 1..s.len() {
let candidate = format!("{}{}", &s[i..], &s[..i]);
if candidate < best {
best = candidate;
}
}
best
}
fn test_prune_degeneracies(input: &str, expected: &str) {
let loop_pts = make_test_loop(input);
let result = prune_degeneracies(&loop_pts);
let result_str = to_test_string(&result);
assert_eq!(
cyclic_canonicalize(&result_str),
cyclic_canonicalize(expected),
"input = \"{input}\""
);
}
#[test]
fn test_prune_completely_degenerate() {
test_prune_degeneracies("", "");
test_prune_degeneracies("a", "");
test_prune_degeneracies("aaaaa", "");
test_prune_degeneracies("ab", "");
test_prune_degeneracies("abb", "");
test_prune_degeneracies("aab", "");
test_prune_degeneracies("aba", "");
test_prune_degeneracies("abba", "");
test_prune_degeneracies("abcb", "");
test_prune_degeneracies("abcba", "");
test_prune_degeneracies("abcdcdedefedcbcdcb", "");
}
#[test]
fn test_prune_partially_degenerate() {
test_prune_degeneracies("abc", "abc");
test_prune_degeneracies("abca", "abc");
test_prune_degeneracies("abcc", "abc");
test_prune_degeneracies("abccaa", "abc");
test_prune_degeneracies("aabbcc", "abc");
test_prune_degeneracies("abcdedca", "abc");
test_prune_degeneracies("abcbabcbcdc", "abc");
test_prune_degeneracies("xyzabcazy", "abc");
test_prune_degeneracies("xxyyzzaabbccaazzyyxx", "abc");
test_prune_degeneracies("abcdb", "bcd");
test_prune_degeneracies("abcdecb", "cde");
test_prune_degeneracies("abcdefdcb", "def");
test_prune_degeneracies("abcad", "bca");
test_prune_degeneracies("abcdbae", "cdb");
test_prune_degeneracies("abcdecbaf", "dec");
}
fn test_canonical_order(input: &str, expected_first: i32, expected_dir: i32) {
let loop_pts = make_test_loop(input);
let order = get_canonical_loop_order(&loop_pts);
assert_eq!(
order,
LoopOrder::new(expected_first, expected_dir),
"input = \"{input}\""
);
}
#[test]
fn test_canonical_loop_order() {
test_canonical_order("", 0, 1);
test_canonical_order("a", 0, 1);
test_canonical_order("aaaaa", 0, 1);
test_canonical_order("ba", 1, 1);
test_canonical_order("bab", 1, 1);
test_canonical_order("cbab", 2, 1);
test_canonical_order("bacbcab", 8, -1);
}
#[test]
fn test_get_perimeter_empty() {
assert_eq!(get_perimeter(&[]).radians(), 0.0);
}
#[test]
fn test_get_perimeter_octant() {
let loop_pts = text_format::parse_points("0:0, 0:90, 90:0");
let perimeter = get_perimeter(&loop_pts);
let expected = 3.0 * std::f64::consts::FRAC_PI_2;
assert!(
(perimeter.radians() - expected).abs() < 1e-14,
"got {}, expected {}",
perimeter.radians(),
expected
);
}
#[test]
fn test_get_perimeter_more_than_two_pi() {
let loop_pts = text_format::parse_points("0:0, 0:90, 0:180, 90:0, 0:-90");
let perimeter = get_perimeter(&loop_pts);
let expected = 5.0 * std::f64::consts::FRAC_PI_2;
assert!(
(perimeter.radians() - expected).abs() < 1e-14,
"got {}, expected {}",
perimeter.radians(),
expected
);
}
#[test]
fn test_get_signed_area_small_ccw() {
let loop_pts = text_format::parse_points("0:0, 0:1, 1:1, 1:0");
let signed = get_signed_area(&loop_pts);
assert!(signed > 0.0, "signed_area = {signed:e}");
}
#[test]
fn test_get_signed_area_small_cw() {
let loop_pts = text_format::parse_points("0:0, 1:0, 1:1, 0:1");
let signed = get_signed_area(&loop_pts);
assert!(signed < 0.0, "signed_area = {signed:e}");
}
fn test_area_consistent_with_curvature(loop_pts: &[Point]) {
let area = get_area(loop_pts);
let gauss_area = 2.0 * PI - get_curvature(loop_pts);
assert!(
(area - gauss_area).abs() < 1e-14,
"Area = {area}, Gauss Area = {gauss_area}"
);
}
#[test]
fn test_area_consistent_with_curvature_standard_loops() {
test_area_consistent_with_curvature(&[]);
let north_hemi = text_format::parse_points("0:-180, 0:-90, 0:0, 0:90");
test_area_consistent_with_curvature(&north_hemi);
let north_hemi3 = text_format::parse_points("0:-180, 0:-60, 0:60");
test_area_consistent_with_curvature(&north_hemi3);
let west_hemi = text_format::parse_points("0:-180, -90:0, 0:0, 90:0");
test_area_consistent_with_curvature(&west_hemi);
let east_hemi = text_format::parse_points("90:0, 0:0, -90:0, 0:-180");
test_area_consistent_with_curvature(&east_hemi);
let candy_cane =
text_format::parse_points("-20:150, -20:-70, 0:70, 10:-150, 10:70, -10:-70");
test_area_consistent_with_curvature(&candy_cane);
let clover = text_format::parse_points("0:0, -3:3, 3:3, 0:0, 3:0, 3:-3, 0:0, -3:-3, -3:0");
test_area_consistent_with_curvature(&clover);
let tessellated =
text_format::parse_points("10:34, 5:34, 0:34, -10:34, -10:36, -5:36, 0:36, 10:36");
test_area_consistent_with_curvature(&tessellated);
}
#[test]
fn test_get_area_and_centroid_full_loop() {
assert_eq!(get_area(&[]), 4.0 * PI);
let centroid = get_centroid(&[]);
assert_eq!(centroid.0.x, 0.0);
assert_eq!(centroid.0.y, 0.0);
assert_eq!(centroid.0.z, 0.0);
}
#[test]
fn test_get_area_hemispheres() {
let north_hemi = text_format::parse_points("0:-180, 0:-90, 0:0, 0:90");
assert!((get_area(&north_hemi) - 2.0 * PI).abs() < 1e-13);
let east_hemi = text_format::parse_points("90:0, 0:0, -90:0, 0:-180");
assert!((get_area(&east_hemi) - 2.0 * PI).abs() < 1e-12);
}
#[test]
fn test_get_curvature_full() {
assert_eq!(get_curvature(&[]), -2.0 * PI);
}
#[test]
fn test_get_curvature_degenerate_v() {
let v_loop = text_format::parse_points("5:1, 0:2, 5:3, 0:2");
assert_eq!(get_curvature(&v_loop), 2.0 * PI);
}
#[test]
fn test_get_curvature_north_hemi3() {
let north_hemi3 = text_format::parse_points("0:-180, 0:-60, 0:60");
assert_eq!(get_curvature(&north_hemi3), 0.0);
}
#[test]
fn test_get_curvature_west_hemi() {
let west_hemi = text_format::parse_points("0:-180, -90:0, 0:0, 90:0");
assert!(get_curvature(&west_hemi).abs() < 1e-15);
}
#[test]
fn test_is_normalized() {
let ccw = text_format::parse_points("0:0, 0:10, 10:0");
assert!(is_normalized(&ccw));
let cw: Vec<Point> = ccw.iter().rev().copied().collect();
assert!(!is_normalized(&cw));
}
#[test]
fn test_kahan_sum_default() {
let sum = KahanSum::new();
assert_eq!(sum.value(), 0.0);
}
#[test]
fn test_kahan_sum_single() {
let mut sum = KahanSum::new();
sum.add(-3.0);
assert_eq!(sum.value(), -3.0);
}
#[test]
fn test_kahan_sum_of_squares() {
for direction in 0..2 {
let mut safe_sum = KahanSum::new();
let mut naive_sum = 0.0_f64;
let n: i64 = 1_000_000;
for i in 0..=n {
let v = if direction == 0 { i } else { n - i };
safe_sum.add((v * v) as f64);
naive_sum += (v * v) as f64;
}
let expected = (2 * n + 1) * n * (n + 1) / 6;
assert_eq!(safe_sum.value(), expected as f64);
assert!((naive_sum - expected as f64).abs() >= expected as f64 / (n * n) as f64);
}
}
fn check_curvature_invariants(loop_in: &[Point]) {
let expected = get_curvature(loop_in);
let mut loop_pts: Vec<Point> = loop_in.to_vec();
for _ in 0..loop_pts.len() {
loop_pts.reverse();
let reversed_curvature = get_curvature(&loop_pts);
let expected_reversed = if expected == 2.0 * PI {
expected
} else {
-expected
};
assert_eq!(reversed_curvature, expected_reversed);
loop_pts.reverse();
loop_pts.rotate_left(1);
assert_eq!(get_curvature(&loop_pts), expected);
}
}
#[test]
fn test_curvature_invariants() {
let north_hemi3 = text_format::parse_points("0:-180, 0:-60, 0:60");
check_curvature_invariants(&north_hemi3);
let west_hemi = text_format::parse_points("0:-180, -90:0, 0:0, 90:0");
check_curvature_invariants(&west_hemi);
let candy_cane =
text_format::parse_points("-20:150, -20:-70, 0:70, 10:-150, 10:70, -10:-70");
check_curvature_invariants(&candy_cane);
let clover = text_format::parse_points("0:0, -3:3, 3:3, 0:0, 3:0, 3:-3, 0:0, -3:-3, -3:0");
check_curvature_invariants(&clover);
}
#[test]
fn test_curvature_spiral() {
let arm_points = 10000;
let arm_radius = 0.01;
let mut spiral = vec![Point::from_coords(0.0, 0.0, 0.0); 2 * arm_points];
spiral[arm_points] = Point::from_coords(0.0, 0.0, 1.0);
for i in 0..arm_points {
let angle = (2.0 * PI / 3.0) * i as f64;
let x = angle.cos();
let y = angle.sin();
let r1 = i as f64 * arm_radius / arm_points as f64;
let r2 = (i as f64 + 1.5) * arm_radius / arm_points as f64;
spiral[arm_points - i - 1] = Point(
crate::r3::Vector {
x: r1 * x,
y: r1 * y,
z: 1.0,
}
.normalize(),
);
spiral[arm_points + i] = Point(
crate::r3::Vector {
x: r2 * x,
y: r2 * y,
z: 1.0,
}
.normalize(),
);
}
let area = get_area(&spiral);
let curvature = get_curvature(&spiral);
let max_error = get_curvature_max_error(&spiral);
assert!(
(2.0 * PI - area - curvature).abs() < 0.01 * max_error,
"area={area}, curvature={curvature}, max_error={max_error}"
);
}
#[test]
fn test_get_signed_area_underflow() {
let pts = text_format::parse_points("0:0, 0:1e-88, 1e-88:1e-88, 1e-88:0");
assert!(get_signed_area(&pts) > 0.0);
}
}