Skip to main content

loam_math/
quotient.rs

1//! Isometries act on the cover; wrap_to_domain returns a representative and its deck element.
2//! LensSpace uses the z1 wedge, which does not select a unique representative on z1 = 0.
3
4use std::borrow::Cow;
5use std::f32::consts::{PI, TAU};
6
7use glam::{BVec3, Mat4, Quat, Vec3, Vec4};
8
9use crate::euclidean::Iso3;
10use crate::space::{IsometryGroup, Space, WgslAccuracy, WgslSpace, FLAT_CHART_MAX_ARC};
11use crate::spherical::Iso4;
12use crate::spherical_embedded::SphericalS3Embedded;
13
14pub trait CoveringSpace: Space {
15    type CoverIso: Copy + Send + Sync + 'static;
16
17    fn cover_identity(&self) -> Self::CoverIso;
18
19    fn cover_compose(&self, a: Self::CoverIso, b: Self::CoverIso) -> Self::CoverIso;
20
21    fn cover_inverse(&self, a: Self::CoverIso) -> Self::CoverIso;
22
23    fn cover_apply(&self, iso: Self::CoverIso, p: Self::Point) -> Self::Point;
24
25    fn cover_transport(
26        &self,
27        iso: Self::CoverIso,
28        at: Self::Point,
29        v: Self::Vector,
30    ) -> Self::Vector;
31}
32
33pub trait QuotientSpace: CoveringSpace {
34    /// Iteration order is part of the contract.
35    fn face_pairings(&self) -> impl Iterator<Item = Self::CoverIso>;
36
37    fn in_fundamental_domain(&self, p: Self::Point) -> bool;
38
39    fn wrap_to_domain(&self, p: Self::Point) -> (Self::Point, Self::CoverIso);
40}
41
42// Conway and Sloane, Sphere Packings, Lattices and Groups, 1988, ch. 4 §1.
43/// Rectangular torus with canonical representatives in `[-cᵢ/2, cᵢ/2)`.
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub struct FlatTorus3 {
46    cell: Vec3,
47}
48
49impl FlatTorus3 {
50    /// Panics unless every side is finite and positive.
51    pub fn new(cell: Vec3) -> Self {
52        assert!(
53            cell.is_finite() && cell.cmpgt(Vec3::ZERO).all(),
54            "FlatTorus3 cell must be finite and positive, got {cell:?}"
55        );
56        Self { cell }
57    }
58
59    pub fn cube(side: f32) -> Self {
60        Self::new(Vec3::splat(side))
61    }
62
63    pub fn cell(&self) -> Vec3 {
64        self.cell
65    }
66
67    pub fn injectivity_radius(&self) -> f32 {
68        0.5 * self.cell.min_element()
69    }
70
71    fn lattice_index(&self, p: Vec3) -> Vec3 {
72        let index = (p / self.cell + Vec3::splat(0.5)).floor();
73        let residual = p - self.cell * index;
74        let half = 0.5 * self.cell;
75        index - unit_where(residual.cmplt(-half)) + unit_where(residual.cmpge(half))
76    }
77
78    fn wrap(&self, p: Vec3) -> Vec3 {
79        p - self.cell * self.lattice_index(p)
80    }
81}
82
83fn unit_where(mask: BVec3) -> Vec3 {
84    Vec3::select(mask, Vec3::ONE, Vec3::ZERO)
85}
86
87impl Space for FlatTorus3 {
88    type Point = Vec3;
89    type Vector = Vec3;
90    type Frame = Quat;
91
92    fn frame_at(&self, _at: Vec3) -> Quat {
93        Quat::IDENTITY
94    }
95
96    fn distance(&self, a: Vec3, b: Vec3) -> f32 {
97        self.wrap(a - b).length()
98    }
99
100    fn exp(&self, at: Vec3, v: Vec3) -> Vec3 {
101        self.wrap(at + v)
102    }
103
104    fn log(&self, from: Vec3, to: Vec3) -> Vec3 {
105        self.wrap(to - from)
106    }
107
108    fn parallel_transport(&self, _from: Vec3, _to: Vec3, v: Vec3) -> Vec3 {
109        v
110    }
111
112    fn is_chart_flat(&self) -> bool {
113        false
114    }
115
116    fn chart_envelope(&self) -> f32 {
117        f32::INFINITY
118    }
119
120    fn valid_point(&self, p: Vec3) -> bool {
121        p.is_finite()
122    }
123}
124
125impl CoveringSpace for FlatTorus3 {
126    type CoverIso = Iso3;
127
128    fn cover_identity(&self) -> Iso3 {
129        Iso3::IDENTITY
130    }
131
132    fn cover_compose(&self, a: Iso3, b: Iso3) -> Iso3 {
133        Iso3 {
134            rotation: a.rotation * b.rotation,
135            translation: a.rotation * b.translation + a.translation,
136        }
137    }
138
139    fn cover_inverse(&self, a: Iso3) -> Iso3 {
140        let inverse_rotation = a.rotation.inverse();
141        Iso3 {
142            rotation: inverse_rotation,
143            translation: inverse_rotation * (-a.translation),
144        }
145    }
146
147    fn cover_apply(&self, iso: Iso3, p: Vec3) -> Vec3 {
148        iso.rotation * p + iso.translation
149    }
150
151    fn cover_transport(&self, iso: Iso3, _at: Vec3, v: Vec3) -> Vec3 {
152        iso.rotation * v
153    }
154}
155
156impl QuotientSpace for FlatTorus3 {
157    fn face_pairings(&self) -> impl Iterator<Item = Iso3> {
158        [
159            Iso3::from_translation(Vec3::new(self.cell.x, 0.0, 0.0)),
160            Iso3::from_translation(Vec3::new(0.0, self.cell.y, 0.0)),
161            Iso3::from_translation(Vec3::new(0.0, 0.0, self.cell.z)),
162        ]
163        .into_iter()
164    }
165
166    fn in_fundamental_domain(&self, p: Vec3) -> bool {
167        let half = 0.5 * self.cell;
168        p.cmpge(-half).all() && p.cmplt(half).all()
169    }
170
171    fn wrap_to_domain(&self, p: Vec3) -> (Vec3, Iso3) {
172        let translation = -(self.cell * self.lattice_index(p));
173
174        (p + translation, Iso3::from_translation(translation))
175    }
176}
177
178impl WgslSpace for FlatTorus3 {
179    fn wgsl_impl(&self) -> Cow<'static, str> {
180        Cow::Owned(flat_torus3_wgsl(self.cell))
181    }
182
183    fn wgsl_accuracy(&self) -> WgslAccuracy {
184        WgslAccuracy::Exact
185    }
186}
187
188fn flat_torus3_wgsl(cell: Vec3) -> String {
189    let (cx, cy, cz) = (cell.x, cell.y, cell.z);
190    let (hx, hy, hz) = (0.5 * cell.x, 0.5 * cell.y, 0.5 * cell.z);
191    format!(
192        r#"
193
194const LOAM_MAX_ARC: f32 = {max_arc:?};
195const LOAM_TORUS_CELL: vec3<f32> = vec3<f32>({cx:?}, {cy:?}, {cz:?});
196const LOAM_TORUS_HALF: vec3<f32> = vec3<f32>({hx:?}, {hy:?}, {hz:?});
197
198fn loam_torus_wrap(p: vec3<f32>) -> vec3<f32> {{
199    let index = floor(p / LOAM_TORUS_CELL + vec3<f32>(0.5));
200    let residual = p - LOAM_TORUS_CELL * index;
201    let below = select(vec3<f32>(0.0), LOAM_TORUS_CELL, residual < -LOAM_TORUS_HALF);
202    let above = select(vec3<f32>(0.0), LOAM_TORUS_CELL, residual >= LOAM_TORUS_HALF);
203    return residual + below - above;
204}}
205
206fn loam_distance(a: vec3<f32>, b: vec3<f32>) -> f32 {{ return length(loam_torus_wrap(a - b)); }}
207fn loam_origin_distance(p: vec3<f32>) -> f32 {{ return length(loam_torus_wrap(p)); }}
208fn loam_exp(at: vec3<f32>, v: vec3<f32>) -> vec3<f32> {{ return loam_torus_wrap(at + v); }}
209fn loam_log(p_from: vec3<f32>, p_to: vec3<f32>) -> vec3<f32> {{ return loam_torus_wrap(p_to - p_from); }}
210struct LoamGeodesicStep {{ p: vec3<f32>, v: vec3<f32> }}
211fn loam_geodesic_step(p: vec3<f32>, v: vec3<f32>, s: f32) -> LoamGeodesicStep {{
212    return LoamGeodesicStep(loam_exp(p, v * s), v);
213}}
214"#,
215        max_arc = FLAT_CHART_MAX_ARC
216    )
217}
218
219const COVER: SphericalS3Embedded = SphericalS3Embedded;
220
221// Rolfsen, Knots and Links, 1976, §9.B.
222// Hatcher, Algebraic Topology, 2002, Example 2.43.
223/// Lens space with `z₁ = x + iy`, `z₂ = z + iw` and deck action `(z₁, z₂) ↦ (e^(2πi/p) z₁, e^(2πiq/p) z₂)`.
224#[derive(Clone, Copy, Debug, PartialEq, Eq)]
225pub struct LensSpace {
226    p: u32,
227    q: u32,
228}
229
230impl LensSpace {
231    /// Panics unless `p ≥ 2` and `gcd(p, q) = 1`; `q` is reduced mod `p`.
232    pub fn new(p: u32, q: u32) -> Self {
233        assert!(
234            p >= 2 && p <= i32::MAX as u32,
235            "LensSpace needs p >= 2 and p <= i32::MAX, got p = {p}"
236        );
237        let q = q % p;
238        assert!(
239            gcd(p, q) == 1,
240            "LensSpace needs gcd(p, q) = 1 for a free action, got p = {p}, q = {q}"
241        );
242        Self { p, q }
243    }
244
245    pub fn p(&self) -> u32 {
246        self.p
247    }
248
249    /// Twist of the gluing, reduced mod `p`.
250    pub fn q(&self) -> u32 {
251        self.q
252    }
253
254    pub fn injectivity_radius(&self) -> f32 {
255        PI / self.p as f32
256    }
257
258    /// `g^power`, with the power reduced mod `p` in integers so `deck(0)` is exact.
259    pub fn deck(&self, power: i32) -> Iso4 {
260        let (beta1, beta2) = self.deck_angles(power);
261        let (sin1, cos1) = beta1.sin_cos();
262        let (sin2, cos2) = beta2.sin_cos();
263        Iso4 {
264            matrix: Mat4::from_cols(
265                Vec4::new(cos1, sin1, 0.0, 0.0),
266                Vec4::new(-sin1, cos1, 0.0, 0.0),
267                Vec4::new(0.0, 0.0, cos2, sin2),
268                Vec4::new(0.0, 0.0, -sin2, cos2),
269            ),
270        }
271    }
272
273    fn deck_angles(&self, power: i32) -> (f32, f32) {
274        let p = self.p as i64;
275        let k = (power as i64).rem_euclid(p);
276        let step = TAU / self.p as f32;
277        (step * k as f32, step * ((k * self.q as i64) % p) as f32)
278    }
279
280    fn wedge_parameter(&self, x: Vec4) -> f32 {
281        x.y.atan2(x.x) * (self.p as f32 / TAU) + 0.5
282    }
283
284    fn wedge_offset(&self, x: Vec4) -> i32 {
285        -(self.wedge_parameter(x).floor() as i32)
286    }
287
288    fn nearest_lift(&self, from: Vec4, to: Vec4) -> (i32, Vec4, f32) {
289        let mut best_lift = self.cover_apply(self.deck(0), to);
290        let mut best_power = 0;
291        let mut best_distance = COVER.distance(from, best_lift);
292        for power in 1..self.p as i32 {
293            let lift = self.cover_apply(self.deck(power), to);
294            let distance = COVER.distance(from, lift);
295            if distance < best_distance {
296                best_lift = lift;
297                best_power = power;
298                best_distance = distance;
299            }
300        }
301        (best_power, best_lift, best_distance)
302    }
303
304    /// Emits an ambient `vec4<f32>` prelude, outside the [`WgslSpace`] ABI.
305    pub fn wgsl_prelude(&self) -> String {
306        let p = self.p;
307        let wedges_per_turn = self.p as f32 / TAU;
308        let table = (0..self.p as i32)
309            .map(|power| {
310                let m = self.deck(power).matrix;
311                format!(
312                    "vec4<f32>({:?}, {:?}, {:?}, {:?})",
313                    m.x_axis.x, m.x_axis.y, m.z_axis.z, m.z_axis.w
314                )
315            })
316            .collect::<Vec<_>>()
317            .join(",\n    ");
318        format!(
319            r#"
320
321const LOAM_LENS_P: i32 = {p};
322const LOAM_LENS_WEDGES_PER_TURN: f32 = {wedges_per_turn:?};
323const LOAM_LENS_DECK = array<vec4<f32>, {p}>(
324    {table}
325);
326
327fn loam_lens_apply(x: vec4<f32>, power: i32) -> vec4<f32> {{
328    let k = ((power % LOAM_LENS_P) + LOAM_LENS_P) % LOAM_LENS_P;
329    let d = LOAM_LENS_DECK[k];
330    return normalize(vec4<f32>(
331        x.x * d.x - x.y * d.y,
332        x.x * d.y + x.y * d.x,
333        x.z * d.z - x.w * d.w,
334        x.z * d.w + x.w * d.z,
335    ));
336}}
337
338fn loam_lens_wedge_offset(x: vec4<f32>) -> i32 {{
339    return -i32(floor(atan2(x.y, x.x) * LOAM_LENS_WEDGES_PER_TURN + 0.5));
340}}
341
342fn loam_lens_wrap(x: vec4<f32>) -> vec4<f32> {{
343    var power = loam_lens_wedge_offset(x);
344    var wrapped = loam_lens_apply(x, power);
345    let correction = loam_lens_wedge_offset(wrapped);
346    if correction != 0 {{
347        power += correction;
348        wrapped = loam_lens_apply(x, power);
349    }}
350    return wrapped;
351}}
352
353fn loam_lens_distance(a: vec4<f32>, b: vec4<f32>) -> f32 {{
354    var best = 2.0;
355    for (var k = 0; k < LOAM_LENS_P; k = k + 1) {{
356        best = min(best, length(a - loam_lens_apply(b, k)) * 0.5);
357    }}
358    return 2.0 * asin(clamp(best, 0.0, 1.0));
359}}
360
361fn loam_lens_nearest_power(a: vec4<f32>, b: vec4<f32>) -> i32 {{
362    var best = 2.0;
363    var power = 0;
364    for (var k = 0; k < LOAM_LENS_P; k = k + 1) {{
365        let half_chord = length(a - loam_lens_apply(b, k)) * 0.5;
366        if half_chord < best {{
367            best = half_chord;
368            power = k;
369        }}
370    }}
371    return power;
372}}
373"#
374        )
375    }
376}
377
378// Knuth, TAOCP vol. 2, 3rd ed., §4.5.2.
379fn gcd(a: u32, b: u32) -> u32 {
380    if b == 0 {
381        a
382    } else {
383        gcd(b, a % b)
384    }
385}
386
387impl Space for LensSpace {
388    type Point = Vec4;
389    type Vector = Vec4;
390    type Frame = Iso4;
391
392    fn frame_at(&self, at: Vec4) -> Iso4 {
393        Iso4::left_translation(at)
394    }
395
396    fn distance(&self, a: Vec4, b: Vec4) -> f32 {
397        self.nearest_lift(a, b).2
398    }
399
400    fn exp(&self, at: Vec4, v: Vec4) -> Vec4 {
401        self.wrap_to_domain(COVER.exp(at, v)).0
402    }
403
404    fn log(&self, from: Vec4, to: Vec4) -> Vec4 {
405        let (_, lift, _) = self.nearest_lift(from, to);
406        COVER.log(from, lift)
407    }
408
409    fn parallel_transport(&self, from: Vec4, to: Vec4, v: Vec4) -> Vec4 {
410        let (power, lift, _) = self.nearest_lift(from, to);
411        let carried = COVER.parallel_transport(from, lift, v);
412
413        self.cover_transport(self.deck(-power), lift, carried)
414    }
415
416    fn chart_envelope(&self) -> f32 {
417        COVER.chart_envelope()
418    }
419
420    fn valid_point(&self, p: Vec4) -> bool {
421        COVER.valid_point(p)
422    }
423}
424
425impl CoveringSpace for LensSpace {
426    type CoverIso = Iso4;
427
428    fn cover_identity(&self) -> Iso4 {
429        COVER.iso_identity()
430    }
431
432    fn cover_compose(&self, a: Iso4, b: Iso4) -> Iso4 {
433        COVER.iso_compose(a, b)
434    }
435
436    fn cover_inverse(&self, a: Iso4) -> Iso4 {
437        COVER.iso_inverse(a)
438    }
439
440    fn cover_apply(&self, iso: Iso4, p: Vec4) -> Vec4 {
441        COVER.iso_apply(iso, p)
442    }
443
444    fn cover_transport(&self, iso: Iso4, at: Vec4, v: Vec4) -> Vec4 {
445        COVER.iso_transport(iso, at, v)
446    }
447}
448
449impl QuotientSpace for LensSpace {
450    fn face_pairings(&self) -> impl Iterator<Item = Iso4> {
451        std::iter::once(self.deck(1))
452    }
453
454    fn in_fundamental_domain(&self, p: Vec4) -> bool {
455        (0.0..1.0).contains(&self.wedge_parameter(p))
456    }
457
458    fn wrap_to_domain(&self, p: Vec4) -> (Vec4, Iso4) {
459        let mut power = self.wedge_offset(p);
460        let mut deck = self.deck(power);
461        let mut wrapped = self.cover_apply(deck, p);
462
463        let correction = self.wedge_offset(wrapped);
464        if correction != 0 {
465            power += correction;
466            deck = self.deck(power);
467            wrapped = self.cover_apply(deck, p);
468        }
469        (wrapped, deck)
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use approx::assert_relative_eq;
477
478    const CELL: Vec3 = Vec3::new(2.0, 3.0, 1.5);
479
480    fn torus() -> FlatTorus3 {
481        FlatTorus3::new(CELL)
482    }
483
484    pub(super) struct Xorshift(pub(super) u32);
485
486    impl Xorshift {
487        pub(super) fn next_u32(&mut self) -> u32 {
488            self.0 ^= self.0 << 13;
489            self.0 ^= self.0 >> 17;
490            self.0 ^= self.0 << 5;
491            self.0
492        }
493
494        pub(super) fn signed(&mut self, span: f32) -> f32 {
495            let unit = self.next_u32() as f32 / u32::MAX as f32;
496            (unit * 2.0 - 1.0) * span
497        }
498
499        fn point(&mut self, span: f32) -> Vec3 {
500            Vec3::new(self.signed(span), self.signed(span), self.signed(span))
501        }
502    }
503
504    fn boundary_lifts(cell: Vec3) -> Vec<Vec3> {
505        let half = 0.5 * cell;
506        let mut lifts = Vec::new();
507        for axis in 0..3 {
508            for sign in [-1.0_f32, 1.0] {
509                let edge = sign * half[axis];
510                for coordinate in [
511                    edge,
512                    f32::from_bits(edge.to_bits() - 1),
513                    f32::from_bits(edge.to_bits() + 1),
514                ] {
515                    for copy in -3..=3 {
516                        let mut p = Vec3::new(0.25, -0.5, 0.125);
517                        p[axis] = coordinate + copy as f32 * cell[axis];
518                        lifts.push(p);
519                    }
520                }
521            }
522        }
523        lifts
524    }
525
526    #[test]
527    fn wrap_lands_every_lift_in_the_half_open_fundamental_domain() {
528        let t = torus();
529        let mut rng = Xorshift(0x5eed_1234);
530        let mut lifts = boundary_lifts(CELL);
531        for _ in 0..200_000 {
532            lifts.push(rng.point(40.0));
533        }
534        for p in lifts {
535            let (q, _) = t.wrap_to_domain(p);
536            assert!(
537                t.in_fundamental_domain(q),
538                "wrap({p:?}) = {q:?} escaped the domain of cell {CELL:?}"
539            );
540        }
541    }
542
543    #[test]
544    fn fundamental_domain_membership_is_exactly_the_wrap_fixpoint() {
545        let t = torus();
546        let mut rng = Xorshift(0x1337_beef);
547        for _ in 0..50_000 {
548            let p = rng.point(6.0);
549            let (q, _) = t.wrap_to_domain(p);
550            assert_eq!(t.in_fundamental_domain(p), p == q, "at {p:?}");
551        }
552    }
553
554    #[test]
555    fn the_returned_deck_element_reproduces_the_representative_exactly() {
556        let t = torus();
557        let mut rng = Xorshift(0x0bad_f00d);
558        let mut lifts = boundary_lifts(CELL);
559        for _ in 0..50_000 {
560            lifts.push(rng.point(25.0));
561        }
562        for p in lifts {
563            let (q, g) = t.wrap_to_domain(p);
564            assert_eq!(t.cover_apply(g, p), q, "deck element disagreed at {p:?}");
565        }
566    }
567
568    #[test]
569    fn wrapping_an_interior_point_returns_the_identity_deck_element() {
570        let t = torus();
571        let p = Vec3::new(0.3, -1.1, 0.4);
572        let (q, g) = t.wrap_to_domain(p);
573        assert_eq!(q, p);
574        assert_eq!(g, Iso3::IDENTITY);
575    }
576
577    #[test]
578    fn deck_group_composition_is_a_group_action_on_lifts() {
579        let t = torus();
580        let generators: Vec<Iso3> = t
581            .face_pairings()
582            .flat_map(|g| [g, t.cover_inverse(g)])
583            .collect();
584        let mut rng = Xorshift(0xfeed_face);
585
586        for _ in 0..20_000 {
587            let a = word(&t, &generators, &mut rng);
588            let b = word(&t, &generators, &mut rng);
589            let p = rng.point(8.0);
590
591            let sequential = t.cover_apply(a, t.cover_apply(b, p));
592            let composed = t.cover_apply(t.cover_compose(a, b), p);
593            assert_relative_eq!(sequential.x, composed.x, epsilon = 1e-5);
594            assert_relative_eq!(sequential.y, composed.y, epsilon = 1e-5);
595            assert_relative_eq!(sequential.z, composed.z, epsilon = 1e-5);
596
597            assert_eq!(t.cover_apply(t.cover_identity(), p), p);
598
599            let round_trip = t.cover_apply(t.cover_inverse(a), t.cover_apply(a, p));
600            assert_relative_eq!(round_trip.x, p.x, epsilon = 1e-5);
601            assert_relative_eq!(round_trip.y, p.y, epsilon = 1e-5);
602            assert_relative_eq!(round_trip.z, p.z, epsilon = 1e-5);
603        }
604    }
605
606    fn word(t: &FlatTorus3, generators: &[Iso3], rng: &mut Xorshift) -> Iso3 {
607        let length = (rng.next_u32() % 5) as usize;
608        (0..length).fold(t.cover_identity(), |accumulated, _| {
609            let pick = generators[(rng.next_u32() as usize) % generators.len()];
610            t.cover_compose(accumulated, pick)
611        })
612    }
613
614    #[test]
615    fn wrap_is_invariant_under_the_deck_group() {
616        let t = torus();
617        let generators: Vec<Iso3> = t
618            .face_pairings()
619            .flat_map(|g| [g, t.cover_inverse(g)])
620            .collect();
621        let mut rng = Xorshift(0x00c0_ffee);
622        for _ in 0..20_000 {
623            let p = rng.point(5.0);
624            let (q, _) = t.wrap_to_domain(p);
625            let g = word(&t, &generators, &mut rng);
626            let (q_translated, _) = t.wrap_to_domain(t.cover_apply(g, p));
627            assert_relative_eq!(q_translated.x, q.x, epsilon = 1e-4);
628            assert_relative_eq!(q_translated.y, q.y, epsilon = 1e-4);
629            assert_relative_eq!(q_translated.z, q.z, epsilon = 1e-4);
630        }
631    }
632
633    #[test]
634    fn distance_is_the_minimum_over_the_lattice() {
635        let t = torus();
636        let mut rng = Xorshift(0xdead_10cc);
637        for _ in 0..20_000 {
638            let a = rng.point(4.0);
639            let b = rng.point(4.0);
640            let center = ((a - b) / CELL).round();
641            let mut brute = f32::INFINITY;
642            for i in -3..=3 {
643                for j in -3..=3 {
644                    for k in -3..=3 {
645                        let offset = Vec3::new(i as f32, j as f32, k as f32);
646                        brute = brute.min((a - b - CELL * (center + offset)).length());
647                    }
648                }
649            }
650            assert_relative_eq!(t.distance(a, b), brute, epsilon = 1e-5);
651        }
652    }
653
654    #[test]
655    fn distance_is_lattice_invariant_symmetric_and_zero_on_the_orbit() {
656        let t = torus();
657        let generators: Vec<Iso3> = t
658            .face_pairings()
659            .flat_map(|g| [g, t.cover_inverse(g)])
660            .collect();
661        let mut rng = Xorshift(0x9e37_79b9);
662        for _ in 0..20_000 {
663            let a = rng.point(4.0);
664            let b = rng.point(4.0);
665            let g = word(&t, &generators, &mut rng);
666
667            assert_relative_eq!(t.distance(a, b), t.distance(b, a));
668            assert_relative_eq!(
669                t.distance(t.cover_apply(g, a), b),
670                t.distance(a, b),
671                epsilon = 1e-4
672            );
673            assert_relative_eq!(t.distance(a, t.cover_apply(g, a)), 0.0, epsilon = 1e-4);
674        }
675    }
676
677    #[test]
678    fn distance_satisfies_the_triangle_inequality() {
679        let t = torus();
680        let mut rng = Xorshift(0x7f4a_7c15);
681        for _ in 0..50_000 {
682            let a = rng.point(5.0);
683            let b = rng.point(5.0);
684            let c = rng.point(5.0);
685            assert!(t.distance(a, c) <= t.distance(a, b) + t.distance(b, c) + 1e-4);
686        }
687    }
688
689    #[test]
690    fn a_geodesic_crossing_the_gluing_lands_where_the_face_pairing_says() {
691        let t = torus();
692        let half = 0.5 * CELL;
693        let pairings: Vec<Iso3> = t.face_pairings().collect();
694
695        for (axis, pairing) in pairings.iter().enumerate() {
696            let mut start = Vec3::new(0.1, 0.9, -0.3);
697            start[axis] = half[axis] - 0.05;
698            let mut step = Vec3::ZERO;
699            step[axis] = 0.2;
700
701            let uncorrected = start + step;
702            let (arrived, deck) = t.wrap_to_domain(uncorrected);
703
704            let expected = t.cover_inverse(*pairing);
705            assert_eq!(deck.translation, expected.translation, "axis {axis}");
706            assert_eq!(t.cover_apply(expected, uncorrected), arrived);
707            assert_eq!(t.exp(start, step), arrived);
708        }
709    }
710
711    #[test]
712    fn stepped_ray_continuation_matches_a_single_geodesic_exp() {
713        let t = torus();
714        let mut rng = Xorshift(0x4d59_5f21);
715        for _ in 0..5_000 {
716            let start = t.wrap(rng.point(1.5));
717            let velocity = rng.point(4.0);
718
719            let direct = t.exp(start, velocity);
720
721            const STEPS: usize = 64;
722            let mut marched = start;
723            for _ in 0..STEPS {
724                marched = t.exp(marched, velocity / STEPS as f32);
725            }
726
727            let residual = t.distance(direct, marched);
728            assert!(
729                residual < 64.0 * f32::EPSILON * CELL.max_element() * 8.0,
730                "stepped {marched:?} vs direct {direct:?}, residual {residual}"
731            );
732        }
733    }
734
735    #[test]
736    fn exp_and_log_invert_exactly_within_the_injectivity_radius() {
737        let t = torus();
738        let radius = t.injectivity_radius();
739        assert_relative_eq!(radius, 0.75);
740
741        let mut rng = Xorshift(0x6c07_8965);
742        for _ in 0..20_000 {
743            let from = t.wrap(rng.point(2.0));
744            let direction = rng.point(1.0).normalize_or_zero();
745            let v = direction * (0.95 * radius);
746            let to = t.exp(from, v);
747            let recovered = t.log(from, to);
748            assert_relative_eq!(recovered.x, v.x, epsilon = 1e-5);
749            assert_relative_eq!(recovered.y, v.y, epsilon = 1e-5);
750            assert_relative_eq!(recovered.z, v.z, epsilon = 1e-5);
751        }
752
753        let short = t.log(Vec3::ZERO, t.exp(Vec3::ZERO, Vec3::new(0.0, 2.0, 0.0)));
754        assert_relative_eq!(short.x, 0.0);
755        assert_relative_eq!(short.y, -1.0);
756        assert_relative_eq!(short.z, 0.0);
757    }
758
759    #[test]
760    fn a_cover_rotation_does_not_claim_quotient_distance_invariance() {
761        let t = FlatTorus3::cube(2.0);
762        let a = Vec3::new(0.9, 0.0, 0.0);
763        let b = Vec3::new(-0.9, 0.0, 0.0);
764        let rotation = Iso3::from_rotation(Quat::from_rotation_z(PI / 4.0));
765        let moved = t.distance(t.cover_apply(rotation, a), t.cover_apply(rotation, b));
766        let deck = t.face_pairings().next().unwrap();
767
768        assert_relative_eq!(t.distance(a, b), 0.2, epsilon = 1e-6);
769        assert!(moved > 1.0);
770        assert_relative_eq!(t.distance(a, t.cover_apply(deck, a)), 0.0, epsilon = 1e-6);
771        assert_relative_eq!(
772            t.distance(t.cover_apply(deck, a), t.cover_apply(deck, b)),
773            t.distance(a, b),
774            epsilon = 1e-6
775        );
776    }
777
778    #[test]
779    #[should_panic(expected = "must be finite and positive")]
780    fn a_degenerate_cell_is_rejected_at_construction() {
781        FlatTorus3::new(Vec3::new(1.0, 0.0, 1.0));
782    }
783
784    #[test]
785    fn wgsl_prelude_exports_the_abi_and_bakes_a_round_tripping_cell() {
786        let cell = Vec3::new(2.0, 3.0, 1.5e-7);
787        let source = FlatTorus3::new(cell).wgsl_impl().into_owned();
788        for symbol in [
789            "fn loam_distance(",
790            "fn loam_origin_distance(",
791            "fn loam_exp(",
792            "fn loam_log(",
793            "fn loam_geodesic_step(",
794            "fn loam_torus_wrap(",
795            "const LOAM_MAX_ARC",
796        ] {
797            assert!(source.contains(symbol), "missing {symbol}");
798        }
799
800        let baked = source
801            .split("const LOAM_TORUS_CELL: vec3<f32> = vec3<f32>(")
802            .nth(1)
803            .and_then(|rest| rest.split(')').next())
804            .expect("cell is baked");
805        let parsed: Vec<f32> = baked
806            .split(", ")
807            .map(|c| c.parse().expect("cell literal parses as f32"))
808            .collect();
809        assert_eq!(parsed, vec![cell.x, cell.y, cell.z], "emitted {baked}");
810    }
811}
812
813#[cfg(test)]
814mod lens_tests {
815    use super::tests::Xorshift;
816    use super::*;
817    use approx::assert_relative_eq;
818
819    const LENSES: &[(u32, u32)] = &[(2, 1), (3, 1), (5, 2), (7, 3), (8, 3)];
820
821    fn unit4(rng: &mut Xorshift) -> Vec4 {
822        let raw = Vec4::new(
823            rng.signed(1.0),
824            rng.signed(1.0),
825            rng.signed(1.0),
826            rng.signed(1.0),
827        );
828        if raw.length_squared() < 1e-6 {
829            Vec4::X
830        } else {
831            raw.normalize()
832        }
833    }
834
835    fn generator_by_hand(p: u32, q: u32) -> Mat4 {
836        let step = TAU / p as f32;
837        let (sin1, cos1) = step.sin_cos();
838        let (sin2, cos2) = (step * q as f32).sin_cos();
839        Mat4::from_cols(
840            Vec4::new(cos1, sin1, 0.0, 0.0),
841            Vec4::new(-sin1, cos1, 0.0, 0.0),
842            Vec4::new(0.0, 0.0, cos2, sin2),
843            Vec4::new(0.0, 0.0, -sin2, cos2),
844        )
845    }
846
847    const WALL_MARGIN: f32 = 1e-4;
848
849    fn wall_lifts(lens: &LensSpace) -> Vec<Vec4> {
850        let mut lifts = Vec::new();
851        for wedge in 0..lens.p() as i32 {
852            let wall = (wedge as f32 + 0.5) * TAU / lens.p() as f32;
853            for angle in [wall - WALL_MARGIN, wall + WALL_MARGIN] {
854                let (sin, cos) = angle.sin_cos();
855                lifts.push(Vec4::new(cos * 0.8, sin * 0.8, 0.5, 0.331_662_5).normalize());
856            }
857        }
858        lifts
859    }
860
861    #[test]
862    fn the_face_pairing_composes_to_the_identity_after_exactly_p_steps() {
863        let mut rng = Xorshift(0x1e45_9ac1);
864        for &(p, q) in LENSES {
865            let lens = LensSpace::new(p, q);
866            let generator = lens.face_pairings().next().expect("one face pairing");
867            let samples: Vec<Vec4> = (0..32).map(|_| unit4(&mut rng)).collect();
868
869            let mut accumulated = lens.cover_identity();
870            for step in 1..=p {
871                accumulated = lens.cover_compose(accumulated, generator);
872                let moved = samples
873                    .iter()
874                    .map(|&x| SphericalS3Embedded.distance(lens.cover_apply(accumulated, x), x))
875                    .fold(0.0f32, f32::max);
876                if step == p {
877                    assert!(
878                        moved < 1e-5,
879                        "L({p}, {q}): g^{step} moved a lift by {moved}, so the loop \
880                         does not close"
881                    );
882                } else {
883                    assert!(
884                        moved > 0.1,
885                        "L({p}, {q}): g^{step} already acts trivially, so the deck \
886                         group has order {step}, not {p}"
887                    );
888                }
889            }
890        }
891    }
892
893    #[test]
894    fn deck_powers_agree_with_repeated_composition_of_the_generator() {
895        let mut rng = Xorshift(0x510c_2b77);
896        for &(p, q) in LENSES {
897            let lens = LensSpace::new(p, q);
898            let by_hand = generator_by_hand(p, q);
899            for power in -3 * p as i32..=3 * p as i32 {
900                let mut composed = Mat4::IDENTITY;
901                for _ in 0..power.rem_euclid(p as i32) {
902                    composed = by_hand * composed;
903                }
904                for _ in 0..4 {
905                    let x = unit4(&mut rng);
906                    let want = (composed * x).normalize();
907                    let got = lens.cover_apply(lens.deck(power), x);
908                    assert!(
909                        SphericalS3Embedded.distance(got, want) < 1e-4,
910                        "L({p}, {q}): deck({power}) disagrees with the composed \
911                         generator at {x:?}"
912                    );
913                }
914            }
915        }
916    }
917
918    #[test]
919    #[should_panic(expected = "gcd(p, q) = 1")]
920    fn an_action_with_fixed_points_is_rejected_at_construction() {
921        LensSpace::new(6, 2);
922    }
923
924    #[test]
925    #[should_panic(expected = "p >= 2")]
926    fn the_trivial_quotient_is_rejected_at_construction() {
927        LensSpace::new(1, 0);
928    }
929
930    #[test]
931    fn wrap_lands_every_lift_in_the_half_open_fundamental_domain() {
932        let mut rng = Xorshift(0x5eed_1234);
933        for &(p, q) in LENSES {
934            let lens = LensSpace::new(p, q);
935            let mut lifts = wall_lifts(&lens);
936            lifts.extend((0..50_000).map(|_| unit4(&mut rng)));
937            for x in lifts {
938                let (wrapped, _) = lens.wrap_to_domain(x);
939                assert!(
940                    lens.in_fundamental_domain(wrapped),
941                    "L({p}, {q}): wrap({x:?}) = {wrapped:?} escaped the domain"
942                );
943            }
944        }
945    }
946
947    #[test]
948    fn fundamental_domain_membership_is_exactly_the_wrap_fixpoint() {
949        let mut rng = Xorshift(0x1337_beef);
950        for &(p, q) in LENSES {
951            let lens = LensSpace::new(p, q);
952            let mut lifts = wall_lifts(&lens);
953            lifts.extend((0..20_000).map(|_| unit4(&mut rng)));
954            for x in lifts {
955                let (wrapped, deck) = lens.wrap_to_domain(x);
956                assert_eq!(
957                    lens.in_fundamental_domain(x),
958                    deck.matrix == Mat4::IDENTITY,
959                    "L({p}, {q}) at {x:?}"
960                );
961                if lens.in_fundamental_domain(x) {
962                    assert!(SphericalS3Embedded.distance(wrapped, x) < 1e-6);
963                }
964            }
965        }
966    }
967
968    #[test]
969    fn a_lift_on_a_wedge_wall_comes_back_on_a_wall() {
970        for &(p, q) in LENSES {
971            let lens = LensSpace::new(p, q);
972            let half = PI / p as f32;
973            for wedge in 0..p as i32 {
974                let wall = (wedge as f32 + 0.5) * TAU / p as f32;
975                for angle in [
976                    wall,
977                    f32::from_bits(wall.to_bits() - 1),
978                    f32::from_bits(wall.to_bits() + 1),
979                ] {
980                    let (sin, cos) = angle.sin_cos();
981                    let x = Vec4::new(cos * 0.8, sin * 0.8, 0.5, 0.331_662_5).normalize();
982                    let (wrapped, deck) = lens.wrap_to_domain(x);
983                    assert_eq!(lens.cover_apply(deck, x), wrapped, "L({p}, {q}) at {angle}");
984                    assert!(
985                        (wrapped.y.atan2(wrapped.x).abs() - half).abs() < 1e-5,
986                        "L({p}, {q}): a wall lift came back at {} rad, not on a wall \
987                         at {half}",
988                        wrapped.y.atan2(wrapped.x)
989                    );
990                }
991            }
992        }
993    }
994
995    #[test]
996    fn the_returned_deck_element_reproduces_the_representative_exactly() {
997        let mut rng = Xorshift(0x0bad_f00d);
998        for &(p, q) in LENSES {
999            let lens = LensSpace::new(p, q);
1000            let mut lifts = wall_lifts(&lens);
1001            lifts.extend((0..20_000).map(|_| unit4(&mut rng)));
1002            for x in lifts {
1003                let (wrapped, deck) = lens.wrap_to_domain(x);
1004                assert_eq!(
1005                    lens.cover_apply(deck, x),
1006                    wrapped,
1007                    "L({p}, {q}): deck element disagreed at {x:?}"
1008                );
1009            }
1010        }
1011    }
1012
1013    #[test]
1014    fn wrap_is_invariant_under_the_deck_group() {
1015        let mut rng = Xorshift(0x00c0_ffee);
1016        for &(p, q) in LENSES {
1017            let lens = LensSpace::new(p, q);
1018            for _ in 0..20_000 {
1019                let x = unit4(&mut rng);
1020                let power = (rng.next_u32() % (4 * p)) as i32 - 2 * p as i32;
1021                let (wrapped, _) = lens.wrap_to_domain(x);
1022                let (translated, _) = lens.wrap_to_domain(lens.cover_apply(lens.deck(power), x));
1023                assert!(
1024                    SphericalS3Embedded.distance(wrapped, translated) < 1e-3,
1025                    "L({p}, {q}): wrap of g^{power} x is a different lift at {x:?}"
1026                );
1027            }
1028        }
1029    }
1030
1031    #[test]
1032    fn crossing_a_face_re_enters_through_the_opposite_face() {
1033        let mut rng = Xorshift(0x4d59_5f21);
1034        for &(p, q) in LENSES {
1035            let lens = LensSpace::new(p, q);
1036            let pairing = lens.face_pairings().next().expect("one face pairing");
1037            let half = PI / p as f32;
1038            let overshoot = 0.01;
1039
1040            let (sin, cos) = (half + overshoot).sin_cos();
1041            let outside = Vec4::new(cos * 0.8, sin * 0.8, 0.5, 0.331_662_5).normalize();
1042            let (wrapped, deck) = lens.wrap_to_domain(outside);
1043
1044            assert!(lens.in_fundamental_domain(wrapped));
1045            assert_relative_eq!(
1046                wrapped.y.atan2(wrapped.x),
1047                -half + overshoot,
1048                epsilon = 1e-5
1049            );
1050
1051            let inverse = lens.cover_inverse(pairing);
1052            for _ in 0..64 {
1053                let x = unit4(&mut rng);
1054                assert!(
1055                    SphericalS3Embedded
1056                        .distance(lens.cover_apply(inverse, x), lens.cover_apply(deck, x))
1057                        < 1e-5,
1058                    "L({p}, {q}): the crossing's deck element is not the face pairing"
1059                );
1060            }
1061        }
1062    }
1063
1064    #[test]
1065    fn distance_is_the_minimum_over_the_deck_group() {
1066        let mut rng = Xorshift(0xdead_10cc);
1067        for &(p, q) in LENSES {
1068            let lens = LensSpace::new(p, q);
1069            let generator = generator_by_hand(p, q);
1070            for _ in 0..5_000 {
1071                let a = unit4(&mut rng);
1072                let b = unit4(&mut rng);
1073                let mut brute = f32::INFINITY;
1074                let mut lift = b;
1075                for _ in 0..p {
1076                    brute = brute.min(SphericalS3Embedded.distance(a, lift.normalize()));
1077                    lift = generator * lift;
1078                }
1079                assert_relative_eq!(lens.distance(a, b), brute, epsilon = 1e-4);
1080            }
1081        }
1082    }
1083
1084    #[test]
1085    fn distance_is_deck_invariant_symmetric_and_zero_on_the_orbit() {
1086        let mut rng = Xorshift(0x9e37_79b9);
1087        for &(p, q) in LENSES {
1088            let lens = LensSpace::new(p, q);
1089            for _ in 0..5_000 {
1090                let a = unit4(&mut rng);
1091                let b = unit4(&mut rng);
1092                let power = (rng.next_u32() % (2 * p)) as i32;
1093                let moved = lens.cover_apply(lens.deck(power), a);
1094
1095                assert_relative_eq!(lens.distance(a, b), lens.distance(b, a), epsilon = 1e-5);
1096                assert_relative_eq!(lens.distance(moved, b), lens.distance(a, b), epsilon = 1e-4);
1097                assert!(lens.distance(a, moved) < 1e-3);
1098            }
1099        }
1100    }
1101
1102    #[test]
1103    fn distance_satisfies_the_triangle_inequality() {
1104        let mut rng = Xorshift(0x7f4a_7c15);
1105        for &(p, q) in LENSES {
1106            let lens = LensSpace::new(p, q);
1107            for _ in 0..5_000 {
1108                let a = unit4(&mut rng);
1109                let b = unit4(&mut rng);
1110                let c = unit4(&mut rng);
1111                assert!(lens.distance(a, c) <= lens.distance(a, b) + lens.distance(b, c) + 1e-4);
1112            }
1113        }
1114    }
1115
1116    #[test]
1117    fn the_injectivity_radius_is_half_the_shortest_deck_displacement() {
1118        let mut rng = Xorshift(0x41c6_4e6d);
1119        for &(p, q) in LENSES {
1120            let lens = LensSpace::new(p, q);
1121            let mut shortest = f32::INFINITY;
1122            for _ in 0..20_000 {
1123                let x = unit4(&mut rng);
1124                for power in 1..p as i32 {
1125                    let moved = lens.cover_apply(lens.deck(power), x);
1126                    shortest = shortest.min(SphericalS3Embedded.distance(x, moved));
1127                }
1128            }
1129            assert_relative_eq!(shortest, TAU / p as f32, epsilon = 2e-3);
1130            assert_relative_eq!(lens.injectivity_radius(), 0.5 * shortest, epsilon = 1e-3);
1131        }
1132    }
1133
1134    #[test]
1135    fn exp_and_log_invert_within_the_injectivity_radius() {
1136        let mut rng = Xorshift(0x6c07_8965);
1137        for &(p, q) in LENSES {
1138            let lens = LensSpace::new(p, q);
1139            let radius = lens.injectivity_radius();
1140            for _ in 0..5_000 {
1141                let at = unit4(&mut rng);
1142                let raw = unit4(&mut rng);
1143                let tangent = raw - raw.dot(at) * at;
1144                if tangent.length_squared() < 1e-6 {
1145                    continue;
1146                }
1147                let v = tangent.normalize() * (0.9 * radius);
1148                let recovered = lens.log(at, lens.exp(at, v));
1149                assert!(
1150                    (recovered - v).length() < 1e-4,
1151                    "L({p}, {q}): log(exp(v)) missed v by {}",
1152                    (recovered - v).length()
1153                );
1154            }
1155
1156            let at = Vec4::X;
1157            let v = Vec4::Y * (TAU / p as f32);
1158            assert!(lens.distance(at, lens.exp(at, v)) < 1e-3);
1159            assert!(lens.log(at, lens.exp(at, v)).length() < 1e-3);
1160        }
1161    }
1162
1163    #[test]
1164    fn parallel_transport_lands_tangent_to_the_lift_the_caller_named() {
1165        let mut rng = Xorshift(0x339a_cb15);
1166        for &(p, q) in LENSES {
1167            let lens = LensSpace::new(p, q);
1168            for _ in 0..5_000 {
1169                let from = unit4(&mut rng);
1170                let to = unit4(&mut rng);
1171                let raw = unit4(&mut rng);
1172                let v = raw - raw.dot(from) * from;
1173                let carried = lens.parallel_transport(from, to, v);
1174                assert!(
1175                    carried.dot(to).abs() < 1e-4,
1176                    "L({p}, {q}): transported vector is not tangent at `to`"
1177                );
1178                assert_relative_eq!(carried.length(), v.length(), epsilon = 1e-4);
1179            }
1180        }
1181    }
1182
1183    #[test]
1184    fn the_wgsl_prelude_bakes_the_deck_table_the_rust_impl_computes() {
1185        for &(p, q) in LENSES {
1186            let lens = LensSpace::new(p, q);
1187            let source = lens.wgsl_prelude();
1188            for symbol in [
1189                "fn loam_lens_apply(",
1190                "fn loam_lens_wedge_offset(",
1191                "fn loam_lens_wrap(",
1192                "fn loam_lens_distance(",
1193                "fn loam_lens_nearest_power(",
1194                "const LOAM_LENS_P",
1195                "const LOAM_LENS_WEDGES_PER_TURN",
1196            ] {
1197                assert!(source.contains(symbol), "L({p}, {q}) is missing {symbol}");
1198            }
1199
1200            let table = source
1201                .split("const LOAM_LENS_DECK = array<vec4<f32>, ")
1202                .nth(1)
1203                .and_then(|rest| rest.split(");").next())
1204                .expect("the deck table is baked");
1205            assert_eq!(
1206                table.matches("vec4<f32>(").count(),
1207                p as usize,
1208                "L({p}, {q}) baked {table}"
1209            );
1210
1211            for (power, entry) in table.split("vec4<f32>(").skip(1).enumerate() {
1212                let baked: Vec<f32> = entry
1213                    .split(')')
1214                    .next()
1215                    .expect("a closed entry")
1216                    .split(", ")
1217                    .map(|coefficient| coefficient.parse().expect("an f32 literal"))
1218                    .collect();
1219                let m = lens.deck(power as i32).matrix;
1220                assert_eq!(
1221                    baked,
1222                    vec![m.x_axis.x, m.x_axis.y, m.z_axis.z, m.z_axis.w],
1223                    "L({p}, {q}): baked power {power} is not the Rust matrix"
1224                );
1225            }
1226        }
1227    }
1228}