Skip to main content

sphereql_core/
spatial.rs

1//! Spatial analysis primitives on S².
2//!
3//! Pure geometry operating on [`SphericalPoint`] and `[f64; 3]` unit vectors.
4//! No embedding or category knowledge — those live in `sphereql-embed`.
5//!
6//! Covers: antipodal maps, spherical cap areas and intersections, great circle
7//! arc geometry, spherical Voronoi tessellation, spherical excess, lune
8//! containment, and Monte Carlo coverage estimation.
9
10use std::f64::consts::PI;
11
12use crate::distance::angular_distance;
13use crate::types::SphericalPoint;
14
15// ── §1  Antipodal geometry ────────────────────────────────────────────
16
17/// Returns the antipodal point of `p` on S² (angular distance π).
18///
19/// The antipode has θ′ = θ + π (mod 2π) and φ′ = π − φ.
20/// Radial coordinate is preserved.
21pub 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/// Measures how coherently a set of points clusters near a target on S².
28///
29/// Returns the fraction of `points` within `radius` radians of `center`,
30/// divided by the expected fraction for a uniform distribution on S²
31/// (which equals the cap's solid angle / 4π).
32///
33/// Values > 1 mean the region is denser than chance; < 1 means sparser.
34/// Returns 0.0 if `points` is empty.
35#[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// ── §2  Spherical cap areas and intersections ─────────────────────────
53
54/// Solid angle of a spherical cap with half-angle `alpha` (radians).
55///
56/// Formula: Ω = 2π(1 − cos α). Returns a value in [0, 4π].
57#[must_use]
58#[inline]
59pub fn cap_solid_angle(alpha: f64) -> f64 {
60    2.0 * PI * (1.0 - alpha.cos())
61}
62
63/// Solid angle of the intersection of two spherical caps.
64///
65/// Given two caps (center_a, α_a) and (center_b, α_b), computes the
66/// solid angle of their overlap region on S².
67///
68/// Uses the analytic formula for two-cap intersection via the lens
69/// integral. Returns 0.0 if the caps don't overlap.
70#[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    // No overlap: caps are too far apart
80    if d >= alpha_a + alpha_b {
81        return 0.0;
82    }
83
84    // One cap contains the other entirely
85    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    // The lens (intersection) area is the sum of two spherical cap sectors
93    // minus the two copies of the spherical triangle formed by the cap
94    // centers and an intersection point.
95    //
96    // Derivation (Mazonka 2012):
97    //   Ω = 2·φ_a·(1 − cos α_a) + 2·φ_b·(1 − cos α_b) − 2·E
98    // where φ_x is the dihedral half-angle of the lens at cap X's pole,
99    // and E is the spherical excess of the triangle (center_a, center_b,
100    // intersection_point) with side lengths (α_a, α_b, d), computed via
101    // L'Huilier's theorem.
102
103    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    // Spherical excess of the triangle with sides (α_a, α_b, d)
120    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/// Which side of an angular bisector a point falls on.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum LuneSide {
134    CloserToA,
135    CloserToB,
136    OnBisector,
137}
138
139/// Determines which side of the angular bisector plane between `a` and `b`
140/// a `point` lies on.
141///
142/// The bisector plane is defined by the great circle equidistant from `a`
143/// and `b`. Points on A's side are angularly closer to A than to B.
144#[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/// Returns the unit normal of the angular bisector plane between `a` and `b`.
159///
160/// The bisector plane passes through the origin and is perpendicular to the
161/// great circle arc from `a` to `b`, positioned at the midpoint.
162/// Points with dot(normal, p) > 0 are on A's side.
163#[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// ── §3  Great circle arc geometry ──────────────────────────────────────
178
179/// Angular distance from `point` to the nearest point on the great circle
180/// arc from `arc_start` to `arc_end`.
181///
182/// The arc is the shorter path (< π) between the two endpoints.
183/// Returns a value in [0, π].
184#[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    // Is `cp` on the short arc between `arc_start` and `arc_end`?
218    // `cp`, `a`, and `b` are all unit vectors on the same great circle
219    // (by construction), so both `a·cp` and `b·cp` are cos(arc-hop). The
220    // arc-between-endpoints test reduces to: both dot products ≥ cos(
221    // arc_len) ≡ dot(a, b). Cheaper than converting `cp` back to
222    // spherical just to call `angular_distance` three times.
223    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
234/// Finds all indices in `points` within `epsilon` radians of the great circle
235/// arc from `arc_start` to `arc_end`.
236///
237/// Returns `(index, distance)` pairs sorted by distance.
238pub 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
256/// Samples the great circle arc at `num_samples` equally spaced points and
257/// counts how many items in `points` lie within `radius` of each sample.
258///
259/// Returns a density profile: `density[i]` is the count at the i-th sample.
260/// Empty stretches (density = 0) indicate interdisciplinary gaps.
261pub 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// ── §4  Spherical Voronoi tessellation ─────────────────────────────────
288
289/// A cell in the spherical Voronoi diagram.
290#[derive(Debug, Clone)]
291pub struct VoronoiCell {
292    /// Index into the `generators` slice passed to [`spherical_voronoi`].
293    pub generator_index: usize,
294    /// Indices of adjacent cells (sharing a Voronoi boundary).
295    pub neighbor_indices: Vec<usize>,
296    /// Approximate cell area in steradians (Monte Carlo estimate).
297    pub area: f64,
298}
299
300/// Computes a spherical Voronoi tessellation from generator points.
301///
302/// Uses Monte Carlo sampling to estimate cell areas and determines Voronoi
303/// neighbors from boundary-proximity detection.
304///
305/// `num_samples` controls precision: 100_000 gives ~1% relative error
306/// for 31 cells; 1_000_000 gives ~0.3%.
307pub 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// ── §5  Coverage and void detection (Monte Carlo) ──────────────────────
374
375/// Result of a sphere coverage analysis.
376#[derive(Debug, Clone)]
377pub struct CoverageReport {
378    pub coverage_fraction: f64,
379    pub covered_area: f64,
380    /// Estimated area covered by two or more caps (Monte Carlo approximation).
381    pub overlap_area: f64,
382    pub void_count: usize,
383    pub total_samples: usize,
384}
385
386/// Estimates sphere coverage via Monte Carlo sampling.
387///
388/// Each "cap" is (center, half-angle). A sample is "covered" if it falls
389/// within at least one cap. Accuracy ~ O(1/√num_samples).
390pub 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/// Angular distance from `point` to the nearest cap boundary.
453///
454/// Positive = outside all caps (void). Negative = inside a cap.
455#[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// ── §6  Pairwise overlap and exclusivity ──────────────────────────────
478
479#[derive(Debug, Clone, Copy)]
480pub struct PairwiseOverlap {
481    pub category_a: usize,
482    pub category_b: usize,
483    pub intersection_area: f64,
484}
485
486/// Computes pairwise cap overlaps, sorted by descending intersection area.
487///
488/// `O(n²)` pair-scan; the outer loop is parallelized once `n` exceeds a
489/// small threshold to amortize thread-pool startup. Each worker emits
490/// its own sub-vector and the results are flattened in deterministic
491/// `(i, j)` order before the final sort.
492pub 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(&centers[i], half_angles[i], &centers[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                        &centers[i],
530                        half_angles[i],
531                        &centers[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/// Exclusivity: fraction of a cap's area not overlapped by any other cap.
556/// Returns [0, 1]. 1.0 = fully exclusive.
557#[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// ── §7  Spherical excess and curvature signatures ─────────────────────
616
617/// Spherical excess (= area) of a triangle on S² with vertices a, b, c.
618///
619/// Uses L'Huilier's theorem:
620///   E = 4·arctan(√[tan(s/2)·tan((s−a)/2)·tan((s−b)/2)·tan((s−c)/2)])
621#[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
641/// Curvature signature: distribution of spherical excesses across all
642/// triples that include the point at `target`. Sorted ascending.
643///
644/// `O(n²)` triangle scan; the outer loop is parallelized once `n` exceeds
645/// a small threshold so the per-triangle `spherical_excess` cost (4× tan,
646/// 1× sqrt, 1× atan) is spread across cores.
647pub 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// ── §8  Geodesic deviation ────────────────────────────────────────────
699
700/// Max angular distance from any interior path waypoint to the direct
701/// great circle arc between first and last waypoints.
702#[must_use]
703pub fn geodesic_deviation(path: &[SphericalPoint]) -> f64 {
704    if path.len() < 3 {
705        return 0.0;
706    }
707    // len >= 3 guaranteed by the check above.
708    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// ── Internal: uniform sphere sampling ──────────────────────────────────
717
718#[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        // With no caps, min_gap stays at INFINITY.
1075        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}