use std::f32::consts::PI;
use glam::Vec3;
use indicatrix_formats::asc::{AscSchedule, AscTier};
use crate::geometry::{cuts::StandardGemCuts, plane::GpuFacetPlane};
fn assert_within_one_ulp(got: f32, want: f32, what: &str) {
let distance = got.to_bits().abs_diff(want.to_bits());
assert!(
distance <= 1,
"{what}: got {got} ({:#010x}), expected {want} ({:#010x}), {distance} ULP apart",
got.to_bits(),
want.to_bits()
);
}
#[test]
fn index_to_azimuth_zero_reference_angle_gives_exact_turn_fractions() {
assert_eq!(StandardGemCuts::index_to_azimuth(0.0, 96.0, 0.0), 0.0);
assert_within_one_ulp(
StandardGemCuts::index_to_azimuth(24.0, 96.0, 0.0),
std::f32::consts::FRAC_PI_2,
"24/96 turn",
);
assert_within_one_ulp(
StandardGemCuts::index_to_azimuth(48.0, 96.0, 0.0),
PI,
"48/96 turn",
);
assert_within_one_ulp(
StandardGemCuts::index_to_azimuth(1.0, 8.0, 0.0),
std::f32::consts::FRAC_PI_4,
"1/8 turn",
);
}
#[test]
fn index_to_azimuth_nonzero_reference_angle_offsets_the_turn_fraction() {
assert_within_one_ulp(
StandardGemCuts::index_to_azimuth(12.0, 96.0, 6.0),
1.178_097_2,
"(12 + 6)/96 turn",
);
}
#[test]
fn index_to_azimuth_full_turn_is_two_pi() {
let full = StandardGemCuts::index_to_azimuth(96.0, 96.0, 0.0);
assert_within_one_ulp(full, 2.0 * PI, "96/96 turn");
assert!(
(full.cos() - 1.0).abs() < 1e-6,
"cos of a full turn: {}",
full.cos()
);
}
#[test]
fn index_to_azimuth_reference_angle_is_additive_on_the_index() {
let gear_teeth = 96.0f32;
for &(index, reference_angle) in &[(0.0, 48.0), (5.0, 20.0), (84.0, -6.0)] {
let via_reference_angle =
StandardGemCuts::index_to_azimuth(index, gear_teeth, reference_angle);
let via_shifted_index =
StandardGemCuts::index_to_azimuth(index + reference_angle, gear_teeth, 0.0);
assert!(
(via_reference_angle - via_shifted_index).abs() < 1e-6,
"index {index}, reference angle {reference_angle}"
);
}
}
#[test]
fn from_asc_schedule_zero_reference_angle_reproduces_the_previous_mapping_bit_for_bit() {
let gear_teeth = 96.0f32;
let mast = 0.6f64;
let indices = vec![3.0, 17.0, 55.0, 90.0];
let schedule = AscSchedule {
gear_teeth: 96,
gear_reference_angle: 0.0,
symmetry_order: 1,
refractive_index: 1.0,
tiers: vec![AscTier {
angle_deg: -41.0,
mast,
indices: indices.clone(),
..Default::default()
}],
..Default::default()
};
let planes = StandardGemCuts::from_asc_schedule(&schedule);
assert_eq!(planes.len(), indices.len());
let theta = 41.0f32.to_radians();
#[expect(
clippy::cast_possible_truncation,
reason = "GpuFacetPlane stores f32; the mast is a small distance"
)]
let d = -(mast.abs() as f32);
for (&idx, plane) in indices.iter().zip(&planes) {
#[expect(
clippy::cast_possible_truncation,
reason = "the pre-fix formula worked in f32; an index is a small whole number"
)]
let phi = 2.0 * PI * (idx as f32) / gear_teeth; let expected = GpuFacetPlane::new(
Vec3::new(
theta.sin() * phi.cos(),
-theta.cos(),
theta.sin() * phi.sin(),
),
d,
);
assert_eq!(*plane, expected, "index {idx}");
}
}
#[test]
fn from_asc_schedule_asymmetric_indices_map_to_expected_azimuths_with_reference_angle() {
let gear_teeth = 96.0f32;
let reference_angle = 48.0f64; let mast = 0.6f64;
let indices = vec![5.0, 20.0, 47.0];
let schedule = AscSchedule {
gear_teeth: 96,
gear_reference_angle: reference_angle,
symmetry_order: 1,
refractive_index: 1.0,
tiers: vec![AscTier {
angle_deg: 40.0,
mast,
indices: indices.clone(),
..Default::default()
}],
..Default::default()
};
let planes = StandardGemCuts::from_asc_schedule(&schedule);
assert_eq!(planes.len(), indices.len());
let theta = 40.0f32.to_radians();
for (&idx, plane) in indices.iter().zip(&planes) {
#[expect(
clippy::cast_possible_truncation,
reason = "the planes are f32; an index and a reference angle are small numbers"
)]
let phi = 2.0 * PI * (idx as f32 + reference_angle as f32) / gear_teeth;
let expected = [
theta.sin() * phi.cos(),
theta.cos(),
theta.sin() * phi.sin(),
];
for (component, &value) in plane.normal.iter().zip(expected.iter()) {
assert!(
(component - value).abs() < 1e-5,
"index {idx}: got {component}, expected {value}"
);
}
}
}
#[test]
fn from_asc_schedule_symmetric_design_is_globally_rotated_by_reference_angle() {
let gear_teeth = 96.0f32;
let mast = 0.59f64;
let indices = vec![0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0];
let reference_angle = 6.0f64;
let base = AscSchedule {
gear_teeth: 96,
gear_reference_angle: 0.0,
symmetry_order: 8,
refractive_index: 1.0,
tiers: vec![AscTier {
angle_deg: 34.5,
mast,
indices: indices.clone(),
..Default::default()
}],
..Default::default()
};
let mut rotated = base.clone();
rotated.gear_reference_angle = reference_angle;
let planes_base = StandardGemCuts::from_asc_schedule(&base);
let planes_rotated = StandardGemCuts::from_asc_schedule(&rotated);
assert_eq!(planes_base.len(), planes_rotated.len());
assert_eq!(planes_base.len(), indices.len());
#[expect(
clippy::cast_possible_truncation,
reason = "the planes are f32; the reference angle is a small number of teeth"
)]
let delta = 2.0 * PI * reference_angle as f32 / gear_teeth;
let (sin_delta, cos_delta) = (delta.sin(), delta.cos());
for base_plane in &planes_base {
let n = base_plane.normal;
let rotated_normal = [
n[0].mul_add(cos_delta, -(n[2] * sin_delta)),
n[1],
n[0].mul_add(sin_delta, n[2] * cos_delta),
];
let matches_one = planes_rotated.iter().any(|p| {
(p.normal[0] - rotated_normal[0]).abs() < 1e-5
&& (p.normal[1] - rotated_normal[1]).abs() < 1e-5
&& (p.normal[2] - rotated_normal[2]).abs() < 1e-5
&& (p.d - base_plane.d).abs() < 1e-5
});
assert!(
matches_one,
"base normal {n:?} rotated by {delta} rad must reappear in the \
reference-angle-shifted schedule's plane set"
);
}
}