1use glam::{Vec3, Vec4};
2
3use crate::space::Space;
4use crate::{EuclideanR3, EuclideanR4};
5
6const PROJECTION_DENOM_EPSILON: f32 = 1e-4;
7
8pub const STEREOGRAPHIC_POLE_EPSILON: f32 = 1e-4;
10
11fn perp_frame(n: Vec4) -> (Vec4, Vec4, Vec4) {
13 let ax = n.x.abs();
14 let ay = n.y.abs();
15 let az = n.z.abs();
16 let aw = n.w.abs();
17 let drop = ax.max(ay).max(az).max(aw);
18 let mut seeds = [Vec4::X, Vec4::Y, Vec4::Z, Vec4::W];
19 let drop_idx = if drop == ax {
20 0
21 } else if drop == ay {
22 1
23 } else if drop == az {
24 2
25 } else {
26 3
27 };
28 seeds[drop_idx] = Vec4::ZERO;
29
30 let mut basis = [Vec4::ZERO; 3];
31 let mut count = 0usize;
32 for s in seeds {
33 if s == Vec4::ZERO {
34 continue;
35 }
36 let mut v = s - s.dot(n) * n;
37 for b in basis.iter().take(count) {
38 v -= v.dot(*b) * *b;
39 }
40 basis[count] = v.normalize();
41 count += 1;
42 }
43 (basis[0], basis[1], basis[2])
44}
45
46pub(crate) fn stereographic_to_r3(p: Vec4, pole: Vec4) -> Vec3 {
48 let dot = p.dot(pole).clamp(-1.0, 1.0);
49 let denom = (1.0 - dot).max(STEREOGRAPHIC_POLE_EPSILON);
50 if pole == Vec4::W {
51 return Vec3::new(p.x, p.y, p.z) / denom;
52 }
53 let perp = p - dot * pole;
54 let scaled = perp / denom;
55 let (e1, e2, e3) = perp_frame(pole);
56 Vec3::new(scaled.dot(e1), scaled.dot(e2), scaled.dot(e3))
57}
58
59#[derive(Clone, Copy, Debug, Default, PartialEq)]
61pub enum Projection<const N: usize> {
62 #[default]
64 Identity,
65
66 Orthographic { drop_axis: usize },
68
69 Perspective4D { focal_distance: f32 },
71
72 Schlegel {
75 cell_normal: Vec4,
76 cell_offset: f32,
77 viewpoint_distance: f32,
78 basis: [Vec4; 3],
79 },
80
81 Stereographic {
84 pole: Vec4,
86 },
87}
88
89impl Projection<4> {
90 pub fn schlegel(cell_normal: Vec4, cell_offset: f32, viewpoint_distance: f32) -> Projection<4> {
91 let (e1, e2, e3) = perp_frame(cell_normal);
92 Self::schlegel_with_basis(cell_normal, cell_offset, viewpoint_distance, [e1, e2, e3])
93 }
94
95 pub fn schlegel_with_basis(
96 cell_normal: Vec4,
97 cell_offset: f32,
98 viewpoint_distance: f32,
99 basis: [Vec4; 3],
100 ) -> Projection<4> {
101 Projection::Schlegel {
102 cell_normal,
103 cell_offset,
104 viewpoint_distance,
105 basis,
106 }
107 }
108}
109
110pub trait RasterizableSpace<const N: usize>: Space {
111 fn point_to_array(p: Self::Point) -> [f32; N];
112
113 fn array_to_point(arr: [f32; N]) -> Self::Point;
114
115 fn project_point(point: Self::Point, projection: &Projection<N>) -> Option<Vec3>;
116
117 fn tessellate_segment(
119 p0: Self::Point,
120 p1: Self::Point,
121 samples: usize,
122 emit: impl FnMut(Self::Point),
123 );
124}
125
126impl RasterizableSpace<3> for EuclideanR3 {
127 fn point_to_array(p: Vec3) -> [f32; 3] {
128 p.to_array()
129 }
130
131 fn array_to_point(arr: [f32; 3]) -> Vec3 {
132 Vec3::from_array(arr)
133 }
134
135 fn project_point(point: Vec3, projection: &Projection<3>) -> Option<Vec3> {
136 match projection {
137 Projection::Identity => Some(point),
138 Projection::Orthographic { drop_axis } => match *drop_axis {
139 0 => Some(Vec3::new(point.y, point.z, 0.0)),
140 1 => Some(Vec3::new(point.x, point.z, 0.0)),
141 2 => Some(Vec3::new(point.x, point.y, 0.0)),
142 _ => None,
143 },
144 Projection::Perspective4D { .. }
145 | Projection::Schlegel { .. }
146 | Projection::Stereographic { .. } => None,
147 }
148 }
149
150 fn tessellate_segment(p0: Vec3, p1: Vec3, samples: usize, mut emit: impl FnMut(Vec3)) {
151 emit(p0);
152 for i in 1..samples {
153 let t = i as f32 / samples as f32;
154 emit(p0.lerp(p1, t));
155 }
156 emit(p1);
157 }
158}
159
160impl RasterizableSpace<4> for EuclideanR4 {
161 fn point_to_array(p: Vec4) -> [f32; 4] {
162 p.to_array()
163 }
164
165 fn array_to_point(arr: [f32; 4]) -> Vec4 {
166 Vec4::from_array(arr)
167 }
168
169 fn project_point(point: Vec4, projection: &Projection<4>) -> Option<Vec3> {
170 match projection {
171 Projection::Identity => Some(Vec3::new(point.x, point.y, point.z)),
172 Projection::Orthographic { drop_axis } => match *drop_axis {
173 0 => Some(Vec3::new(point.y, point.z, point.w)),
174 1 => Some(Vec3::new(point.x, point.z, point.w)),
175 2 => Some(Vec3::new(point.x, point.y, point.w)),
176 3 => Some(Vec3::new(point.x, point.y, point.z)),
177 _ => None,
178 },
179 Projection::Perspective4D { focal_distance } => {
180 let denom = focal_distance - point.w;
181 if denom < PROJECTION_DENOM_EPSILON {
182 return None;
183 }
184 let scale = focal_distance / denom;
185 Some(Vec3::new(point.x, point.y, point.z) * scale)
186 }
187 Projection::Schlegel {
188 cell_normal,
189 cell_offset,
190 viewpoint_distance,
191 basis,
192 } => {
193 let n = *cell_normal;
195 let eye = *viewpoint_distance * n;
196 let n_dot_eye = n.dot(eye);
197
198 let denom = n.dot(point) - n_dot_eye;
199 if denom.abs() < PROJECTION_DENOM_EPSILON {
200 return None;
201 }
202 let t = (*cell_offset - n_dot_eye) / denom;
203 let result = eye + t * (point - eye);
204 let [e1, e2, e3] = *basis;
205 Some(Vec3::new(result.dot(e1), result.dot(e2), result.dot(e3)))
206 }
207 Projection::Stereographic { pole } => {
208 Some(stereographic_to_r3(point.normalize(), *pole))
209 }
210 }
211 }
212
213 fn tessellate_segment(p0: Vec4, p1: Vec4, samples: usize, mut emit: impl FnMut(Vec4)) {
214 emit(p0);
215 for i in 1..samples {
216 let t = i as f32 / samples as f32;
217 emit(p0.lerp(p1, t));
218 }
219 emit(p1);
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::SphericalS3Embedded;
227 use approx::assert_relative_eq;
228
229 const GOLDEN_TIE_FRAME: Vec3 = Vec3::new(0.5, 0.5, std::f32::consts::FRAC_1_SQRT_2);
230
231 fn project4(p: Vec4, projection: &Projection<4>) -> Vec3 {
232 <EuclideanR4 as RasterizableSpace<4>>::project_point(p, projection)
233 .expect("projection should accept this vertex")
234 }
235
236 #[test]
237 fn stereographic_r4_normalizes_scaled_input() {
238 let proj = Projection::Stereographic { pole: Vec4::W };
239 for p in [
240 Vec4::new(0.3, -0.1, 0.2, -0.5).normalize(),
241 Vec4::new(-0.4, 0.6, 0.1, 0.3).normalize(),
242 Vec4::new(0.0, 0.0, 0.0, -1.0),
243 ] {
244 let unit = project4(p, &proj);
245 for k in [0.25_f32, 1.5, 3.0] {
246 let scaled = project4(k * p, &proj);
247 assert!(
248 scaled.abs_diff_eq(unit, 1e-5),
249 "scale {k}: {scaled:?} vs {unit:?}"
250 );
251 }
252 }
253 }
254
255 #[test]
256 fn r3_tessellate_one_sample_appends_endpoints() {
257 let p0 = Vec3::new(0.0, 0.0, 0.0);
258 let p1 = Vec3::new(2.0, 4.0, -6.0);
259 let mut out = Vec::new();
260 <EuclideanR3 as RasterizableSpace<3>>::tessellate_segment(p0, p1, 1, |p| out.push(p));
261 assert_eq!(out.len(), 2);
262 assert_eq!(out[0], p0);
263 assert_eq!(out[1], p1);
264 }
265
266 #[test]
267 fn r3_orthographic_drops_named_axis() {
268 let p = Vec3::new(1.0, 2.0, 3.0);
269 let pj = |drop_axis| {
270 <EuclideanR3 as RasterizableSpace<3>>::project_point(
271 p,
272 &Projection::Orthographic { drop_axis },
273 )
274 };
275 assert_eq!(pj(0), Some(Vec3::new(2.0, 3.0, 0.0)));
276 assert_eq!(pj(1), Some(Vec3::new(1.0, 3.0, 0.0)));
277 assert_eq!(pj(2), Some(Vec3::new(1.0, 2.0, 0.0)));
278 assert_eq!(pj(3), None);
279 assert_eq!(pj(99), None);
280 }
281
282 #[test]
283 fn r4_identity_drops_w() {
284 let p = Vec4::new(1.0, 2.0, 3.0, 4.0);
285 let projected = project4(p, &Projection::Identity);
286 assert_eq!(projected, Vec3::new(1.0, 2.0, 3.0));
287 }
288
289 #[test]
290 fn r4_orthographic_drops_each_axis() {
291 let p = Vec4::new(1.0, 2.0, 3.0, 4.0);
292 let pj = |drop_axis| {
293 <EuclideanR4 as RasterizableSpace<4>>::project_point(
294 p,
295 &Projection::Orthographic { drop_axis },
296 )
297 };
298 assert_eq!(pj(0), Some(Vec3::new(2.0, 3.0, 4.0)));
299 assert_eq!(pj(1), Some(Vec3::new(1.0, 3.0, 4.0)));
300 assert_eq!(pj(2), Some(Vec3::new(1.0, 2.0, 4.0)));
301 assert_eq!(pj(3), Some(Vec3::new(1.0, 2.0, 3.0)));
302 assert_eq!(pj(4), None);
303 }
304
305 #[test]
306 fn r4_perspective4d_w_zero_is_unchanged() {
307 let p = Vec4::new(1.0, 2.0, 3.0, 0.0);
308 let proj = Projection::Perspective4D {
309 focal_distance: 2.0,
310 };
311 let got = project4(p, &proj);
312 assert_eq!(got, Vec3::new(1.0, 2.0, 3.0));
313 }
314
315 #[test]
316 fn r4_perspective4d_cube_within_cube_scaling() {
317 let focal = 2.0;
318 let proj = Projection::Perspective4D {
319 focal_distance: focal,
320 };
321 let near = Vec4::new(0.5, 0.5, 0.5, 0.5);
322 let far = Vec4::new(0.5, 0.5, 0.5, -0.5);
323 let pn = project4(near, &proj);
324 let pf = project4(far, &proj);
325
326 let r_near = (pn.length() / 0.5_f32.mul_add(3.0_f32.sqrt(), 0.0)).abs();
327 let r_far = (pf.length() / 0.5_f32.mul_add(3.0_f32.sqrt(), 0.0)).abs();
328 assert!((r_near - 4.0 / 3.0).abs() < 1e-5, "near scale {r_near}");
329 assert!((r_far - 4.0 / 5.0).abs() < 1e-5, "far scale {r_far}");
330 assert!(pn.length() > pf.length(), "near={pn:?} far={pf:?}");
331 }
332
333 #[test]
334 fn r4_perspective4d_at_viewer_refuses() {
335 let p = Vec4::new(0.1, 0.2, 0.3, 2.0);
336 let proj = Projection::Perspective4D {
337 focal_distance: 2.0,
338 };
339 let got = <EuclideanR4 as RasterizableSpace<4>>::project_point(p, &proj);
340 assert_eq!(got, None, "expected a refusal at the viewer, got {got:?}");
341 }
342
343 #[test]
344 fn r3_perspective4d_refuses() {
345 let p = Vec3::new(1.0, 2.0, 3.0);
346 let proj = Projection::Perspective4D {
347 focal_distance: 2.0,
348 };
349 let got = <EuclideanR3 as RasterizableSpace<3>>::project_point(p, &proj);
350 assert_eq!(got, None);
351 }
352
353 const TESSERACT_VERTS: [Vec4; 16] = [
354 Vec4::new(0.5, 0.5, 0.5, 0.5),
355 Vec4::new(-0.5, 0.5, 0.5, 0.5),
356 Vec4::new(0.5, -0.5, 0.5, 0.5),
357 Vec4::new(-0.5, -0.5, 0.5, 0.5),
358 Vec4::new(0.5, 0.5, -0.5, 0.5),
359 Vec4::new(-0.5, 0.5, -0.5, 0.5),
360 Vec4::new(0.5, -0.5, -0.5, 0.5),
361 Vec4::new(-0.5, -0.5, -0.5, 0.5),
362 Vec4::new(0.5, 0.5, 0.5, -0.5),
363 Vec4::new(-0.5, 0.5, 0.5, -0.5),
364 Vec4::new(0.5, -0.5, 0.5, -0.5),
365 Vec4::new(-0.5, -0.5, 0.5, -0.5),
366 Vec4::new(0.5, 0.5, -0.5, -0.5),
367 Vec4::new(-0.5, 0.5, -0.5, -0.5),
368 Vec4::new(0.5, -0.5, -0.5, -0.5),
369 Vec4::new(-0.5, -0.5, -0.5, -0.5),
370 ];
371
372 #[test]
373 fn schlegel_chosen_cell_renders_undistorted() {
374 let cell_offset = 0.5;
375 let proj = Projection::schlegel(Vec4::W, cell_offset, 1.5 * cell_offset);
376 let cell: Vec<Vec4> = TESSERACT_VERTS.iter().take(8).copied().collect();
377 let projected: Vec<Vec3> = cell.iter().map(|v| project4(*v, &proj)).collect();
378
379 for i in 0..cell.len() {
380 for j in (i + 1)..cell.len() {
381 let orig = (Vec3::new(cell[i].x, cell[i].y, cell[i].z)
382 - Vec3::new(cell[j].x, cell[j].y, cell[j].z))
383 .length();
384 let got = (projected[i] - projected[j]).length();
385 assert!(
386 (orig - got).abs() < 1e-5,
387 "chosen-cell distance v{i}-v{j} should be {orig}, got {got}"
388 );
389 }
390 }
391 }
392
393 #[test]
394 fn schlegel_non_axis_aligned_cell_is_not_flattened() {
395 let verts = [
396 Vec4::X,
397 -Vec4::X,
398 Vec4::Y,
399 -Vec4::Y,
400 Vec4::Z,
401 -Vec4::Z,
402 Vec4::W,
403 -Vec4::W,
404 ];
405
406 let centroid = (Vec4::X + Vec4::Y + Vec4::Z + Vec4::W) / 4.0;
407 let cell_offset = centroid.length();
408 let cell_normal = centroid / cell_offset;
409 let proj = Projection::schlegel(cell_normal, cell_offset, 1.5 * cell_offset);
410
411 let boundary = [Vec4::X, Vec4::Y, Vec4::Z, Vec4::W];
412 let inner = [-Vec4::X, -Vec4::Y, -Vec4::Z, -Vec4::W];
413 let proj_pt = |v: Vec4| project4(v, &proj);
414
415 let boundary_r: Vec<f32> = boundary.iter().map(|&v| proj_pt(v).length()).collect();
416 let inner_r: Vec<f32> = inner.iter().map(|&v| proj_pt(v).length()).collect();
417 let r0 = boundary_r[0];
418 assert!(r0 > 1e-3, "boundary must not collapse to the origin");
419 for r in &boundary_r {
420 assert!(
421 (r - r0).abs() < 1e-5,
422 "boundary tetrahedron must be regular, radii {boundary_r:?}"
423 );
424 }
425 for r in &inner_r {
426 assert!(
427 *r < r0 - 1e-3,
428 "every nested vertex must sit inside the boundary, inner {inner_r:?} vs {r0}"
429 );
430 }
431
432 let proj_boundary: Vec<Vec3> = boundary.iter().map(|&v| proj_pt(v)).collect();
433 let edge_len = 2.0_f32.sqrt();
434 for i in 0..proj_boundary.len() {
435 for j in (i + 1)..proj_boundary.len() {
436 let got = (proj_boundary[i] - proj_boundary[j]).length();
437 assert!(
438 (got - edge_len).abs() < 1e-5,
439 "oblique boundary edge {i}-{j} should stay {edge_len}, got {got}"
440 );
441 }
442 }
443
444 let all: Vec<Vec3> = verts.iter().map(|&v| proj_pt(v)).collect();
445 for axis in 0..3 {
446 let comp = |p: Vec3| [p.x, p.y, p.z][axis];
447 let spread = all
448 .iter()
449 .map(|&p| comp(p))
450 .fold(f32::NEG_INFINITY, f32::max)
451 - all.iter().map(|&p| comp(p)).fold(f32::INFINITY, f32::min);
452 assert!(
453 spread > 0.5,
454 "axis {axis} should have real spread, got {spread}"
455 );
456 }
457 }
458
459 #[test]
460 fn schlegel_uses_supplied_basis_for_readout() {
461 let p = Vec4::new(0.5, -0.25, 0.125, 0.5);
462 let xyz = Projection::schlegel_with_basis(Vec4::W, 0.5, 0.75, [Vec4::X, Vec4::Y, Vec4::Z]);
463 let yxz = Projection::schlegel_with_basis(Vec4::W, 0.5, 0.75, [Vec4::Y, Vec4::X, Vec4::Z]);
464
465 let a = project4(p, &xyz);
466 let b = project4(p, &yxz);
467
468 assert_eq!(a, Vec3::new(0.5, -0.25, 0.125));
469 assert_eq!(b, Vec3::new(-0.25, 0.5, 0.125));
470 }
471
472 #[test]
473 fn schlegel_zero_denominator_refuses() {
474 let viewpoint_distance = 0.75;
475 let proj = Projection::schlegel(Vec4::W, 0.5, viewpoint_distance);
476
477 let p = Vec4::new(0.3, -0.2, 0.1, viewpoint_distance);
478 let got = <EuclideanR4 as RasterizableSpace<4>>::project_point(p, &proj);
479 assert_eq!(
480 got, None,
481 "a degenerate denominator should refuse, got {got:?}"
482 );
483 }
484
485 fn stereo_inverse_w_pole(q: Vec3) -> Vec4 {
487 let s = q.length_squared();
488 let w = (s - 1.0) / (s + 1.0);
489 let xyz = q * (1.0 - w);
490 Vec4::new(xyz.x, xyz.y, xyz.z, w)
491 }
492
493 fn stereo_inverse_general(q: Vec3, pole: Vec4) -> Vec4 {
494 let (e1, e2, e3) = perp_frame(pole);
495 let perp = q.x * e1 + q.y * e2 + q.z * e3;
496
497 let s = perp.length_squared();
498 let dot = (s - 1.0) / (s + 1.0);
499 dot * pole + (1.0 - dot) * perp
500 }
501
502 #[test]
503 fn stereographic_default_pole_is_drop_w_of_scaled() {
504 for p in [
505 Vec4::new(0.5, 0.5, 0.5, 0.5),
506 Vec4::new(-0.5, 0.5, 0.5, -0.5),
507 Vec4::new(-0.6, 0.0, 0.8, 0.0),
508 ] {
509 let got = stereographic_to_r3(p, Vec4::W);
510 let want = Vec3::new(p.x, p.y, p.z) / (1.0 - p.w);
511 assert_eq!(
512 got, want,
513 "fast path must match canonical formula for {p:?}"
514 );
515 }
516 }
517
518 #[test]
519 fn stereographic_inverts_off_pole() {
520 let proj_w = Projection::Stereographic { pole: Vec4::W };
521 for p in [
522 Vec4::new(0.2, 0.1, -0.3, 0.4).normalize(),
523 Vec4::new(-0.5, 0.5, 0.5, -0.5),
524 Vec4::new(0.7, -0.2, 0.1, 0.1).normalize(),
525 ] {
526 let img = project4(p, &proj_w);
527 let back = stereo_inverse_w_pole(img);
528 assert_relative_eq!(back.x, p.x, epsilon = 1e-5);
529 assert_relative_eq!(back.y, p.y, epsilon = 1e-5);
530 assert_relative_eq!(back.z, p.z, epsilon = 1e-5);
531 assert_relative_eq!(back.w, p.w, epsilon = 1e-5);
532 }
533 let pole = Vec4::new(0.1, -0.2, 0.3, 0.9).normalize();
534 let proj_n = Projection::Stereographic { pole };
535 for p in [
536 Vec4::new(0.6, 0.5, -0.2, 0.0).normalize(),
537 Vec4::new(-0.3, 0.4, 0.5, -0.2).normalize(),
538 ] {
539 let img = project4(p, &proj_n);
540 let back = stereo_inverse_general(img, pole);
541 assert_relative_eq!(back.x, p.x, epsilon = 1e-5);
542 assert_relative_eq!(back.y, p.y, epsilon = 1e-5);
543 assert_relative_eq!(back.z, p.z, epsilon = 1e-5);
544 assert_relative_eq!(back.w, p.w, epsilon = 1e-5);
545 }
546 }
547
548 #[test]
549 fn stereographic_pole_denominator_clamped_finite() {
550 for pole in [Vec4::W, Vec4::new(0.5, 0.5, 0.5, 0.5)] {
551 let proj = Projection::Stereographic { pole };
552 let at_pole = project4(pole, &proj);
553 for c in [at_pole.x, at_pole.y, at_pole.z] {
554 assert!(
555 c.is_finite(),
556 "pole input must clamp finite, got {at_pole:?}"
557 );
558 }
559
560 let (e1, _, _) = perp_frame(pole);
561 let near = (pole * 0.9999 + e1 * 0.01).normalize();
562 let near_img = project4(near, &proj);
563 for c in [near_img.x, near_img.y, near_img.z] {
564 assert!(
565 c.is_finite(),
566 "near-pole input must stay finite, got {near_img:?}"
567 );
568 }
569 }
570 }
571
572 #[test]
573 fn stereographic_antipode_maps_to_origin() {
574 for pole in [Vec4::W, Vec4::new(0.1, -0.2, 0.3, 0.9).normalize()] {
575 let proj = Projection::Stereographic { pole };
576 let got = project4(-pole, &proj);
577 assert_relative_eq!(got.x, 0.0, epsilon = 1e-6);
578 assert_relative_eq!(got.y, 0.0, epsilon = 1e-6);
579 assert_relative_eq!(got.z, 0.0, epsilon = 1e-6);
580 }
581 }
582
583 #[test]
584 fn stereographic_frame_breaks_axis_ties_in_index_order() {
585 let pole = Vec4::new(0.0, 0.0, 1.0, 1.0).normalize();
586 let proj = Projection::Stereographic { pole };
587 let p = Vec4::new(0.5, 0.5, -0.5, 0.5);
588 let first = project4(p, &proj);
589
590 assert_relative_eq!(first.x, GOLDEN_TIE_FRAME.x, epsilon = 1e-6);
591 assert_relative_eq!(first.y, GOLDEN_TIE_FRAME.y, epsilon = 1e-6);
592 assert_relative_eq!(first.z, GOLDEN_TIE_FRAME.z, epsilon = 1e-6);
593 }
594
595 #[test]
596 fn stereographic_frame_orthonormal_for_every_pole() {
597 let mut poles = vec![Vec4::X, Vec4::Y, Vec4::Z, Vec4::W];
598 for axis in [Vec4::X, Vec4::Y, Vec4::Z, Vec4::W] {
599 for k in 1..6 {
600 let t = k as f32 / 6.0;
601 poles.push((Vec4::splat(0.5) * (1.0 - t) + axis * t).normalize());
602 }
603 }
604 for pole in poles {
605 let (e1, e2, e3) = perp_frame(pole);
606 for e in [e1, e2, e3] {
607 assert!(e.is_finite(), "frame must be finite for pole {pole:?}");
608 }
609 assert_relative_eq!(e1.dot(e1), 1.0, epsilon = 1e-5);
610 assert_relative_eq!(e2.dot(e2), 1.0, epsilon = 1e-5);
611 assert_relative_eq!(e3.dot(e3), 1.0, epsilon = 1e-5);
612 assert!(e1.dot(e2).abs() < 1e-5, "e1·e2 for pole {pole:?}");
613 assert!(e1.dot(e3).abs() < 1e-5, "e1·e3 for pole {pole:?}");
614 assert!(e2.dot(e3).abs() < 1e-5, "e2·e3 for pole {pole:?}");
615 assert!(e1.dot(pole).abs() < 1e-5, "e1 ⟂ pole {pole:?}");
616 assert!(e2.dot(pole).abs() < 1e-5, "e2 ⟂ pole {pole:?}");
617 assert!(e3.dot(pole).abs() < 1e-5, "e3 ⟂ pole {pole:?}");
618 }
619 }
620
621 #[test]
622 fn stereographic_is_conformal() {
623 let s = SphericalS3Embedded;
624 let pole = Vec4::W;
625 let proj = Projection::Stereographic { pole };
626 let v = Vec4::new(0.3, -0.1, 0.2, -0.5).normalize();
627 let a = Vec4::new(0.5, 0.4, -0.1, -0.3).normalize();
628 let b = Vec4::new(-0.2, 0.3, 0.6, -0.4).normalize();
629
630 let ta = s.log(v, a);
631 let tb = s.log(v, b);
632 let intrinsic = (ta.dot(tb) / (ta.length() * tb.length()))
633 .clamp(-1.0, 1.0)
634 .acos();
635
636 let step = 1e-3;
637 let pv = project4(v, &proj);
638 let pa = project4(s.exp(v, ta.normalize() * step), &proj);
639 let pb = project4(s.exp(v, tb.normalize() * step), &proj);
640 let da = pa - pv;
641 let db = pb - pv;
642 let projected = (da.dot(db) / (da.length() * db.length()))
643 .clamp(-1.0, 1.0)
644 .acos();
645 assert_relative_eq!(projected, intrinsic, epsilon = 1e-2);
646 }
647
648 #[test]
649 fn r4_tessellate_lerps_all_components() {
650 let p0 = Vec4::new(0.0, 0.0, 0.0, 0.0);
651 let p1 = Vec4::new(4.0, 8.0, 12.0, 16.0);
652 let mut out = Vec::new();
653 <EuclideanR4 as RasterizableSpace<4>>::tessellate_segment(p0, p1, 2, |p| out.push(p));
654 assert_eq!(out.len(), 3);
655 assert_eq!(out[0], p0);
656 assert_eq!(out[1], Vec4::new(2.0, 4.0, 6.0, 8.0));
657 assert_eq!(out[2], p1);
658 }
659}