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 / §5  Spherical cap areas and coverage ─────────────────────────
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///
291/// NOTE: item-level Voronoi (beyond category centroids) may be explored
292/// in a future version. For now, the input is expected to be O(10–100)
293/// category centroids.
294#[derive(Debug, Clone)]
295pub struct VoronoiCell {
296    pub generator_index: usize,
297    pub neighbor_indices: Vec<usize>,
298    /// Approximate cell area in steradians, estimated via Monte Carlo.
299    pub area: f64,
300}
301
302/// Computes a spherical Voronoi tessellation from generator points.
303///
304/// Uses Monte Carlo sampling to estimate cell areas and determines Voronoi
305/// neighbors from boundary-proximity detection.
306///
307/// `num_samples` controls precision: 100_000 gives ~1% relative error
308/// for 31 cells; 1_000_000 gives ~0.3%.
309///
310/// NOTE: exact Delaunay-on-S² via convex hull may be added later.
311pub 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// ── §2  Coverage and void detection (Monte Carlo) ──────────────────────
378
379/// Result of a sphere coverage analysis.
380#[derive(Debug, Clone)]
381pub struct CoverageReport {
382    pub coverage_fraction: f64,
383    pub covered_area: f64,
384    /// NOTE: exact inclusion-exclusion may be added in a future version.
385    pub overlap_area: f64,
386    pub void_count: usize,
387    pub total_samples: usize,
388}
389
390/// Estimates sphere coverage via Monte Carlo sampling.
391///
392/// Each "cap" is (center, half-angle). A sample is "covered" if it falls
393/// within at least one cap. Accuracy ~ O(1/√num_samples).
394pub 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/// Angular distance from `point` to the nearest cap boundary.
442///
443/// Positive = outside all caps (void). Negative = inside a cap.
444#[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// ── §5  Pairwise overlap and exclusivity ───────────────────────────────
463
464#[derive(Debug, Clone, Copy)]
465pub struct PairwiseOverlap {
466    pub category_a: usize,
467    pub category_b: usize,
468    pub intersection_area: f64,
469}
470
471/// Computes pairwise cap overlaps, sorted by descending intersection area.
472pub 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(&centers[i], half_angles[i], &centers[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/// Exclusivity: fraction of a cap's area not overlapped by any other cap.
500/// Returns [0, 1]. 1.0 = fully exclusive.
501#[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// ── §6  Spherical excess and curvature signatures ──────────────────────
552
553/// Spherical excess (= area) of a triangle on S² with vertices a, b, c.
554///
555/// Uses L'Huilier's theorem:
556///   E = 4·arctan(√[tan(s/2)·tan((s−a)/2)·tan((s−b)/2)·tan((s−c)/2)])
557#[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
577/// Curvature signature: distribution of spherical excesses across all
578/// triples that include the point at `target`. Sorted ascending.
579pub 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// ── §3  Geodesic deviation ─────────────────────────────────────────────
602
603/// Max angular distance from any interior path waypoint to the direct
604/// great circle arc between first and last waypoints.
605#[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// ── Internal: uniform sphere sampling ──────────────────────────────────
619
620#[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}