1use std::f64::consts::PI;
11
12use crate::distance::angular_distance;
13use crate::types::SphericalPoint;
14
15pub fn antipode(p: &SphericalPoint) -> SphericalPoint {
22 let theta = (p.theta + PI) % std::f64::consts::TAU;
23 let phi = PI - p.phi;
24 SphericalPoint::new_unchecked(p.r, theta, phi)
25}
26
27#[must_use]
36pub fn region_coherence(center: &SphericalPoint, radius: f64, points: &[SphericalPoint]) -> f64 {
37 if points.is_empty() || radius <= 0.0 {
38 return 0.0;
39 }
40 let hits = points
41 .iter()
42 .filter(|p| angular_distance(center, p) <= radius)
43 .count();
44 let observed = hits as f64 / points.len() as f64;
45 let expected = cap_solid_angle(radius) / (4.0 * PI);
46 if expected < f64::EPSILON {
47 return 0.0;
48 }
49 observed / expected
50}
51
52#[must_use]
58#[inline]
59pub fn cap_solid_angle(alpha: f64) -> f64 {
60 2.0 * PI * (1.0 - alpha.cos())
61}
62
63#[must_use]
71pub fn cap_intersection_area(
72 center_a: &SphericalPoint,
73 alpha_a: f64,
74 center_b: &SphericalPoint,
75 alpha_b: f64,
76) -> f64 {
77 let d = angular_distance(center_a, center_b);
78
79 if d >= alpha_a + alpha_b {
81 return 0.0;
82 }
83
84 if d + alpha_b <= alpha_a {
86 return cap_solid_angle(alpha_b);
87 }
88 if d + alpha_a <= alpha_b {
89 return cap_solid_angle(alpha_a);
90 }
91
92 let cos_d = d.cos();
104 let sin_d = d.sin();
105 let cos_a = alpha_a.cos();
106 let cos_b = alpha_b.cos();
107
108 if sin_d.abs() < 1e-15 {
109 return cap_solid_angle(alpha_a.min(alpha_b));
110 }
111
112 let phi_a = ((cos_b - cos_a * cos_d) / (alpha_a.sin() * sin_d))
113 .clamp(-1.0, 1.0)
114 .acos();
115 let phi_b = ((cos_a - cos_b * cos_d) / (alpha_b.sin() * sin_d))
116 .clamp(-1.0, 1.0)
117 .acos();
118
119 let s = (alpha_a + alpha_b + d) / 2.0;
121 let product = ((s / 2.0).tan()
122 * ((s - alpha_a) / 2.0).tan()
123 * ((s - alpha_b) / 2.0).tan()
124 * ((s - d) / 2.0).tan())
125 .max(0.0);
126 let excess = 4.0 * product.sqrt().atan();
127
128 2.0 * phi_a * (1.0 - cos_a) + 2.0 * phi_b * (1.0 - cos_b) - 2.0 * excess
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum LuneSide {
134 CloserToA,
135 CloserToB,
136 OnBisector,
137}
138
139#[must_use]
145pub fn lune_classify(a: &SphericalPoint, b: &SphericalPoint, point: &SphericalPoint) -> LuneSide {
146 let da = angular_distance(a, point);
147 let db = angular_distance(b, point);
148 let diff = da - db;
149 if diff.abs() < 1e-12 {
150 LuneSide::OnBisector
151 } else if diff < 0.0 {
152 LuneSide::CloserToA
153 } else {
154 LuneSide::CloserToB
155 }
156}
157
158#[must_use]
164pub fn angular_bisector_normal(a: &SphericalPoint, b: &SphericalPoint) -> [f64; 3] {
165 let ac = a.unit_cartesian();
166 let bc = b.unit_cartesian();
167 let nx = ac[0] - bc[0];
168 let ny = ac[1] - bc[1];
169 let nz = ac[2] - bc[2];
170 let mag = (nx * nx + ny * ny + nz * nz).sqrt();
171 if mag < 1e-15 {
172 return [0.0, 0.0, 1.0];
173 }
174 [nx / mag, ny / mag, nz / mag]
175}
176
177#[must_use]
185pub fn distance_to_great_circle_arc(
186 point: &SphericalPoint,
187 arc_start: &SphericalPoint,
188 arc_end: &SphericalPoint,
189) -> f64 {
190 let p = point.unit_cartesian();
191 let a = arc_start.unit_cartesian();
192 let b = arc_end.unit_cartesian();
193
194 let nx = a[1] * b[2] - a[2] * b[1];
195 let ny = a[2] * b[0] - a[0] * b[2];
196 let nz = a[0] * b[1] - a[1] * b[0];
197 let nmag = (nx * nx + ny * ny + nz * nz).sqrt();
198
199 if nmag < 1e-15 {
200 return angular_distance(point, arc_start);
201 }
202
203 let dot_pn = p[0] * nx / nmag + p[1] * ny / nmag + p[2] * nz / nmag;
204 let gc_dist = (PI / 2.0 - dot_pn.abs().acos()).abs();
205
206 let proj_x = p[0] - dot_pn * nx / nmag;
207 let proj_y = p[1] - dot_pn * ny / nmag;
208 let proj_z = p[2] - dot_pn * nz / nmag;
209 let proj_mag = (proj_x * proj_x + proj_y * proj_y + proj_z * proj_z).sqrt();
210
211 if proj_mag < 1e-15 {
212 return angular_distance(point, arc_start).min(angular_distance(point, arc_end));
213 }
214
215 let cp = [proj_x / proj_mag, proj_y / proj_mag, proj_z / proj_mag];
216
217 let ab_cos = a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
224 let acp_cos = a[0] * cp[0] + a[1] * cp[1] + a[2] * cp[2];
225 let bcp_cos = b[0] * cp[0] + b[1] * cp[1] + b[2] * cp[2];
226
227 if acp_cos >= ab_cos - 1e-10 && bcp_cos >= ab_cos - 1e-10 {
228 gc_dist
229 } else {
230 angular_distance(point, arc_start).min(angular_distance(point, arc_end))
231 }
232}
233
234pub fn geodesic_sweep(
239 arc_start: &SphericalPoint,
240 arc_end: &SphericalPoint,
241 points: &[SphericalPoint],
242 epsilon: f64,
243) -> Vec<(usize, f64)> {
244 let mut hits: Vec<(usize, f64)> = points
245 .iter()
246 .enumerate()
247 .filter_map(|(i, p)| {
248 let d = distance_to_great_circle_arc(p, arc_start, arc_end);
249 if d <= epsilon { Some((i, d)) } else { None }
250 })
251 .collect();
252 hits.sort_by(|a, b| a.1.total_cmp(&b.1));
253 hits
254}
255
256pub fn geodesic_density_profile(
262 start: &SphericalPoint,
263 end: &SphericalPoint,
264 points: &[SphericalPoint],
265 radius: f64,
266 num_samples: usize,
267) -> Vec<usize> {
268 let num_samples = num_samples.max(2);
269 let mut profile = Vec::with_capacity(num_samples);
270 let arc_len = angular_distance(start, end);
271 if arc_len < 1e-15 {
272 return vec![0; num_samples];
273 }
274
275 for i in 0..num_samples {
276 let t = i as f64 / (num_samples - 1) as f64;
277 let sample = crate::interpolation::slerp(start, end, t);
278 let count = points
279 .iter()
280 .filter(|p| angular_distance(&sample, p) <= radius)
281 .count();
282 profile.push(count);
283 }
284 profile
285}
286
287#[derive(Debug, Clone)]
291pub struct VoronoiCell {
292 pub generator_index: usize,
294 pub neighbor_indices: Vec<usize>,
296 pub area: f64,
298}
299
300pub fn spherical_voronoi(generators: &[SphericalPoint], num_samples: usize) -> Vec<VoronoiCell> {
308 let n = generators.len();
309 if n == 0 {
310 return Vec::new();
311 }
312 if n == 1 {
313 return vec![VoronoiCell {
314 generator_index: 0,
315 neighbor_indices: Vec::new(),
316 area: 4.0 * PI,
317 }];
318 }
319
320 let gen_carts: Vec<[f64; 3]> = generators.iter().map(|g| g.unit_cartesian()).collect();
321
322 let mut cell_counts = vec![0usize; n];
323 let mut neighbor_hits = vec![vec![false; n]; n];
324
325 let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_1337;
326
327 for _ in 0..num_samples {
328 let (x, y, z) = uniform_sphere_sample(&mut rng_state);
329
330 let mut best_idx = 0;
331 let mut best_dot = f64::NEG_INFINITY;
332 let mut second_dot = f64::NEG_INFINITY;
333 let mut second_idx = 0;
334
335 for (i, gc) in gen_carts.iter().enumerate() {
336 let dot = x * gc[0] + y * gc[1] + z * gc[2];
337 if dot > best_dot {
338 second_dot = best_dot;
339 second_idx = best_idx;
340 best_dot = dot;
341 best_idx = i;
342 } else if dot > second_dot {
343 second_dot = dot;
344 second_idx = i;
345 }
346 }
347
348 cell_counts[best_idx] += 1;
349
350 let margin = best_dot - second_dot;
351 if margin < 0.05 {
352 neighbor_hits[best_idx][second_idx] = true;
353 neighbor_hits[second_idx][best_idx] = true;
354 }
355 }
356
357 let total_area = 4.0 * PI;
358 let total_samples = num_samples as f64;
359
360 (0..n)
361 .map(|i| {
362 let neighbor_indices: Vec<usize> =
363 (0..n).filter(|&j| j != i && neighbor_hits[i][j]).collect();
364 VoronoiCell {
365 generator_index: i,
366 neighbor_indices,
367 area: total_area * cell_counts[i] as f64 / total_samples,
368 }
369 })
370 .collect()
371}
372
373#[derive(Debug, Clone)]
377pub struct CoverageReport {
378 pub coverage_fraction: f64,
379 pub covered_area: f64,
380 pub overlap_area: f64,
382 pub void_count: usize,
383 pub total_samples: usize,
384}
385
386pub fn estimate_coverage(
391 centers: &[SphericalPoint],
392 half_angles: &[f64],
393 num_samples: usize,
394) -> CoverageReport {
395 debug_assert_eq!(
396 centers.len(),
397 half_angles.len(),
398 "centers and half_angles must have the same length"
399 );
400 let n = centers.len();
401
402 let cap_carts: Vec<[f64; 3]> = centers.iter().map(|c| c.unit_cartesian()).collect();
403 let cos_alphas: Vec<f64> = half_angles.iter().map(|a| a.cos()).collect();
404
405 let mut covered = 0usize;
406 let mut multi_covered = 0usize;
407 let mut rng_state: u64 = 0x1234_5678_9ABC_DEF0;
408
409 for _ in 0..num_samples {
410 let (x, y, z) = uniform_sphere_sample(&mut rng_state);
411 let mut hit_count = 0usize;
412
413 for i in 0..n {
414 let dot = x * cap_carts[i][0] + y * cap_carts[i][1] + z * cap_carts[i][2];
415 if dot >= cos_alphas[i] {
416 hit_count += 1;
417 }
418 }
419
420 if hit_count > 0 {
421 covered += 1;
422 }
423 if hit_count > 1 {
424 multi_covered += 1;
425 }
426 }
427
428 let total_area = 4.0 * PI;
429
430 if num_samples == 0 {
431 return CoverageReport {
432 coverage_fraction: 0.0,
433 covered_area: 0.0,
434 overlap_area: 0.0,
435 void_count: 0,
436 total_samples: 0,
437 };
438 }
439
440 let total = num_samples as f64;
441 let coverage_fraction = covered as f64 / total;
442
443 CoverageReport {
444 coverage_fraction,
445 covered_area: coverage_fraction * total_area,
446 overlap_area: multi_covered as f64 / total * total_area,
447 void_count: num_samples - covered,
448 total_samples: num_samples,
449 }
450}
451
452#[must_use]
456pub fn void_distance(
457 point: &SphericalPoint,
458 centers: &[SphericalPoint],
459 half_angles: &[f64],
460) -> f64 {
461 debug_assert_eq!(
462 centers.len(),
463 half_angles.len(),
464 "centers and half_angles must have the same length"
465 );
466 let mut min_gap = f64::INFINITY;
467 for (center, &alpha) in centers.iter().zip(half_angles.iter()) {
468 let d = angular_distance(point, center);
469 let gap = d - alpha;
470 if gap < min_gap {
471 min_gap = gap;
472 }
473 }
474 min_gap
475}
476
477#[derive(Debug, Clone, Copy)]
480pub struct PairwiseOverlap {
481 pub category_a: usize,
482 pub category_b: usize,
483 pub intersection_area: f64,
484}
485
486pub fn pairwise_overlaps(centers: &[SphericalPoint], half_angles: &[f64]) -> Vec<PairwiseOverlap> {
493 debug_assert_eq!(
494 centers.len(),
495 half_angles.len(),
496 "centers and half_angles must have the same length"
497 );
498 let n = centers.len();
499 if n < 2 {
500 return Vec::new();
501 }
502
503 use rayon::prelude::*;
504 const SERIAL_THRESHOLD: usize = 128;
505
506 let mut overlaps: Vec<PairwiseOverlap> = if n < SERIAL_THRESHOLD {
507 let mut out = Vec::with_capacity(n * (n - 1) / 2);
508 for i in 0..n {
509 for j in (i + 1)..n {
510 let area =
511 cap_intersection_area(¢ers[i], half_angles[i], ¢ers[j], half_angles[j]);
512 if area > 1e-15 {
513 out.push(PairwiseOverlap {
514 category_a: i,
515 category_b: j,
516 intersection_area: area,
517 });
518 }
519 }
520 }
521 out
522 } else {
523 (0..n)
524 .into_par_iter()
525 .flat_map_iter(|i| {
526 let mut local = Vec::new();
527 for j in (i + 1)..n {
528 let area = cap_intersection_area(
529 ¢ers[i],
530 half_angles[i],
531 ¢ers[j],
532 half_angles[j],
533 );
534 if area > 1e-15 {
535 local.push(PairwiseOverlap {
536 category_a: i,
537 category_b: j,
538 intersection_area: area,
539 });
540 }
541 }
542 local
543 })
544 .collect()
545 };
546
547 overlaps.sort_by(|a, b| {
548 b.intersection_area
549 .partial_cmp(&a.intersection_area)
550 .unwrap_or(std::cmp::Ordering::Equal)
551 });
552 overlaps
553}
554
555#[must_use]
558pub fn cap_exclusivity(
559 cap_index: usize,
560 centers: &[SphericalPoint],
561 half_angles: &[f64],
562 num_samples: usize,
563) -> f64 {
564 let n = centers.len();
565 assert!(
566 cap_index < n,
567 "cap_index {cap_index} out of bounds for {n} caps"
568 );
569 debug_assert_eq!(
570 centers.len(),
571 half_angles.len(),
572 "centers and half_angles must have the same length"
573 );
574
575 let cap_carts: Vec<[f64; 3]> = centers.iter().map(|c| c.unit_cartesian()).collect();
576 let cos_alphas: Vec<f64> = half_angles.iter().map(|a| a.cos()).collect();
577
578 let center = &cap_carts[cap_index];
579 let cos_alpha = cos_alphas[cap_index];
580
581 let mut in_cap = 0usize;
582 let mut exclusive = 0usize;
583 let mut rng_state: u64 = 0xFEED_FACE_0000_0001 + cap_index as u64;
584
585 for _ in 0..num_samples {
586 let (x, y, z) = uniform_sphere_sample(&mut rng_state);
587 let dot = x * center[0] + y * center[1] + z * center[2];
588 if dot < cos_alpha {
589 continue;
590 }
591 in_cap += 1;
592
593 let mut only_this = true;
594 for (j, gc) in cap_carts.iter().enumerate() {
595 if j == cap_index {
596 continue;
597 }
598 let d = x * gc[0] + y * gc[1] + z * gc[2];
599 if d >= cos_alphas[j] {
600 only_this = false;
601 break;
602 }
603 }
604 if only_this {
605 exclusive += 1;
606 }
607 }
608
609 if in_cap == 0 {
610 return 1.0;
611 }
612 exclusive as f64 / in_cap as f64
613}
614
615#[must_use]
622pub fn spherical_excess(a: &SphericalPoint, b: &SphericalPoint, c: &SphericalPoint) -> f64 {
623 let side_a = angular_distance(b, c);
624 let side_b = angular_distance(a, c);
625 let side_c = angular_distance(a, b);
626
627 let s = (side_a + side_b + side_c) / 2.0;
628
629 let t0 = (s / 2.0).tan();
630 let t1 = ((s - side_a) / 2.0).tan();
631 let t2 = ((s - side_b) / 2.0).tan();
632 let t3 = ((s - side_c) / 2.0).tan();
633
634 let product = t0 * t1 * t2 * t3;
635 if product < 0.0 {
636 return 0.0;
637 }
638 4.0 * product.sqrt().atan()
639}
640
641pub fn curvature_signature(target: usize, all_points: &[SphericalPoint]) -> Vec<f64> {
648 let n = all_points.len();
649 if n < 3 || target >= n {
650 return Vec::new();
651 }
652
653 use rayon::prelude::*;
654 const SERIAL_THRESHOLD: usize = 128;
655
656 let mut excesses: Vec<f64> = if n < SERIAL_THRESHOLD {
657 let mut out = Vec::new();
658 for i in 0..n {
659 if i == target {
660 continue;
661 }
662 for j in (i + 1)..n {
663 if j == target {
664 continue;
665 }
666 out.push(spherical_excess(
667 &all_points[target],
668 &all_points[i],
669 &all_points[j],
670 ));
671 }
672 }
673 out
674 } else {
675 (0..n)
676 .into_par_iter()
677 .flat_map_iter(|i| {
678 let mut local = Vec::new();
679 if i != target {
680 for j in (i + 1)..n {
681 if j != target {
682 local.push(spherical_excess(
683 &all_points[target],
684 &all_points[i],
685 &all_points[j],
686 ));
687 }
688 }
689 }
690 local
691 })
692 .collect()
693 };
694 excesses.sort_by(|a, b| a.total_cmp(b));
695 excesses
696}
697
698#[must_use]
703pub fn geodesic_deviation(path: &[SphericalPoint]) -> f64 {
704 if path.len() < 3 {
705 return 0.0;
706 }
707 let start = path.first().expect("path len >= 3");
709 let end = path.last().expect("path len >= 3");
710 path[1..path.len() - 1]
711 .iter()
712 .map(|p| distance_to_great_circle_arc(p, start, end))
713 .fold(0.0_f64, f64::max)
714}
715
716#[inline]
719fn next_f64(state: &mut u64) -> f64 {
720 *state = state
721 .wrapping_mul(6364136223846793005)
722 .wrapping_add(1442695040888963407);
723 (*state >> 33) as f64 / (1u64 << 31) as f64
724}
725
726#[inline]
727fn uniform_sphere_sample(state: &mut u64) -> (f64, f64, f64) {
728 let theta = std::f64::consts::TAU * next_f64(state);
729 let cos_phi = 2.0 * next_f64(state) - 1.0;
730 let sin_phi = (1.0 - cos_phi * cos_phi).sqrt();
731 let (sin_t, cos_t) = theta.sin_cos();
732 (sin_phi * cos_t, sin_phi * sin_t, cos_phi)
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use approx::assert_relative_eq;
739 use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
740
741 fn unit(theta: f64, phi: f64) -> SphericalPoint {
742 SphericalPoint::new_unchecked(1.0, theta, phi)
743 }
744
745 #[test]
746 fn antipode_of_north_pole() {
747 let north = unit(0.0, 0.0);
748 let south = antipode(&north);
749 assert_relative_eq!(south.phi, PI, epsilon = 1e-12);
750 assert_relative_eq!(angular_distance(&north, &south), PI, epsilon = 1e-12);
751 }
752
753 #[test]
754 fn antipode_is_involution() {
755 let p = unit(1.3, 0.7);
756 let pp = antipode(&antipode(&p));
757 assert_relative_eq!(angular_distance(&p, &pp), 0.0, epsilon = 1e-10);
758 }
759
760 #[test]
761 fn antipode_always_distance_pi() {
762 for &(t, p) in &[(0.0, FRAC_PI_2), (1.0, 0.3), (3.0, 2.5), (5.5, 1.0)] {
763 let pt = unit(t, p);
764 let ap = antipode(&pt);
765 assert_relative_eq!(angular_distance(&pt, &ap), PI, epsilon = 1e-10);
766 }
767 }
768
769 #[test]
770 fn region_coherence_all_at_center() {
771 let c = unit(0.0, FRAC_PI_2);
772 let points = vec![c; 100];
773 let coh = region_coherence(&c, 0.1, &points);
774 assert!(coh > 100.0);
775 }
776
777 #[test]
778 fn region_coherence_empty() {
779 let c = unit(0.0, FRAC_PI_2);
780 assert_eq!(region_coherence(&c, 0.1, &[]), 0.0);
781 }
782
783 #[test]
784 fn cap_solid_angle_hemisphere() {
785 assert_relative_eq!(cap_solid_angle(FRAC_PI_2), 2.0 * PI, epsilon = 1e-12);
786 }
787
788 #[test]
789 fn cap_solid_angle_full_sphere() {
790 assert_relative_eq!(cap_solid_angle(PI), 4.0 * PI, epsilon = 1e-12);
791 }
792
793 #[test]
794 fn cap_solid_angle_zero() {
795 assert_relative_eq!(cap_solid_angle(0.0), 0.0, epsilon = 1e-12);
796 }
797
798 #[test]
799 fn cap_solid_angle_small() {
800 let alpha: f64 = 0.1;
801 let expected = 2.0 * PI * (1.0 - alpha.cos());
802 assert_relative_eq!(cap_solid_angle(alpha), expected, epsilon = 1e-12);
803 }
804
805 #[test]
806 fn cap_intersection_no_overlap() {
807 let a = unit(0.0, 0.1);
808 let b = unit(PI, PI - 0.1);
809 assert!(cap_intersection_area(&a, 0.2, &b, 0.2) < 1e-10);
810 }
811
812 #[test]
813 fn cap_intersection_full_containment() {
814 let a = unit(0.0, FRAC_PI_2);
815 let b = unit(0.0, FRAC_PI_2);
816 let area = cap_intersection_area(&a, FRAC_PI_2, &b, FRAC_PI_4);
817 assert_relative_eq!(area, cap_solid_angle(FRAC_PI_4), epsilon = 1e-10);
818 }
819
820 #[test]
821 fn cap_intersection_identical_caps() {
822 let a = unit(0.5, 1.0);
823 let area = cap_intersection_area(&a, 0.3, &a, 0.3);
824 assert_relative_eq!(area, cap_solid_angle(0.3), epsilon = 1e-10);
825 }
826
827 #[test]
828 fn cap_intersection_symmetric() {
829 let a = unit(0.0, FRAC_PI_4);
830 let b = unit(0.5, FRAC_PI_2);
831 let ab = cap_intersection_area(&a, 0.5, &b, 0.3);
832 let ba = cap_intersection_area(&b, 0.3, &a, 0.5);
833 assert_relative_eq!(ab, ba, epsilon = 1e-10);
834 }
835
836 #[test]
837 fn cap_intersection_positive_for_overlapping() {
838 let a = unit(0.0, FRAC_PI_2);
839 let b = unit(0.3, FRAC_PI_2);
840 let area = cap_intersection_area(&a, 0.5, &b, 0.5);
841 assert!(area > 0.0);
842 assert!(area < cap_solid_angle(0.5));
843 }
844
845 #[test]
846 fn distance_to_arc_endpoint() {
847 let a = unit(0.0, FRAC_PI_2);
848 let b = unit(FRAC_PI_2, FRAC_PI_2);
849 assert!(distance_to_great_circle_arc(&a, &a, &b) < 1e-10);
850 }
851
852 #[test]
853 fn distance_to_arc_midpoint_on_arc() {
854 let a = unit(0.0, FRAC_PI_2);
855 let b = unit(FRAC_PI_2, FRAC_PI_2);
856 let mid = crate::interpolation::slerp(&a, &b, 0.5);
857 assert!(distance_to_great_circle_arc(&mid, &a, &b) < 1e-10);
858 }
859
860 #[test]
861 fn distance_to_arc_pole_is_pi_over_2() {
862 let a = unit(0.0, FRAC_PI_2);
863 let b = unit(FRAC_PI_2, FRAC_PI_2);
864 let pole = unit(0.0, 0.0);
865 assert_relative_eq!(
866 distance_to_great_circle_arc(&pole, &a, &b),
867 FRAC_PI_2,
868 epsilon = 1e-6
869 );
870 }
871
872 #[test]
873 fn geodesic_sweep_finds_nearby() {
874 let a = unit(0.0, FRAC_PI_2);
875 let b = unit(FRAC_PI_2, FRAC_PI_2);
876 let mid = crate::interpolation::slerp(&a, &b, 0.5);
877 let far = unit(0.0, 0.1);
878 let hits = geodesic_sweep(&a, &b, &[mid, far], 0.1);
879 assert_eq!(hits.len(), 1);
880 assert_eq!(hits[0].0, 0);
881 }
882
883 #[test]
884 fn geodesic_density_profile_shape() {
885 let a = unit(0.0, FRAC_PI_2);
886 let b = unit(FRAC_PI_2, FRAC_PI_2);
887 let mid = crate::interpolation::slerp(&a, &b, 0.5);
888 let profile = geodesic_density_profile(&a, &b, &[mid], 0.1, 10);
889 assert_eq!(profile.len(), 10);
890 assert!(profile.iter().sum::<usize>() > 0);
891 }
892
893 #[test]
894 fn geodesic_deviation_straight_path() {
895 let a = unit(0.0, FRAC_PI_2);
896 let b = unit(FRAC_PI_2, FRAC_PI_2);
897 let mid = crate::interpolation::slerp(&a, &b, 0.5);
898 assert!(geodesic_deviation(&[a, mid, b]) < 1e-8);
899 }
900
901 #[test]
902 fn geodesic_deviation_detour() {
903 let a = unit(0.0, FRAC_PI_2);
904 let b = unit(FRAC_PI_2, FRAC_PI_2);
905 let detour = unit(0.0, 0.1);
906 assert!(geodesic_deviation(&[a, detour, b]) > 1.0);
907 }
908
909 #[test]
910 fn voronoi_single_point() {
911 let cells = spherical_voronoi(&[unit(0.0, FRAC_PI_2)], 1000);
912 assert_eq!(cells.len(), 1);
913 assert_relative_eq!(cells[0].area, 4.0 * PI, epsilon = 1e-12);
914 }
915
916 #[test]
917 fn voronoi_antipodal_pair_equal_area() {
918 let a = unit(0.0, FRAC_PI_2);
919 let b = antipode(&a);
920 let cells = spherical_voronoi(&[a, b], 200_000);
921 assert_eq!(cells.len(), 2);
922 assert!((cells[0].area - cells[1].area).abs() < 0.5);
923 assert!(cells[0].neighbor_indices.contains(&1));
924 }
925
926 #[test]
927 fn voronoi_total_area_is_4pi() {
928 let gens: Vec<SphericalPoint> = (0..6).map(|i| unit(i as f64 * 1.0, FRAC_PI_2)).collect();
929 let cells = spherical_voronoi(&gens, 100_000);
930 let total: f64 = cells.iter().map(|c| c.area).sum();
931 assert_relative_eq!(total, 4.0 * PI, epsilon = 0.5);
932 }
933
934 #[test]
935 fn coverage_single_hemisphere() {
936 let c = unit(0.0, 0.0);
937 let report = estimate_coverage(&[c], &[FRAC_PI_2], 100_000);
938 assert!((report.coverage_fraction - 0.5).abs() < 0.02);
939 }
940
941 #[test]
942 fn coverage_full_sphere() {
943 let c = unit(0.0, 0.0);
944 let report = estimate_coverage(&[c], &[PI], 10_000);
945 assert!((report.coverage_fraction - 1.0).abs() < 0.01);
946 }
947
948 #[test]
949 fn coverage_empty() {
950 let report = estimate_coverage(&[], &[], 10_000);
951 assert_eq!(report.coverage_fraction, 0.0);
952 assert_eq!(report.void_count, 10_000);
953 }
954
955 #[test]
956 fn void_distance_inside_cap() {
957 let c = unit(0.0, FRAC_PI_2);
958 let p = unit(0.05, FRAC_PI_2);
959 assert!(void_distance(&p, &[c], &[0.5]) < 0.0);
960 }
961
962 #[test]
963 fn void_distance_outside_all() {
964 let c = unit(0.0, 0.1);
965 let p = unit(PI, PI - 0.1);
966 assert!(void_distance(&p, &[c], &[0.2]) > 0.0);
967 }
968
969 #[test]
970 fn spherical_excess_right_triangle() {
971 let a = unit(0.0, 0.0);
972 let b = unit(0.0, FRAC_PI_2);
973 let c = unit(FRAC_PI_2, FRAC_PI_2);
974 assert_relative_eq!(spherical_excess(&a, &b, &c), FRAC_PI_2, epsilon = 1e-6);
975 }
976
977 #[test]
978 fn spherical_excess_degenerate() {
979 let a = unit(0.0, FRAC_PI_2);
980 let b = unit(0.5, FRAC_PI_2);
981 let c = crate::interpolation::slerp(&a, &b, 0.5);
982 assert!(spherical_excess(&a, &b, &c) < 1e-6);
983 }
984
985 #[test]
986 fn spherical_excess_symmetric() {
987 let a = unit(0.0, 0.5);
988 let b = unit(1.0, 1.0);
989 let c = unit(2.0, 0.8);
990 let abc = spherical_excess(&a, &b, &c);
991 let bca = spherical_excess(&b, &c, &a);
992 assert_relative_eq!(abc, bca, epsilon = 1e-12);
993 }
994
995 #[test]
996 fn curvature_signature_length() {
997 let points: Vec<SphericalPoint> = (0..5).map(|i| unit(i as f64 * 1.0, FRAC_PI_2)).collect();
998 assert_eq!(curvature_signature(0, &points).len(), 6);
999 }
1000
1001 #[test]
1002 fn lune_classify_closer_to_a() {
1003 let a = unit(0.0, FRAC_PI_2);
1004 let b = unit(PI, FRAC_PI_2);
1005 assert_eq!(
1006 lune_classify(&a, &b, &unit(0.1, FRAC_PI_2)),
1007 LuneSide::CloserToA
1008 );
1009 }
1010
1011 #[test]
1012 fn lune_classify_closer_to_b() {
1013 let a = unit(0.0, FRAC_PI_2);
1014 let b = unit(PI, FRAC_PI_2);
1015 assert_eq!(
1016 lune_classify(&a, &b, &unit(PI - 0.1, FRAC_PI_2)),
1017 LuneSide::CloserToB
1018 );
1019 }
1020
1021 #[test]
1022 fn lune_classify_on_bisector() {
1023 let a = unit(0.0, FRAC_PI_2);
1024 let b = unit(PI, FRAC_PI_2);
1025 assert_eq!(
1026 lune_classify(&a, &b, &unit(FRAC_PI_2, FRAC_PI_2)),
1027 LuneSide::OnBisector
1028 );
1029 }
1030
1031 #[test]
1032 fn angular_bisector_normal_sign() {
1033 let a = unit(0.0, FRAC_PI_2);
1034 let b = unit(FRAC_PI_2, FRAC_PI_2);
1035 let n = angular_bisector_normal(&a, &b);
1036 let ac = a.unit_cartesian();
1037 let bc = b.unit_cartesian();
1038 let dot_a = n[0] * ac[0] + n[1] * ac[1] + n[2] * ac[2];
1039 let dot_b = n[0] * bc[0] + n[1] * bc[1] + n[2] * bc[2];
1040 assert!(dot_a > dot_b);
1041 }
1042
1043 #[test]
1044 fn cap_exclusivity_isolated() {
1045 let a = unit(0.0, 0.1);
1046 let b = unit(PI, PI - 0.1);
1047 let exc = cap_exclusivity(0, &[a, b], &[0.2, 0.2], 50_000);
1048 assert!(exc > 0.95, "got {exc}");
1049 }
1050
1051 #[test]
1052 fn cap_exclusivity_identical_caps() {
1053 let a = unit(0.0, FRAC_PI_2);
1054 let exc = cap_exclusivity(0, &[a, a], &[0.5, 0.5], 50_000);
1055 assert!(exc < 0.05, "got {exc}");
1056 }
1057
1058 #[test]
1059 fn pairwise_overlaps_empty() {
1060 let result = pairwise_overlaps(&[], &[]);
1061 assert!(result.is_empty());
1062 }
1063
1064 #[test]
1065 fn pairwise_overlaps_single_cap() {
1066 let c = unit(0.0, FRAC_PI_2);
1067 let result = pairwise_overlaps(&[c], &[0.5]);
1068 assert!(result.is_empty());
1069 }
1070
1071 #[test]
1072 fn void_distance_empty_caps() {
1073 let p = unit(0.0, FRAC_PI_2);
1074 let d = void_distance(&p, &[], &[]);
1076 assert!(d.is_infinite() && d > 0.0);
1077 }
1078
1079 #[test]
1080 fn geodesic_deviation_two_point_path() {
1081 let a = unit(0.0, FRAC_PI_2);
1082 let b = unit(FRAC_PI_2, FRAC_PI_2);
1083 assert_eq!(geodesic_deviation(&[a, b]), 0.0);
1084 }
1085
1086 #[test]
1087 fn geodesic_density_profile_degenerate_arc() {
1088 let p = unit(0.0, FRAC_PI_2);
1089 let profile = geodesic_density_profile(&p, &p, &[p], 0.1, 5);
1090 assert_eq!(profile.len(), 5);
1091 assert!(profile.iter().all(|&c| c == 0));
1092 }
1093
1094 #[test]
1095 fn voronoi_empty_generators() {
1096 let cells = spherical_voronoi(&[], 1000);
1097 assert!(cells.is_empty());
1098 }
1099
1100 #[test]
1101 fn region_coherence_zero_radius() {
1102 let c = unit(0.0, FRAC_PI_2);
1103 let points = vec![c; 10];
1104 assert_eq!(region_coherence(&c, 0.0, &points), 0.0);
1105 }
1106}