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)]
295pub struct VoronoiCell {
296 pub generator_index: usize,
297 pub neighbor_indices: Vec<usize>,
298 pub area: f64,
300}
301
302pub fn spherical_voronoi(generators: &[SphericalPoint], num_samples: usize) -> Vec<VoronoiCell> {
312 let n = generators.len();
313 if n == 0 {
314 return Vec::new();
315 }
316 if n == 1 {
317 return vec![VoronoiCell {
318 generator_index: 0,
319 neighbor_indices: Vec::new(),
320 area: 4.0 * PI,
321 }];
322 }
323
324 let gen_carts: Vec<[f64; 3]> = generators.iter().map(|g| g.unit_cartesian()).collect();
325
326 let mut cell_counts = vec![0usize; n];
327 let mut neighbor_hits = vec![vec![false; n]; n];
328
329 let mut rng_state: u64 = 0xDEAD_BEEF_CAFE_1337;
330
331 for _ in 0..num_samples {
332 let (x, y, z) = uniform_sphere_sample(&mut rng_state);
333
334 let mut best_idx = 0;
335 let mut best_dot = f64::NEG_INFINITY;
336 let mut second_dot = f64::NEG_INFINITY;
337 let mut second_idx = 0;
338
339 for (i, gc) in gen_carts.iter().enumerate() {
340 let dot = x * gc[0] + y * gc[1] + z * gc[2];
341 if dot > best_dot {
342 second_dot = best_dot;
343 second_idx = best_idx;
344 best_dot = dot;
345 best_idx = i;
346 } else if dot > second_dot {
347 second_dot = dot;
348 second_idx = i;
349 }
350 }
351
352 cell_counts[best_idx] += 1;
353
354 let margin = best_dot - second_dot;
355 if margin < 0.05 {
356 neighbor_hits[best_idx][second_idx] = true;
357 neighbor_hits[second_idx][best_idx] = true;
358 }
359 }
360
361 let total_area = 4.0 * PI;
362 let total_samples = num_samples as f64;
363
364 (0..n)
365 .map(|i| {
366 let neighbor_indices: Vec<usize> =
367 (0..n).filter(|&j| j != i && neighbor_hits[i][j]).collect();
368 VoronoiCell {
369 generator_index: i,
370 neighbor_indices,
371 area: total_area * cell_counts[i] as f64 / total_samples,
372 }
373 })
374 .collect()
375}
376
377#[derive(Debug, Clone)]
381pub struct CoverageReport {
382 pub coverage_fraction: f64,
383 pub covered_area: f64,
384 pub overlap_area: f64,
386 pub void_count: usize,
387 pub total_samples: usize,
388}
389
390pub fn estimate_coverage(
395 centers: &[SphericalPoint],
396 half_angles: &[f64],
397 num_samples: usize,
398) -> CoverageReport {
399 assert_eq!(centers.len(), half_angles.len());
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 = num_samples as f64;
429 let total_area = 4.0 * PI;
430 let coverage_fraction = covered as f64 / total;
431
432 CoverageReport {
433 coverage_fraction,
434 covered_area: coverage_fraction * total_area,
435 overlap_area: multi_covered as f64 / total * total_area,
436 void_count: num_samples - covered,
437 total_samples: num_samples,
438 }
439}
440
441#[must_use]
445pub fn void_distance(
446 point: &SphericalPoint,
447 centers: &[SphericalPoint],
448 half_angles: &[f64],
449) -> f64 {
450 assert_eq!(centers.len(), half_angles.len());
451 let mut min_gap = f64::INFINITY;
452 for (center, &alpha) in centers.iter().zip(half_angles.iter()) {
453 let d = angular_distance(point, center);
454 let gap = d - alpha;
455 if gap < min_gap {
456 min_gap = gap;
457 }
458 }
459 min_gap
460}
461
462#[derive(Debug, Clone, Copy)]
465pub struct PairwiseOverlap {
466 pub category_a: usize,
467 pub category_b: usize,
468 pub intersection_area: f64,
469}
470
471pub fn pairwise_overlaps(centers: &[SphericalPoint], half_angles: &[f64]) -> Vec<PairwiseOverlap> {
473 assert_eq!(centers.len(), half_angles.len());
474 let n = centers.len();
475 let mut overlaps = Vec::with_capacity(n * (n - 1) / 2);
476
477 for i in 0..n {
478 for j in (i + 1)..n {
479 let area =
480 cap_intersection_area(¢ers[i], half_angles[i], ¢ers[j], half_angles[j]);
481 if area > 1e-15 {
482 overlaps.push(PairwiseOverlap {
483 category_a: i,
484 category_b: j,
485 intersection_area: area,
486 });
487 }
488 }
489 }
490
491 overlaps.sort_by(|a, b| {
492 b.intersection_area
493 .partial_cmp(&a.intersection_area)
494 .unwrap_or(std::cmp::Ordering::Equal)
495 });
496 overlaps
497}
498
499#[must_use]
502pub fn cap_exclusivity(
503 cap_index: usize,
504 centers: &[SphericalPoint],
505 half_angles: &[f64],
506 num_samples: usize,
507) -> f64 {
508 let n = centers.len();
509 assert!(cap_index < n);
510
511 let cap_carts: Vec<[f64; 3]> = centers.iter().map(|c| c.unit_cartesian()).collect();
512 let cos_alphas: Vec<f64> = half_angles.iter().map(|a| a.cos()).collect();
513
514 let center = &cap_carts[cap_index];
515 let cos_alpha = cos_alphas[cap_index];
516
517 let mut in_cap = 0usize;
518 let mut exclusive = 0usize;
519 let mut rng_state: u64 = 0xFEED_FACE_0000_0001 + cap_index as u64;
520
521 for _ in 0..num_samples {
522 let (x, y, z) = uniform_sphere_sample(&mut rng_state);
523 let dot = x * center[0] + y * center[1] + z * center[2];
524 if dot < cos_alpha {
525 continue;
526 }
527 in_cap += 1;
528
529 let mut only_this = true;
530 for (j, gc) in cap_carts.iter().enumerate() {
531 if j == cap_index {
532 continue;
533 }
534 let d = x * gc[0] + y * gc[1] + z * gc[2];
535 if d >= cos_alphas[j] {
536 only_this = false;
537 break;
538 }
539 }
540 if only_this {
541 exclusive += 1;
542 }
543 }
544
545 if in_cap == 0 {
546 return 1.0;
547 }
548 exclusive as f64 / in_cap as f64
549}
550
551#[must_use]
558pub fn spherical_excess(a: &SphericalPoint, b: &SphericalPoint, c: &SphericalPoint) -> f64 {
559 let side_a = angular_distance(b, c);
560 let side_b = angular_distance(a, c);
561 let side_c = angular_distance(a, b);
562
563 let s = (side_a + side_b + side_c) / 2.0;
564
565 let t0 = (s / 2.0).tan();
566 let t1 = ((s - side_a) / 2.0).tan();
567 let t2 = ((s - side_b) / 2.0).tan();
568 let t3 = ((s - side_c) / 2.0).tan();
569
570 let product = t0 * t1 * t2 * t3;
571 if product < 0.0 {
572 return 0.0;
573 }
574 4.0 * product.sqrt().atan()
575}
576
577pub fn curvature_signature(target: usize, all_points: &[SphericalPoint]) -> Vec<f64> {
580 let n = all_points.len();
581 if n < 3 || target >= n {
582 return Vec::new();
583 }
584 let mut excesses = Vec::new();
585 for i in 0..n {
586 if i == target {
587 continue;
588 }
589 for j in (i + 1)..n {
590 if j == target {
591 continue;
592 }
593 let e = spherical_excess(&all_points[target], &all_points[i], &all_points[j]);
594 excesses.push(e);
595 }
596 }
597 excesses.sort_by(|a, b| a.total_cmp(b));
598 excesses
599}
600
601#[must_use]
606pub fn geodesic_deviation(path: &[SphericalPoint]) -> f64 {
607 if path.len() < 3 {
608 return 0.0;
609 }
610 let start = path.first().unwrap();
611 let end = path.last().unwrap();
612 path[1..path.len() - 1]
613 .iter()
614 .map(|p| distance_to_great_circle_arc(p, start, end))
615 .fold(0.0_f64, f64::max)
616}
617
618#[inline]
621fn next_f64(state: &mut u64) -> f64 {
622 *state = state
623 .wrapping_mul(6364136223846793005)
624 .wrapping_add(1442695040888963407);
625 (*state >> 33) as f64 / (1u64 << 31) as f64
626}
627
628#[inline]
629fn uniform_sphere_sample(state: &mut u64) -> (f64, f64, f64) {
630 let theta = std::f64::consts::TAU * next_f64(state);
631 let cos_phi = 2.0 * next_f64(state) - 1.0;
632 let sin_phi = (1.0 - cos_phi * cos_phi).sqrt();
633 let (sin_t, cos_t) = theta.sin_cos();
634 (sin_phi * cos_t, sin_phi * sin_t, cos_phi)
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640 use approx::assert_relative_eq;
641 use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
642
643 fn unit(theta: f64, phi: f64) -> SphericalPoint {
644 SphericalPoint::new_unchecked(1.0, theta, phi)
645 }
646
647 #[test]
648 fn antipode_of_north_pole() {
649 let north = unit(0.0, 0.0);
650 let south = antipode(&north);
651 assert_relative_eq!(south.phi, PI, epsilon = 1e-12);
652 assert_relative_eq!(angular_distance(&north, &south), PI, epsilon = 1e-12);
653 }
654
655 #[test]
656 fn antipode_is_involution() {
657 let p = unit(1.3, 0.7);
658 let pp = antipode(&antipode(&p));
659 assert_relative_eq!(angular_distance(&p, &pp), 0.0, epsilon = 1e-10);
660 }
661
662 #[test]
663 fn antipode_always_distance_pi() {
664 for &(t, p) in &[(0.0, FRAC_PI_2), (1.0, 0.3), (3.0, 2.5), (5.5, 1.0)] {
665 let pt = unit(t, p);
666 let ap = antipode(&pt);
667 assert_relative_eq!(angular_distance(&pt, &ap), PI, epsilon = 1e-10);
668 }
669 }
670
671 #[test]
672 fn region_coherence_all_at_center() {
673 let c = unit(0.0, FRAC_PI_2);
674 let points = vec![c; 100];
675 let coh = region_coherence(&c, 0.1, &points);
676 assert!(coh > 100.0);
677 }
678
679 #[test]
680 fn region_coherence_empty() {
681 let c = unit(0.0, FRAC_PI_2);
682 assert_eq!(region_coherence(&c, 0.1, &[]), 0.0);
683 }
684
685 #[test]
686 fn cap_solid_angle_hemisphere() {
687 assert_relative_eq!(cap_solid_angle(FRAC_PI_2), 2.0 * PI, epsilon = 1e-12);
688 }
689
690 #[test]
691 fn cap_solid_angle_full_sphere() {
692 assert_relative_eq!(cap_solid_angle(PI), 4.0 * PI, epsilon = 1e-12);
693 }
694
695 #[test]
696 fn cap_solid_angle_zero() {
697 assert_relative_eq!(cap_solid_angle(0.0), 0.0, epsilon = 1e-12);
698 }
699
700 #[test]
701 fn cap_solid_angle_small() {
702 let alpha: f64 = 0.1;
703 let expected = 2.0 * PI * (1.0 - alpha.cos());
704 assert_relative_eq!(cap_solid_angle(alpha), expected, epsilon = 1e-12);
705 }
706
707 #[test]
708 fn cap_intersection_no_overlap() {
709 let a = unit(0.0, 0.1);
710 let b = unit(PI, PI - 0.1);
711 assert!(cap_intersection_area(&a, 0.2, &b, 0.2) < 1e-10);
712 }
713
714 #[test]
715 fn cap_intersection_full_containment() {
716 let a = unit(0.0, FRAC_PI_2);
717 let b = unit(0.0, FRAC_PI_2);
718 let area = cap_intersection_area(&a, FRAC_PI_2, &b, FRAC_PI_4);
719 assert_relative_eq!(area, cap_solid_angle(FRAC_PI_4), epsilon = 1e-10);
720 }
721
722 #[test]
723 fn cap_intersection_identical_caps() {
724 let a = unit(0.5, 1.0);
725 let area = cap_intersection_area(&a, 0.3, &a, 0.3);
726 assert_relative_eq!(area, cap_solid_angle(0.3), epsilon = 1e-10);
727 }
728
729 #[test]
730 fn cap_intersection_symmetric() {
731 let a = unit(0.0, FRAC_PI_4);
732 let b = unit(0.5, FRAC_PI_2);
733 let ab = cap_intersection_area(&a, 0.5, &b, 0.3);
734 let ba = cap_intersection_area(&b, 0.3, &a, 0.5);
735 assert_relative_eq!(ab, ba, epsilon = 1e-10);
736 }
737
738 #[test]
739 fn cap_intersection_positive_for_overlapping() {
740 let a = unit(0.0, FRAC_PI_2);
741 let b = unit(0.3, FRAC_PI_2);
742 let area = cap_intersection_area(&a, 0.5, &b, 0.5);
743 assert!(area > 0.0);
744 assert!(area < cap_solid_angle(0.5));
745 }
746
747 #[test]
748 fn distance_to_arc_endpoint() {
749 let a = unit(0.0, FRAC_PI_2);
750 let b = unit(FRAC_PI_2, FRAC_PI_2);
751 assert!(distance_to_great_circle_arc(&a, &a, &b) < 1e-10);
752 }
753
754 #[test]
755 fn distance_to_arc_midpoint_on_arc() {
756 let a = unit(0.0, FRAC_PI_2);
757 let b = unit(FRAC_PI_2, FRAC_PI_2);
758 let mid = crate::interpolation::slerp(&a, &b, 0.5);
759 assert!(distance_to_great_circle_arc(&mid, &a, &b) < 1e-10);
760 }
761
762 #[test]
763 fn distance_to_arc_pole_is_pi_over_2() {
764 let a = unit(0.0, FRAC_PI_2);
765 let b = unit(FRAC_PI_2, FRAC_PI_2);
766 let pole = unit(0.0, 0.0);
767 assert_relative_eq!(
768 distance_to_great_circle_arc(&pole, &a, &b),
769 FRAC_PI_2,
770 epsilon = 1e-6
771 );
772 }
773
774 #[test]
775 fn geodesic_sweep_finds_nearby() {
776 let a = unit(0.0, FRAC_PI_2);
777 let b = unit(FRAC_PI_2, FRAC_PI_2);
778 let mid = crate::interpolation::slerp(&a, &b, 0.5);
779 let far = unit(0.0, 0.1);
780 let hits = geodesic_sweep(&a, &b, &[mid, far], 0.1);
781 assert_eq!(hits.len(), 1);
782 assert_eq!(hits[0].0, 0);
783 }
784
785 #[test]
786 fn geodesic_density_profile_shape() {
787 let a = unit(0.0, FRAC_PI_2);
788 let b = unit(FRAC_PI_2, FRAC_PI_2);
789 let mid = crate::interpolation::slerp(&a, &b, 0.5);
790 let profile = geodesic_density_profile(&a, &b, &[mid], 0.1, 10);
791 assert_eq!(profile.len(), 10);
792 assert!(profile.iter().sum::<usize>() > 0);
793 }
794
795 #[test]
796 fn geodesic_deviation_straight_path() {
797 let a = unit(0.0, FRAC_PI_2);
798 let b = unit(FRAC_PI_2, FRAC_PI_2);
799 let mid = crate::interpolation::slerp(&a, &b, 0.5);
800 assert!(geodesic_deviation(&[a, mid, b]) < 1e-8);
801 }
802
803 #[test]
804 fn geodesic_deviation_detour() {
805 let a = unit(0.0, FRAC_PI_2);
806 let b = unit(FRAC_PI_2, FRAC_PI_2);
807 let detour = unit(0.0, 0.1);
808 assert!(geodesic_deviation(&[a, detour, b]) > 1.0);
809 }
810
811 #[test]
812 fn voronoi_single_point() {
813 let cells = spherical_voronoi(&[unit(0.0, FRAC_PI_2)], 1000);
814 assert_eq!(cells.len(), 1);
815 assert_relative_eq!(cells[0].area, 4.0 * PI, epsilon = 1e-12);
816 }
817
818 #[test]
819 fn voronoi_antipodal_pair_equal_area() {
820 let a = unit(0.0, FRAC_PI_2);
821 let b = antipode(&a);
822 let cells = spherical_voronoi(&[a, b], 200_000);
823 assert_eq!(cells.len(), 2);
824 assert!((cells[0].area - cells[1].area).abs() < 0.5);
825 assert!(cells[0].neighbor_indices.contains(&1));
826 }
827
828 #[test]
829 fn voronoi_total_area_is_4pi() {
830 let gens: Vec<SphericalPoint> = (0..6).map(|i| unit(i as f64 * 1.0, FRAC_PI_2)).collect();
831 let cells = spherical_voronoi(&gens, 100_000);
832 let total: f64 = cells.iter().map(|c| c.area).sum();
833 assert_relative_eq!(total, 4.0 * PI, epsilon = 0.5);
834 }
835
836 #[test]
837 fn coverage_single_hemisphere() {
838 let c = unit(0.0, 0.0);
839 let report = estimate_coverage(&[c], &[FRAC_PI_2], 100_000);
840 assert!((report.coverage_fraction - 0.5).abs() < 0.02);
841 }
842
843 #[test]
844 fn coverage_full_sphere() {
845 let c = unit(0.0, 0.0);
846 let report = estimate_coverage(&[c], &[PI], 10_000);
847 assert!((report.coverage_fraction - 1.0).abs() < 0.01);
848 }
849
850 #[test]
851 fn coverage_empty() {
852 let report = estimate_coverage(&[], &[], 10_000);
853 assert_eq!(report.coverage_fraction, 0.0);
854 assert_eq!(report.void_count, 10_000);
855 }
856
857 #[test]
858 fn void_distance_inside_cap() {
859 let c = unit(0.0, FRAC_PI_2);
860 let p = unit(0.05, FRAC_PI_2);
861 assert!(void_distance(&p, &[c], &[0.5]) < 0.0);
862 }
863
864 #[test]
865 fn void_distance_outside_all() {
866 let c = unit(0.0, 0.1);
867 let p = unit(PI, PI - 0.1);
868 assert!(void_distance(&p, &[c], &[0.2]) > 0.0);
869 }
870
871 #[test]
872 fn spherical_excess_right_triangle() {
873 let a = unit(0.0, 0.0);
874 let b = unit(0.0, FRAC_PI_2);
875 let c = unit(FRAC_PI_2, FRAC_PI_2);
876 assert_relative_eq!(spherical_excess(&a, &b, &c), FRAC_PI_2, epsilon = 1e-6);
877 }
878
879 #[test]
880 fn spherical_excess_degenerate() {
881 let a = unit(0.0, FRAC_PI_2);
882 let b = unit(0.5, FRAC_PI_2);
883 let c = crate::interpolation::slerp(&a, &b, 0.5);
884 assert!(spherical_excess(&a, &b, &c) < 1e-6);
885 }
886
887 #[test]
888 fn spherical_excess_symmetric() {
889 let a = unit(0.0, 0.5);
890 let b = unit(1.0, 1.0);
891 let c = unit(2.0, 0.8);
892 let abc = spherical_excess(&a, &b, &c);
893 let bca = spherical_excess(&b, &c, &a);
894 assert_relative_eq!(abc, bca, epsilon = 1e-12);
895 }
896
897 #[test]
898 fn curvature_signature_length() {
899 let points: Vec<SphericalPoint> = (0..5).map(|i| unit(i as f64 * 1.0, FRAC_PI_2)).collect();
900 assert_eq!(curvature_signature(0, &points).len(), 6);
901 }
902
903 #[test]
904 fn lune_classify_closer_to_a() {
905 let a = unit(0.0, FRAC_PI_2);
906 let b = unit(PI, FRAC_PI_2);
907 assert_eq!(
908 lune_classify(&a, &b, &unit(0.1, FRAC_PI_2)),
909 LuneSide::CloserToA
910 );
911 }
912
913 #[test]
914 fn lune_classify_closer_to_b() {
915 let a = unit(0.0, FRAC_PI_2);
916 let b = unit(PI, FRAC_PI_2);
917 assert_eq!(
918 lune_classify(&a, &b, &unit(PI - 0.1, FRAC_PI_2)),
919 LuneSide::CloserToB
920 );
921 }
922
923 #[test]
924 fn lune_classify_on_bisector() {
925 let a = unit(0.0, FRAC_PI_2);
926 let b = unit(PI, FRAC_PI_2);
927 assert_eq!(
928 lune_classify(&a, &b, &unit(FRAC_PI_2, FRAC_PI_2)),
929 LuneSide::OnBisector
930 );
931 }
932
933 #[test]
934 fn angular_bisector_normal_sign() {
935 let a = unit(0.0, FRAC_PI_2);
936 let b = unit(FRAC_PI_2, FRAC_PI_2);
937 let n = angular_bisector_normal(&a, &b);
938 let ac = a.unit_cartesian();
939 let bc = b.unit_cartesian();
940 let dot_a = n[0] * ac[0] + n[1] * ac[1] + n[2] * ac[2];
941 let dot_b = n[0] * bc[0] + n[1] * bc[1] + n[2] * bc[2];
942 assert!(dot_a > dot_b);
943 }
944
945 #[test]
946 fn cap_exclusivity_isolated() {
947 let a = unit(0.0, 0.1);
948 let b = unit(PI, PI - 0.1);
949 let exc = cap_exclusivity(0, &[a, b], &[0.2, 0.2], 50_000);
950 assert!(exc > 0.95, "got {exc}");
951 }
952
953 #[test]
954 fn cap_exclusivity_identical_caps() {
955 let a = unit(0.0, FRAC_PI_2);
956 let exc = cap_exclusivity(0, &[a, a], &[0.5, 0.5], 50_000);
957 assert!(exc < 0.05, "got {exc}");
958 }
959}