1use glam::Vec4;
2
3use crate::rasterizable::{Projection, RasterizableSpace};
4use crate::space::{IsometryGroup, Space};
5use crate::spherical::Iso4;
6use crate::EuclideanR4;
7
8const GEODESIC_DIRECTION_MIN: f32 = 1e-7;
9
10const TRANSPORT_DENOM_MIN: f32 = 1e-7;
11
12const UNIT_POINT_TOLERANCE: f32 = 1e-4;
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub struct SphericalS3Embedded;
17
18impl Space for SphericalS3Embedded {
19 type Point = Vec4;
20
21 type Vector = Vec4;
22
23 type Frame = Iso4;
24
25 fn frame_at(&self, at: Vec4) -> Iso4 {
26 Iso4::left_translation(at)
27 }
28
29 fn distance(&self, a: Vec4, b: Vec4) -> f32 {
30 let half_chord = (a - b).length() * 0.5;
31 2.0 * half_chord.clamp(0.0, 1.0).asin()
32 }
33
34 fn exp(&self, at: Vec4, v: Vec4) -> Vec4 {
35 let v_tan = v - v.dot(at) * at;
36 let theta = v_tan.length();
37 if theta < GEODESIC_DIRECTION_MIN {
38 return at;
39 }
40 at * theta.cos() + v_tan * (theta.sin() / theta)
41 }
42
43 fn log(&self, from: Vec4, to: Vec4) -> Vec4 {
44 let d = self.distance(from, to);
45
46 let dot = from.dot(to).clamp(-1.0, 1.0);
47 let perp = to - dot * from;
48 let n = perp.length();
49 if n < GEODESIC_DIRECTION_MIN {
50 return Vec4::ZERO;
51 }
52 perp * (d / n)
53 }
54
55 fn parallel_transport(&self, from: Vec4, to: Vec4, v: Vec4) -> Vec4 {
56 let sum = from + to;
58 let denom = (sum.length_squared() * 0.5).max(TRANSPORT_DENOM_MIN);
59 v - (v.dot(to) / denom) * sum
60 }
61
62 fn chart_envelope(&self) -> f32 {
63 std::f32::consts::PI
64 }
65
66 fn valid_point(&self, p: Vec4) -> bool {
67 p.is_finite() && (p.length_squared() - 1.0).abs() <= UNIT_POINT_TOLERANCE
68 }
69}
70
71impl IsometryGroup for SphericalS3Embedded {
72 type Iso = Iso4;
73
74 fn iso_identity(&self) -> Iso4 {
75 Iso4::IDENTITY
76 }
77
78 fn iso_compose(&self, a: Iso4, b: Iso4) -> Iso4 {
79 Iso4 {
80 matrix: a.matrix * b.matrix,
81 }
82 }
83
84 fn iso_inverse(&self, a: Iso4) -> Iso4 {
85 Iso4 {
86 matrix: a.matrix.transpose(),
87 }
88 }
89
90 fn iso_apply(&self, iso: Iso4, p: Vec4) -> Vec4 {
91 (iso.matrix * p).normalize()
92 }
93
94 fn iso_transport(&self, iso: Iso4, _at: Vec4, v: Vec4) -> Vec4 {
95 iso.matrix * v
96 }
97}
98
99impl RasterizableSpace<4> for SphericalS3Embedded {
100 fn point_to_array(p: Vec4) -> [f32; 4] {
101 p.to_array()
102 }
103
104 fn array_to_point(arr: [f32; 4]) -> Vec4 {
105 Vec4::from_array(arr).normalize()
106 }
107
108 fn project_point(point: Vec4, projection: &Projection<4>) -> Option<glam::Vec3> {
109 match projection {
110 Projection::Stereographic { pole } => {
111 Some(crate::rasterizable::stereographic_to_r3(point, *pole))
112 }
113
114 Projection::Identity
115 | Projection::Orthographic { .. }
116 | Projection::Perspective4D { .. }
117 | Projection::Schlegel { .. } => {
118 <EuclideanR4 as RasterizableSpace<4>>::project_point(point, projection)
119 }
120 }
121 }
122
123 fn tessellate_segment(p0: Vec4, p1: Vec4, samples: usize, mut emit: impl FnMut(Vec4)) {
124 let dot = p0.dot(p1).clamp(-1.0, 1.0);
127 let half_chord = (p0 - p1).length() * 0.5;
128 let omega = 2.0 * half_chord.clamp(0.0, 1.0).asin();
129
130 let perp = p1 - dot * p0;
131 let n = perp.length();
132 let dir = if n > GEODESIC_DIRECTION_MIN {
133 perp / n
134 } else {
135 deterministic_perp(p0)
136 };
137 emit(p0);
138 for i in 1..samples {
139 let ang = i as f32 / samples as f32 * omega;
140 emit(ang.cos() * p0 + ang.sin() * dir);
141 }
142 emit(p1);
143 }
144}
145
146fn deterministic_perp(p0: Vec4) -> Vec4 {
148 let a = p0.abs();
149
150 let mut min_idx = 0usize;
151 let mut min_v = a.x;
152 if a.y < min_v {
153 min_v = a.y;
154 min_idx = 1;
155 }
156 if a.z < min_v {
157 min_v = a.z;
158 min_idx = 2;
159 }
160 if a.w < min_v {
161 min_idx = 3;
162 }
163 let axis = match min_idx {
164 0 => Vec4::X,
165 1 => Vec4::Y,
166 2 => Vec4::Z,
167 _ => Vec4::W,
168 };
169 (axis - axis.dot(p0) * p0).normalize()
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use approx::assert_relative_eq;
176 use std::f32::consts::PI;
177
178 fn s3() -> SphericalS3Embedded {
179 SphericalS3Embedded
180 }
181
182 #[test]
183 fn distance_orthonormal_is_quarter_circle() {
184 let s = s3();
185 assert_relative_eq!(s.distance(Vec4::X, Vec4::Y), PI / 2.0, epsilon = 1e-6);
186 assert_relative_eq!(s.distance(Vec4::X, Vec4::W), PI / 2.0, epsilon = 1e-6);
187 }
188
189 #[test]
190 fn distance_at_antipode_is_pi() {
191 let s = s3();
192 let a = Vec4::new(0.5, 0.5, 0.5, 0.5);
193 assert_relative_eq!(s.distance(a, -a), PI, epsilon = 1e-5);
194 }
195
196 #[test]
197 fn log_magnitude_is_distance_and_is_tangent() {
198 let s = s3();
199 let from = Vec4::new(0.3, 0.2, 0.1, 0.9).normalize();
200 let to = Vec4::new(-0.2, 0.5, 0.0, 0.8).normalize();
201 let v = s.log(from, to);
202 assert_relative_eq!(v.length(), s.distance(from, to), epsilon = 1e-5);
203 assert_relative_eq!(v.dot(from), 0.0, epsilon = 1e-6);
204 }
205
206 #[test]
207 fn exp_stays_on_sphere_with_non_tangent_input() {
208 let s = s3();
209 let at = Vec4::new(0.0, 0.0, 0.0, 1.0);
210 let v = Vec4::new(0.4, 0.2, 0.0, 0.7);
211 let moved = s.exp(at, v);
212 assert_relative_eq!(moved.length(), 1.0, epsilon = 1e-6);
213 }
214
215 #[test]
216 fn parallel_transport_preserves_norm_and_tangency() {
217 let s = s3();
218 let from = Vec4::new(0.0, 0.0, 0.0, 1.0);
219 let to = Vec4::new(0.6, 0.0, 0.0, 0.8);
220 let v = Vec4::new(0.5, 0.0, 0.0, 0.0);
221 let vt = s.parallel_transport(from, to, v);
222 assert_relative_eq!(vt.length(), v.length(), epsilon = 1e-5);
223 assert_relative_eq!(vt.dot(to), 0.0, epsilon = 1e-5);
224 assert!((vt - v).length() > 1e-3, "in-plane vector should rotate");
225 }
226
227 #[test]
228 fn parallel_transport_preserves_norm_near_antipode() {
229 let s = s3();
230 let from = Vec4::X;
231 for delta in [3e-3_f32, 2e-3, 1e-3] {
232 let omega = PI - delta;
233 let to = Vec4::new(omega.cos(), omega.sin(), 0.0, 0.0).normalize();
234
235 let v = Vec4::Y;
236 let vt = s.parallel_transport(from, to, v);
237 assert_relative_eq!(vt.length(), v.length(), epsilon = 1e-3);
238 assert_relative_eq!(vt.dot(to), 0.0, epsilon = 1e-3);
239 }
240 }
241
242 #[test]
243 fn iso_transport_keeps_tangency_and_norm() {
244 let s = s3();
245 let iso = Iso4::from_translation(glam::Vec3::new(0.2, -0.1, 0.15));
246 let at = Vec4::new(0.0, 0.0, 0.0, 1.0);
247 let v = Vec4::new(0.3, 0.2, 0.0, 0.0);
248 let moved_at = s.iso_apply(iso, at);
249 let moved_v = s.iso_transport(iso, at, v);
250 assert_relative_eq!(moved_v.length(), v.length(), epsilon = 1e-5);
251 assert_relative_eq!(moved_v.dot(moved_at), 0.0, epsilon = 1e-5);
252 }
253
254 #[test]
255 fn slerp_endpoints_exact_and_count() {
256 let p0 = Vec4::X;
257 let p1 = Vec4::Y;
258 let mut out = Vec::new();
259 <SphericalS3Embedded as RasterizableSpace<4>>::tessellate_segment(p0, p1, 4, |p| {
260 out.push(p)
261 });
262 assert_eq!(out.len(), 5);
263 assert_relative_eq!(out[0].x, p0.x, epsilon = 1e-6);
264 assert_relative_eq!(out[4].y, p1.y, epsilon = 1e-6);
265 }
266
267 #[test]
268 fn slerp_midpoint_is_on_great_circle_not_chord() {
269 let s = s3();
270 let p0 = Vec4::new(1.0, 0.0, 0.0, 0.0);
271 let p1 = Vec4::new(0.0, 0.0, 0.0, 1.0);
272 let mut out = Vec::new();
273 <SphericalS3Embedded as RasterizableSpace<4>>::tessellate_segment(p0, p1, 2, |p| {
274 out.push(p)
275 });
276 let mid = out[1];
277 assert_relative_eq!(s.distance(mid, p0), s.distance(mid, p1), epsilon = 1e-6);
278
279 let c = (PI / 4.0).cos();
280 assert_relative_eq!(mid.x, c, epsilon = 1e-5);
281 assert_relative_eq!(mid.w, c, epsilon = 1e-5);
282 assert!(mid.x > 0.5, "slerp midpoint must bulge off the chord");
283 }
284
285 #[test]
286 fn slerp_antipode_produces_finite_unit_samples() {
287 let p0 = Vec4::X;
288 let p1 = -Vec4::X;
289 let mut out = Vec::new();
290 <SphericalS3Embedded as RasterizableSpace<4>>::tessellate_segment(p0, p1, 16, |p| {
291 out.push(p)
292 });
293 assert_eq!(out.len(), 17);
294 for p in &out {
295 assert!(
296 p.is_finite(),
297 "antipodal slerp sample must be finite: {p:?}"
298 );
299 assert_relative_eq!(p.length(), 1.0, epsilon = 1e-6);
300 }
301 }
302
303 #[test]
304 fn slerp_near_antipode_samples_stay_on_sphere() {
305 let p0 = Vec4::X;
306
307 for delta in [1e-3_f32, 1e-5, GEODESIC_DIRECTION_MIN * 2.0] {
308 let omega = PI - delta;
309 let p1 = Vec4::new(omega.cos(), omega.sin(), 0.0, 0.0).normalize();
310 let mut out = Vec::new();
311 <SphericalS3Embedded as RasterizableSpace<4>>::tessellate_segment(p0, p1, 16, |p| {
312 out.push(p)
313 });
314 for p in &out[1..out.len() - 1] {
315 assert!(p.is_finite(), "near-antipode sample must be finite: {p:?}");
316 assert!(
317 (p.length() - 1.0).abs() < 1e-4,
318 "near-antipode (omega = PI - {delta:e}) sample off-sphere: |p| = {}",
319 p.length()
320 );
321 }
322 }
323 }
324
325 #[test]
326 fn slerp_consecutive_arc_sum_equals_total() {
327 let s = s3();
328 let p0 = Vec4::new(0.2, 0.1, -0.3, 0.9).normalize();
329 let p1 = Vec4::new(-0.1, 0.4, 0.2, 0.8).normalize();
330 let total = s.distance(p0, p1);
331 for samples in [2usize, 3, 8, 17] {
332 let mut out = Vec::new();
333 <SphericalS3Embedded as RasterizableSpace<4>>::tessellate_segment(
334 p0,
335 p1,
336 samples,
337 |p| out.push(p),
338 );
339 let arc_sum: f32 = out.windows(2).map(|w| s.distance(w[0], w[1])).sum();
340 assert_relative_eq!(arc_sum, total, epsilon = 1e-6);
341 }
342 }
343
344 #[test]
345 fn slerp_near_coincident_falls_back_on_sphere() {
346 let p0 = Vec4::new(0.1, -0.2, 0.3, 0.9).normalize();
347 let nudge = GEODESIC_DIRECTION_MIN * 0.01;
348 let p1 = (p0 + Vec4::new(nudge, 0.0, -nudge, 0.0)).normalize();
349 let mut out = Vec::new();
350 <SphericalS3Embedded as RasterizableSpace<4>>::tessellate_segment(p0, p1, 8, |p| {
351 out.push(p)
352 });
353 for p in &out {
354 assert!(p.is_finite(), "coincident sample must be finite: {p:?}");
355 assert_relative_eq!(p.length(), 1.0, epsilon = 1e-6);
356 assert!(
357 s3().distance(*p, p0) < 1e-4,
358 "coincident-arc sample should stay at p0, dist {}",
359 s3().distance(*p, p0)
360 );
361 }
362 }
363
364 #[test]
365 fn project_point_stereographic_is_conformal_map_not_drop_w() {
366 let p = Vec4::new(0.5, 0.5, 0.5, 0.5);
367 let proj = Projection::Stereographic { pole: Vec4::W };
368 let got = <SphericalS3Embedded as RasterizableSpace<4>>::project_point(p, &proj)
369 .expect("stereographic should accept an off-pole point");
370 let want = glam::Vec3::new(p.x, p.y, p.z) / (1.0 - p.w);
371 assert_relative_eq!(got.x, want.x, epsilon = 1e-6);
372 assert_relative_eq!(got.y, want.y, epsilon = 1e-6);
373 assert_relative_eq!(got.z, want.z, epsilon = 1e-6);
374 let drop_w = glam::Vec3::new(p.x, p.y, p.z);
375 assert!(
376 (got - drop_w).length() > 1e-3,
377 "stereographic must scale by 1/(1-w), not pass through drop-w; got {got:?}"
378 );
379 }
380}