Skip to main content

proof_engine/topology/
spherical.rs

1// topology/spherical.rs — Spherical geometry: coordinates, geodesics, projections
2
3use glam::{Vec2, Vec3};
4use std::f32::consts::PI;
5
6// ─── Spherical Coordinates ─────────────────────────────────────────────────
7
8/// Spherical coordinate with polar angle theta (from +Z) and azimuthal angle phi (from +X in XY).
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct SphericalCoord {
11    /// Polar angle from the positive Z axis, in [0, PI]
12    pub theta: f32,
13    /// Azimuthal angle from the positive X axis in the XY plane, in [0, 2*PI)
14    pub phi: f32,
15}
16
17impl SphericalCoord {
18    pub fn new(theta: f32, phi: f32) -> Self {
19        Self { theta, phi }
20    }
21}
22
23/// Convert spherical coordinates to Cartesian (x, y, z) on a sphere of given radius.
24pub fn to_cartesian(coord: SphericalCoord, radius: f32) -> Vec3 {
25    let sin_theta = coord.theta.sin();
26    Vec3::new(
27        radius * sin_theta * coord.phi.cos(),
28        radius * sin_theta * coord.phi.sin(),
29        radius * coord.theta.cos(),
30    )
31}
32
33/// Convert a Cartesian position to spherical coordinates.
34/// The radius is implicitly the length of the vector.
35pub fn from_cartesian(pos: Vec3) -> SphericalCoord {
36    let r = pos.length();
37    if r < 1e-10 {
38        return SphericalCoord::new(0.0, 0.0);
39    }
40    let theta = (pos.z / r).clamp(-1.0, 1.0).acos();
41    let phi = pos.y.atan2(pos.x);
42    let phi = if phi < 0.0 { phi + 2.0 * PI } else { phi };
43    SphericalCoord::new(theta, phi)
44}
45
46/// Great-circle distance between two points on a unit sphere.
47/// Uses the Vincenty formula for numerical stability.
48pub fn great_circle_distance(a: SphericalCoord, b: SphericalCoord) -> f32 {
49    let ca = to_cartesian(a, 1.0);
50    let cb = to_cartesian(b, 1.0);
51    let dot = ca.dot(cb).clamp(-1.0, 1.0);
52    dot.acos()
53}
54
55/// Compute a geodesic (great circle arc) between two spherical coordinates.
56/// Returns `steps` points on the unit sphere as Vec3.
57pub fn geodesic(a: SphericalCoord, b: SphericalCoord, steps: usize) -> Vec<Vec3> {
58    if steps < 2 {
59        return vec![to_cartesian(a, 1.0), to_cartesian(b, 1.0)];
60    }
61    let pa = to_cartesian(a, 1.0);
62    let pb = to_cartesian(b, 1.0);
63
64    let dot = pa.dot(pb).clamp(-1.0, 1.0);
65    let omega = dot.acos();
66
67    if omega.abs() < 1e-8 {
68        // Points are the same
69        return vec![pa; steps];
70    }
71
72    let sin_omega = omega.sin();
73    let mut result = Vec::with_capacity(steps);
74
75    for i in 0..steps {
76        let t = i as f32 / (steps - 1) as f32;
77        let a_coeff = ((1.0 - t) * omega).sin() / sin_omega;
78        let b_coeff = (t * omega).sin() / sin_omega;
79        result.push(pa * a_coeff + pb * b_coeff);
80    }
81    result
82}
83
84/// Area of a spherical triangle on a unit sphere using spherical excess.
85/// Girard's theorem: Area = E = A + B + C - PI
86/// where A, B, C are the interior angles.
87pub fn spherical_triangle_area(a: SphericalCoord, b: SphericalCoord, c: SphericalCoord) -> f32 {
88    let pa = to_cartesian(a, 1.0);
89    let pb = to_cartesian(b, 1.0);
90    let pc = to_cartesian(c, 1.0);
91
92    // Compute the three dihedral angles using cross products
93    let ab = pb - pa;
94    let ac = pc - pa;
95    let ba = pa - pb;
96    let bc = pc - pb;
97    let ca = pa - pc;
98    let cb = pb - pc;
99
100    // Normal to the great circle planes
101    let n_ab = pa.cross(pb);
102    let n_ac = pa.cross(pc);
103    let n_ba = pb.cross(pa);
104    let n_bc = pb.cross(pc);
105    let n_ca = pc.cross(pa);
106    let n_cb = pc.cross(pb);
107
108    let angle_a = angle_between_planes(n_ab, n_ac);
109    let angle_b = angle_between_planes(n_ba, n_bc);
110    let angle_c = angle_between_planes(n_ca, n_cb);
111
112    let excess = angle_a + angle_b + angle_c - PI;
113    excess.abs()
114}
115
116fn angle_between_planes(n1: Vec3, n2: Vec3) -> f32 {
117    let l1 = n1.length();
118    let l2 = n2.length();
119    if l1 < 1e-10 || l2 < 1e-10 {
120        return 0.0;
121    }
122    let cos_angle = n1.dot(n2) / (l1 * l2);
123    cos_angle.clamp(-1.0, 1.0).acos()
124}
125
126// ─── Stereographic Projection ──────────────────────────────────────────────
127
128/// Stereographic projection from a unit sphere to the plane.
129/// Projects from the north pole (0,0,1) onto the z=0 plane.
130/// point3d should lie on the unit sphere.
131pub fn stereographic_projection(point3d: Vec3) -> Vec2 {
132    let denom = 1.0 - point3d.z;
133    if denom.abs() < 1e-8 {
134        // Near the north pole, project to infinity
135        return Vec2::new(f32::MAX, f32::MAX);
136    }
137    Vec2::new(point3d.x / denom, point3d.y / denom)
138}
139
140/// Inverse stereographic projection from the plane back to the unit sphere.
141pub fn inverse_stereographic(point2d: Vec2) -> Vec3 {
142    let x = point2d.x;
143    let y = point2d.y;
144    let r_sq = x * x + y * y;
145    let denom = r_sq + 1.0;
146    Vec3::new(
147        2.0 * x / denom,
148        2.0 * y / denom,
149        (r_sq - 1.0) / denom,
150    )
151}
152
153// ─── Spherical Grid ────────────────────────────────────────────────────────
154
155/// A grid of points distributed on a sphere, useful for rendering.
156pub struct SphericalGrid {
157    pub lat_divisions: usize,
158    pub lon_divisions: usize,
159}
160
161impl SphericalGrid {
162    pub fn new(lat_divisions: usize, lon_divisions: usize) -> Self {
163        Self {
164            lat_divisions,
165            lon_divisions,
166        }
167    }
168
169    /// Generate grid points on a sphere of given radius.
170    pub fn generate(&self, radius: f32) -> Vec<Vec3> {
171        let mut points = Vec::new();
172        for i in 0..=self.lat_divisions {
173            let theta = PI * i as f32 / self.lat_divisions as f32;
174            for j in 0..self.lon_divisions {
175                let phi = 2.0 * PI * j as f32 / self.lon_divisions as f32;
176                let coord = SphericalCoord::new(theta, phi);
177                points.push(to_cartesian(coord, radius));
178            }
179        }
180        points
181    }
182
183    /// Generate line segments forming the grid (for wireframe rendering).
184    pub fn generate_lines(&self, radius: f32) -> Vec<(Vec3, Vec3)> {
185        let mut lines = Vec::new();
186        let points = self.generate(radius);
187
188        for i in 0..=self.lat_divisions {
189            for j in 0..self.lon_divisions {
190                let idx = i * self.lon_divisions + j;
191                let next_j = i * self.lon_divisions + (j + 1) % self.lon_divisions;
192                if idx < points.len() && next_j < points.len() {
193                    lines.push((points[idx], points[next_j]));
194                }
195                if i < self.lat_divisions {
196                    let next_i = (i + 1) * self.lon_divisions + j;
197                    if idx < points.len() && next_i < points.len() {
198                        lines.push((points[idx], points[next_i]));
199                    }
200                }
201            }
202        }
203        lines
204    }
205}
206
207/// Generate n approximately evenly distributed points on a unit sphere using the Fibonacci spiral.
208pub fn fibonacci_sphere(n: usize) -> Vec<Vec3> {
209    if n == 0 {
210        return vec![];
211    }
212    let golden_ratio = (1.0 + 5.0_f32.sqrt()) / 2.0;
213    let mut points = Vec::with_capacity(n);
214
215    for i in 0..n {
216        let theta = (1.0 - 2.0 * i as f32 / (n as f32 - 1.0).max(1.0)).clamp(-1.0, 1.0).acos();
217        let phi = 2.0 * PI * i as f32 / golden_ratio;
218        let sin_theta = theta.sin();
219        points.push(Vec3::new(
220            sin_theta * phi.cos(),
221            sin_theta * phi.sin(),
222            theta.cos(),
223        ));
224    }
225    points
226}
227
228// ─── Tests ─────────────────────────────────────────────────────────────────
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_cartesian_roundtrip() {
236        let coord = SphericalCoord::new(1.2, 0.8);
237        let cart = to_cartesian(coord, 1.0);
238        let back = from_cartesian(cart);
239        assert!((back.theta - coord.theta).abs() < 1e-4);
240        assert!((back.phi - coord.phi).abs() < 1e-4);
241    }
242
243    #[test]
244    fn test_north_pole() {
245        let coord = SphericalCoord::new(0.0, 0.0);
246        let cart = to_cartesian(coord, 1.0);
247        assert!((cart.z - 1.0).abs() < 1e-6);
248        assert!(cart.x.abs() < 1e-6);
249        assert!(cart.y.abs() < 1e-6);
250    }
251
252    #[test]
253    fn test_south_pole() {
254        let coord = SphericalCoord::new(PI, 0.0);
255        let cart = to_cartesian(coord, 1.0);
256        assert!((cart.z - (-1.0)).abs() < 1e-5);
257    }
258
259    #[test]
260    fn test_great_circle_distance_same_point() {
261        let a = SphericalCoord::new(0.5, 1.0);
262        let d = great_circle_distance(a, a);
263        assert!(d.abs() < 1e-4);
264    }
265
266    #[test]
267    fn test_great_circle_distance_antipodal() {
268        let a = SphericalCoord::new(0.0, 0.0); // north pole
269        let b = SphericalCoord::new(PI, 0.0);   // south pole
270        let d = great_circle_distance(a, b);
271        assert!((d - PI).abs() < 1e-4, "Antipodal distance should be PI, got {}", d);
272    }
273
274    #[test]
275    fn test_geodesic_endpoints() {
276        let a = SphericalCoord::new(0.5, 0.3);
277        let b = SphericalCoord::new(1.2, 2.0);
278        let path = geodesic(a, b, 50);
279        let pa = to_cartesian(a, 1.0);
280        let pb = to_cartesian(b, 1.0);
281        assert!((path[0] - pa).length() < 1e-4);
282        assert!((path[49] - pb).length() < 1e-4);
283    }
284
285    #[test]
286    fn test_geodesic_on_sphere() {
287        let a = SphericalCoord::new(0.5, 0.0);
288        let b = SphericalCoord::new(1.5, 0.0);
289        let path = geodesic(a, b, 10);
290        for p in &path {
291            assert!((p.length() - 1.0).abs() < 1e-4, "Point not on unit sphere");
292        }
293    }
294
295    #[test]
296    fn test_spherical_triangle_area_octant() {
297        // Triangle covering one octant of the sphere
298        let a = SphericalCoord::new(0.0, 0.0);           // north pole
299        let b = SphericalCoord::new(PI / 2.0, 0.0);       // equator, x-axis
300        let c = SphericalCoord::new(PI / 2.0, PI / 2.0);  // equator, y-axis
301        let area = spherical_triangle_area(a, b, c);
302        // Expected: PI/2 (one eighth of sphere = 4*PI/8 = PI/2)
303        assert!((area - PI / 2.0).abs() < 0.2, "Octant area should be ~PI/2, got {}", area);
304    }
305
306    #[test]
307    fn test_stereographic_roundtrip() {
308        let p3 = Vec3::new(0.5, 0.3, -0.8124).normalize();
309        let p2 = stereographic_projection(p3);
310        let back = inverse_stereographic(p2);
311        assert!((p3 - back).length() < 1e-3, "Stereographic roundtrip: {:?} vs {:?}", p3, back);
312    }
313
314    #[test]
315    fn test_stereographic_south_pole() {
316        // South pole maps to origin
317        let south = Vec3::new(0.0, 0.0, -1.0);
318        let proj = stereographic_projection(south);
319        assert!(proj.length() < 1e-4);
320    }
321
322    #[test]
323    fn test_inverse_stereographic_origin() {
324        let p = inverse_stereographic(Vec2::ZERO);
325        assert!((p - Vec3::new(0.0, 0.0, -1.0)).length() < 1e-4);
326    }
327
328    #[test]
329    fn test_fibonacci_sphere_count() {
330        let points = fibonacci_sphere(100);
331        assert_eq!(points.len(), 100);
332    }
333
334    #[test]
335    fn test_fibonacci_sphere_on_sphere() {
336        let points = fibonacci_sphere(50);
337        for p in &points {
338            assert!((p.length() - 1.0).abs() < 1e-4, "Fibonacci point not on unit sphere");
339        }
340    }
341
342    #[test]
343    fn test_fibonacci_sphere_distribution() {
344        // Check that points are roughly evenly distributed by checking min distance
345        let points = fibonacci_sphere(100);
346        let mut min_dist = f32::MAX;
347        for i in 0..points.len() {
348            for j in (i + 1)..points.len() {
349                let d = (points[i] - points[j]).length();
350                if d < min_dist {
351                    min_dist = d;
352                }
353            }
354        }
355        // For 100 points, the minimum distance should be reasonable (> 0.1)
356        assert!(min_dist > 0.05, "Points too clustered: min_dist = {}", min_dist);
357    }
358
359    #[test]
360    fn test_spherical_grid() {
361        let grid = SphericalGrid::new(10, 20);
362        let points = grid.generate(1.0);
363        assert_eq!(points.len(), 11 * 20);
364        for p in &points {
365            assert!((p.length() - 1.0).abs() < 1e-4);
366        }
367    }
368}