Skip to main content

proof_engine/topology/
hyperbolic.rs

1// topology/hyperbolic.rs — Hyperbolic space rendering (Poincare disk, Klein disk, tilings)
2
3use glam::Vec2;
4use std::f32::consts::PI;
5
6// ─── Poincare Disk Model ───────────────────────────────────────────────────
7
8/// Maps world positions into the Poincare disk model of the hyperbolic plane.
9/// Points inside the unit disk represent the entire hyperbolic plane.
10pub struct PoincareDisk {
11    /// Scale factor mapping world units to disk radius
12    pub scale: f32,
13}
14
15impl PoincareDisk {
16    pub fn new(scale: f32) -> Self {
17        Self { scale }
18    }
19
20    /// Convert a world position to Poincare disk coordinates.
21    /// Uses the exponential map: disk_pos = tanh(|p|/2) * (p/|p|)
22    pub fn to_disk(&self, world_pos: Vec2) -> Vec2 {
23        let p = world_pos / self.scale;
24        let r = p.length();
25        if r < 1e-8 {
26            return Vec2::ZERO;
27        }
28        let disk_r = (r / 2.0).tanh();
29        p.normalize() * disk_r
30    }
31
32    /// Convert a Poincare disk coordinate back to world position.
33    /// Inverse of to_disk: world = 2 * atanh(|d|) * (d/|d|) * scale
34    pub fn from_disk(&self, disk_pos: Vec2) -> Vec2 {
35        let r = disk_pos.length();
36        if r < 1e-8 {
37            return Vec2::ZERO;
38        }
39        if r >= 1.0 {
40            // Clamp to boundary
41            let clamped_r = 0.9999;
42            let world_r = 2.0 * atanh(clamped_r);
43            return disk_pos.normalize() * world_r * self.scale;
44        }
45        let world_r = 2.0 * atanh(r);
46        disk_pos.normalize() * world_r * self.scale
47    }
48
49    /// Compute the hyperbolic distance between two points in the Poincare disk.
50    /// d(a,b) = acosh(1 + 2|a-b|^2 / ((1-|a|^2)(1-|b|^2)))
51    pub fn hyperbolic_distance(&self, a: Vec2, b: Vec2) -> f32 {
52        let a_sq = a.length_squared();
53        let b_sq = b.length_squared();
54        let diff_sq = (a - b).length_squared();
55
56        let denom = (1.0 - a_sq) * (1.0 - b_sq);
57        if denom <= 0.0 {
58            return f32::INFINITY;
59        }
60        let arg = 1.0 + 2.0 * diff_sq / denom;
61        acosh(arg)
62    }
63
64    /// Compute a geodesic (hyperbolic line) between two points on the disk.
65    /// In the Poincare model geodesics are circular arcs orthogonal to the boundary,
66    /// or diameters through the origin.
67    pub fn geodesic(&self, a: Vec2, b: Vec2, steps: usize) -> Vec<Vec2> {
68        if steps < 2 {
69            return vec![a, b];
70        }
71
72        // Use Mobius transforms: translate a to origin, interpolate along diameter, translate back.
73        let mut result = Vec::with_capacity(steps);
74        // Map a to origin via Mobius transform with parameter -a
75        let neg_a = -a;
76        let b_mapped = mobius_transform_impl(b, neg_a);
77
78        // b_mapped now lies on a diameter through origin — geodesic is a straight line
79        for i in 0..steps {
80            let t = i as f32 / (steps - 1) as f32;
81            let point_on_line = b_mapped * t;
82            // Map back
83            result.push(mobius_transform_impl(point_on_line, a));
84        }
85        result
86    }
87
88    /// Apply a Mobius transform (isometry of the Poincare disk).
89    /// T_a(z) = (z + a) / (1 + conj(a)*z)
90    /// treating Vec2 as complex numbers.
91    pub fn mobius_transform(&self, z: Vec2, a: Vec2) -> Vec2 {
92        mobius_transform_impl(z, a)
93    }
94}
95
96/// Complex multiply: (a+bi)(c+di) = (ac-bd) + (ad+bc)i
97fn complex_mul(a: Vec2, b: Vec2) -> Vec2 {
98    Vec2::new(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x)
99}
100
101/// Complex conjugate
102fn complex_conj(a: Vec2) -> Vec2 {
103    Vec2::new(a.x, -a.y)
104}
105
106/// Complex division: a / b
107fn complex_div(a: Vec2, b: Vec2) -> Vec2 {
108    let denom = b.x * b.x + b.y * b.y;
109    if denom < 1e-12 {
110        return Vec2::ZERO;
111    }
112    Vec2::new(
113        (a.x * b.x + a.y * b.y) / denom,
114        (a.y * b.x - a.x * b.y) / denom,
115    )
116}
117
118/// Mobius transform T_a(z) = (z + a) / (1 + conj(a)*z)
119fn mobius_transform_impl(z: Vec2, a: Vec2) -> Vec2 {
120    let numerator = z + a;
121    let denominator = Vec2::new(1.0, 0.0) + complex_mul(complex_conj(a), z);
122    complex_div(numerator, denominator)
123}
124
125fn atanh(x: f32) -> f32 {
126    0.5 * ((1.0 + x) / (1.0 - x)).ln()
127}
128
129fn acosh(x: f32) -> f32 {
130    (x + (x * x - 1.0).sqrt()).ln()
131}
132
133// ─── Klein Disk Model ──────────────────────────────────────────────────────
134
135/// The Klein (Beltrami-Klein) disk model of hyperbolic geometry.
136/// Geodesics are straight lines (chords) but angles are distorted.
137pub struct KleinDisk;
138
139impl KleinDisk {
140    /// Convert a world position to Klein disk coordinates.
141    /// Points map into the unit disk.
142    pub fn to_klein(&self, pos: Vec2) -> Vec2 {
143        let r = pos.length();
144        if r < 1e-8 {
145            return Vec2::ZERO;
146        }
147        let klein_r = (r / 2.0).tanh() * 2.0 / (1.0 + (r / 2.0).tanh().powi(2));
148        // Simplification: klein_r = tanh(r) for the Klein model
149        let klein_r = r.tanh();
150        pos.normalize() * klein_r
151    }
152
153    /// Convert Klein disk coordinates back to world position.
154    pub fn from_klein(&self, pos: Vec2) -> Vec2 {
155        let r = pos.length();
156        if r < 1e-8 {
157            return Vec2::ZERO;
158        }
159        if r >= 1.0 {
160            return pos.normalize() * 10.0; // clamp
161        }
162        let world_r = atanh(r);
163        pos.normalize() * world_r
164    }
165
166    /// Convert a Poincare disk point to Klein disk point.
167    /// K = 2P / (1 + |P|^2)
168    pub fn poincare_to_klein(pos: Vec2) -> Vec2 {
169        let r_sq = pos.length_squared();
170        pos * (2.0 / (1.0 + r_sq))
171    }
172
173    /// Convert a Klein disk point to Poincare disk point.
174    /// P = K / (1 + sqrt(1 - |K|^2))
175    pub fn klein_to_poincare(pos: Vec2) -> Vec2 {
176        let r_sq = pos.length_squared();
177        if r_sq >= 1.0 {
178            return pos.normalize() * 0.9999;
179        }
180        let factor = 1.0 + (1.0 - r_sq).sqrt();
181        pos / factor
182    }
183}
184
185// ─── Hyperbolic Polygon & Tiling ───────────────────────────────────────────
186
187/// A polygon in hyperbolic space, represented in the Poincare disk.
188#[derive(Clone, Debug)]
189pub struct HyperbolicPolygon {
190    pub vertices: Vec<Vec2>,
191    pub center: Vec2,
192}
193
194/// Generate regular tilings {p,q} in hyperbolic space.
195/// A {p,q} tiling has regular p-gons with q meeting at each vertex.
196/// The tiling is hyperbolic when (p-2)(q-2) > 4.
197pub struct HyperbolicTiling;
198
199impl HyperbolicTiling {
200    /// Generate a {p,q} tiling up to the given depth (number of layers from center).
201    /// Returns polygons in Poincare disk coordinates.
202    pub fn generate(p: usize, q: usize, depth: usize) -> Vec<HyperbolicPolygon> {
203        if p < 3 || q < 3 {
204            return vec![];
205        }
206        // Check that tiling is hyperbolic
207        if (p - 2) * (q - 2) <= 4 {
208            return vec![]; // Euclidean or spherical, not hyperbolic
209        }
210
211        let mut polygons = Vec::new();
212        let mut visited_centers: Vec<Vec2> = Vec::new();
213
214        // Compute the radius of the central polygon's circumscribed circle in the Poincare disk
215        let central_radius = central_polygon_radius(p, q);
216
217        // Create central polygon
218        let central = create_regular_polygon(Vec2::ZERO, central_radius, p, 0.0);
219        polygons.push(central.clone());
220        visited_centers.push(Vec2::ZERO);
221
222        if depth == 0 {
223            return polygons;
224        }
225
226        // BFS expansion
227        let mut frontier: Vec<HyperbolicPolygon> = vec![central];
228
229        for _layer in 0..depth {
230            let mut next_frontier = Vec::new();
231            for poly in &frontier {
232                // For each edge, try to place an adjacent polygon
233                let n = poly.vertices.len();
234                for i in 0..n {
235                    let v1 = poly.vertices[i];
236                    let v2 = poly.vertices[(i + 1) % n];
237                    let edge_mid = (v1 + v2) * 0.5;
238
239                    // Reflect center through this edge to get neighbor center
240                    let neighbor_center = reflect_through_geodesic(poly.center, v1, v2);
241
242                    if neighbor_center.length() >= 0.98 {
243                        continue; // too close to boundary
244                    }
245
246                    // Check if we already have a polygon near this center
247                    let dominated = visited_centers.iter().any(|c| (*c - neighbor_center).length() < 0.01);
248                    if dominated {
249                        continue;
250                    }
251
252                    // Create new polygon via Mobius transform
253                    // Move origin to neighbor center, create polygon, move back
254                    let new_poly = create_polygon_at(neighbor_center, central_radius, p, v1, v2);
255                    visited_centers.push(neighbor_center);
256                    polygons.push(new_poly.clone());
257                    next_frontier.push(new_poly);
258                }
259            }
260            frontier = next_frontier;
261        }
262
263        polygons
264    }
265}
266
267/// Compute the Poincare disk radius of the central polygon in a {p,q} tiling.
268fn central_polygon_radius(p: usize, q: usize) -> f32 {
269    let pi_p = PI / p as f32;
270    let pi_q = PI / q as f32;
271    // The circumradius in the hyperbolic plane satisfies:
272    // cosh(R) = cos(pi/q) / sin(pi/p)
273    let cosh_r = pi_q.cos() / pi_p.sin();
274    let hyp_r = acosh(cosh_r);
275    // Convert hyperbolic distance to Poincare disk radius: r = tanh(R/2)
276    (hyp_r / 2.0).tanh()
277}
278
279/// Create a regular p-gon centered at `center` with circumradius `radius` in disk coords.
280fn create_regular_polygon(center: Vec2, radius: f32, p: usize, rotation: f32) -> HyperbolicPolygon {
281    let mut vertices = Vec::with_capacity(p);
282    for i in 0..p {
283        let angle = rotation + 2.0 * PI * i as f32 / p as f32;
284        let vertex_local = Vec2::new(angle.cos(), angle.sin()) * radius;
285        // Translate from origin to center using Mobius transform
286        let vertex = mobius_transform_impl(vertex_local, center);
287        vertices.push(vertex);
288    }
289    HyperbolicPolygon { vertices, center }
290}
291
292/// Create a polygon at a given center, oriented to share the edge (v1, v2) with its neighbor.
293fn create_polygon_at(center: Vec2, radius: f32, p: usize, v1: Vec2, v2: Vec2) -> HyperbolicPolygon {
294    // Determine the rotation so two of the polygon's vertices coincide with v1 and v2
295    let edge_mid = (v1 + v2) * 0.5;
296    let to_edge = edge_mid - center;
297    let base_angle = to_edge.y.atan2(to_edge.x);
298    let rotation = base_angle - PI / p as f32;
299
300    create_regular_polygon(center, radius, p, rotation)
301}
302
303/// Reflect point `p` through the hyperbolic geodesic defined by points a, b on the Poincare disk.
304fn reflect_through_geodesic(p: Vec2, a: Vec2, b: Vec2) -> Vec2 {
305    // Move a to origin
306    let neg_a = -a;
307    let b_m = mobius_transform_impl(b, neg_a);
308    let p_m = mobius_transform_impl(p, neg_a);
309
310    // Now geodesic through origin and b_m is a straight line (diameter)
311    // Reflect p_m through this line
312    let angle = b_m.y.atan2(b_m.x);
313    let cos2 = (2.0 * angle).cos();
314    let sin2 = (2.0 * angle).sin();
315    let reflected = Vec2::new(
316        p_m.x * cos2 + p_m.y * sin2,
317        p_m.x * sin2 - p_m.y * cos2,
318    );
319
320    // Move back
321    mobius_transform_impl(reflected, a)
322}
323
324// ─── Hyperbolic Grid ───────────────────────────────────────────────────────
325
326/// A deformed grid for rendering game elements in hyperbolic space.
327/// Space expands exponentially as you move away from the center.
328pub struct HyperbolicGrid {
329    pub disk: PoincareDisk,
330    pub grid_lines: usize,
331}
332
333impl HyperbolicGrid {
334    pub fn new(scale: f32, grid_lines: usize) -> Self {
335        Self {
336            disk: PoincareDisk::new(scale),
337            grid_lines,
338        }
339    }
340
341    /// Generate a deformed grid of points in the Poincare disk model.
342    /// Returns pairs of points representing line segments.
343    pub fn generate_grid(&self) -> Vec<(Vec2, Vec2)> {
344        let mut segments = Vec::new();
345        let n = self.grid_lines;
346        let step = 2.0 * self.disk.scale / n as f32;
347
348        // Horizontal lines
349        for j in 0..=n {
350            let y = -self.disk.scale + j as f32 * step;
351            let mut prev = None;
352            for i in 0..=n * 4 {
353                let x = -self.disk.scale + i as f32 * step / 4.0;
354                let world = Vec2::new(x, y);
355                let disk = self.disk.to_disk(world);
356                if disk.length() < 0.99 {
357                    if let Some(p) = prev {
358                        segments.push((p, disk));
359                    }
360                    prev = Some(disk);
361                } else {
362                    prev = None;
363                }
364            }
365        }
366
367        // Vertical lines
368        for i in 0..=n {
369            let x = -self.disk.scale + i as f32 * step;
370            let mut prev = None;
371            for j in 0..=n * 4 {
372                let y = -self.disk.scale + j as f32 * step / 4.0;
373                let world = Vec2::new(x, y);
374                let disk = self.disk.to_disk(world);
375                if disk.length() < 0.99 {
376                    if let Some(p) = prev {
377                        segments.push((p, disk));
378                    }
379                    prev = Some(disk);
380                } else {
381                    prev = None;
382                }
383            }
384        }
385
386        segments
387    }
388
389    /// Transform an entity position from world space to hyperbolic disk space.
390    pub fn transform_entity(&self, world_pos: Vec2) -> Vec2 {
391        self.disk.to_disk(world_pos)
392    }
393
394    /// Transform multiple entity positions.
395    pub fn transform_entities(&self, positions: &[Vec2]) -> Vec<Vec2> {
396        positions.iter().map(|p| self.disk.to_disk(*p)).collect()
397    }
398}
399
400// ─── Tests ─────────────────────────────────────────────────────────────────
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_poincare_roundtrip() {
408        let disk = PoincareDisk::new(10.0);
409        let world = Vec2::new(3.0, 4.0);
410        let d = disk.to_disk(world);
411        let back = disk.from_disk(d);
412        assert!((world - back).length() < 0.01, "Roundtrip failed: {:?} vs {:?}", world, back);
413    }
414
415    #[test]
416    fn test_poincare_origin() {
417        let disk = PoincareDisk::new(1.0);
418        let d = disk.to_disk(Vec2::ZERO);
419        assert!(d.length() < 1e-6);
420    }
421
422    #[test]
423    fn test_disk_points_inside_unit_circle() {
424        let disk = PoincareDisk::new(5.0);
425        for x in -10..=10 {
426            for y in -10..=10 {
427                let d = disk.to_disk(Vec2::new(x as f32, y as f32));
428                assert!(d.length() < 1.0 + 1e-6, "Point outside disk: {:?}", d);
429            }
430        }
431    }
432
433    #[test]
434    fn test_hyperbolic_distance_zero() {
435        let disk = PoincareDisk::new(1.0);
436        let a = Vec2::new(0.3, 0.2);
437        let d = disk.hyperbolic_distance(a, a);
438        assert!(d.abs() < 1e-4, "Self-distance should be ~0, got {}", d);
439    }
440
441    #[test]
442    fn test_hyperbolic_distance_symmetry() {
443        let disk = PoincareDisk::new(1.0);
444        let a = Vec2::new(0.3, 0.1);
445        let b = Vec2::new(-0.2, 0.4);
446        let d1 = disk.hyperbolic_distance(a, b);
447        let d2 = disk.hyperbolic_distance(b, a);
448        assert!((d1 - d2).abs() < 1e-4, "Distance not symmetric: {} vs {}", d1, d2);
449    }
450
451    #[test]
452    fn test_mobius_preserves_distance() {
453        let disk = PoincareDisk::new(1.0);
454        let a = Vec2::new(0.2, 0.1);
455        let b = Vec2::new(-0.1, 0.3);
456        let c = Vec2::new(0.15, -0.05);
457
458        let d_before = disk.hyperbolic_distance(a, b);
459        let a_t = disk.mobius_transform(a, c);
460        let b_t = disk.mobius_transform(b, c);
461        let d_after = disk.hyperbolic_distance(a_t, b_t);
462
463        assert!((d_before - d_after).abs() < 0.05,
464            "Mobius should preserve distance: {} vs {}", d_before, d_after);
465    }
466
467    #[test]
468    fn test_geodesic_endpoints() {
469        let disk = PoincareDisk::new(1.0);
470        let a = Vec2::new(0.2, 0.1);
471        let b = Vec2::new(-0.3, 0.2);
472        let path = disk.geodesic(a, b, 20);
473        assert!((path[0] - a).length() < 1e-4);
474        assert!((path[path.len() - 1] - b).length() < 0.05);
475    }
476
477    #[test]
478    fn test_klein_poincare_roundtrip() {
479        let p = Vec2::new(0.3, 0.2);
480        let k = KleinDisk::poincare_to_klein(p);
481        let back = KleinDisk::klein_to_poincare(k);
482        assert!((p - back).length() < 1e-4, "Klein-Poincare roundtrip failed");
483    }
484
485    #[test]
486    fn test_hyperbolic_tiling_basic() {
487        // {5,4} tiling (pentagons, 4 at each vertex)
488        let polygons = HyperbolicTiling::generate(5, 4, 1);
489        assert!(!polygons.is_empty(), "Tiling should produce polygons");
490        for poly in &polygons {
491            assert_eq!(poly.vertices.len(), 5);
492            for v in &poly.vertices {
493                assert!(v.length() < 1.0 + 1e-3, "Vertex outside disk");
494            }
495        }
496    }
497
498    #[test]
499    fn test_hyperbolic_tiling_not_hyperbolic() {
500        // {4,4} is Euclidean, should return empty
501        let polygons = HyperbolicTiling::generate(4, 4, 2);
502        assert!(polygons.is_empty());
503    }
504
505    #[test]
506    fn test_hyperbolic_grid_generation() {
507        let grid = HyperbolicGrid::new(5.0, 4);
508        let segments = grid.generate_grid();
509        assert!(!segments.is_empty());
510        for (a, b) in &segments {
511            assert!(a.length() < 1.0);
512            assert!(b.length() < 1.0);
513        }
514    }
515
516    #[test]
517    fn test_complex_operations() {
518        let a = Vec2::new(1.0, 2.0);
519        let b = Vec2::new(3.0, 4.0);
520        let prod = complex_mul(a, b);
521        // (1+2i)(3+4i) = 3+4i+6i+8i^2 = -5+10i
522        assert!((prod.x - (-5.0)).abs() < 1e-6);
523        assert!((prod.y - 10.0).abs() < 1e-6);
524    }
525}