#[must_use]
pub fn canon_angle_deg(deg: f32) -> f32 {
let r = deg % 360.0;
if r > 180.0 {
r - 360.0
} else if r <= -180.0 {
r + 360.0
} else {
r
}
}
#[must_use]
pub fn quat_from_euler_xyz(x: f32, y: f32, z: f32) -> glam::Quat {
let ti = x * 0.5;
let tj = (-y) * 0.5; let th = z * 0.5;
let (si, ci) = alice_det_math::sin_cos(ti);
let (sj, cj) = alice_det_math::sin_cos(tj);
let (sh, ch) = alice_det_math::sin_cos(th);
let cc = ci * ch;
let cs = ci * sh;
let sc = si * ch;
let ss = si * sh;
glam::Quat::from_xyzw(
cj.mul_add(sc, -(sj * cs)),
(-cj).mul_add(ss, -(sj * cc)),
cj.mul_add(cs, -(sj * sc)),
cj.mul_add(cc, sj * ss),
)
}
#[must_use]
pub fn quat_from_rotation_x(angle: f32) -> glam::Quat {
let (s, c) = alice_det_math::sin_cos(angle * 0.5);
glam::Quat::from_xyzw(s, 0.0, 0.0, c)
}
#[must_use]
pub fn quat_from_rotation_y(angle: f32) -> glam::Quat {
let (s, c) = alice_det_math::sin_cos(angle * 0.5);
glam::Quat::from_xyzw(0.0, s, 0.0, c)
}
#[must_use]
pub fn quat_from_rotation_z(angle: f32) -> glam::Quat {
let (s, c) = alice_det_math::sin_cos(angle * 0.5);
glam::Quat::from_xyzw(0.0, 0.0, s, c)
}
pub(crate) const CANON_ANGLE_DEG_PROBE_VALUES: &[f32] = &[
10.0,
-15.0,
-0.1,
0.0,
180.0,
-180.0,
1e9,
-1e9,
f32::MAX,
f32::MIN_POSITIVE / 2.0,
-(f32::MIN_POSITIVE / 2.0),
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
];
pub(crate) type EulerXyzProbeRow = ((f32, f32, f32), (u32, u32, u32, u32));
pub(crate) const EULER_XYZ_PROBE_TABLE: &[EulerXyzProbeRow] = &[
(
(0.0, 0.0, 0.0),
(0x0000_0000, 0x0000_0000, 0x0000_0000, 0x3f80_0000),
),
(
(std::f32::consts::FRAC_PI_2, 0.0, 0.0),
(0x3f35_04f4, 0x0000_0000, 0x0000_0000, 0x3f35_04f3),
),
(
(0.0, std::f32::consts::FRAC_PI_4, 0.0),
(0x0000_0000, 0x3ec3_ef16, 0x0000_0000, 0x3f6c_835e),
),
(
(0.0, 0.0, std::f32::consts::FRAC_PI_6),
(0x0000_0000, 0x0000_0000, 0x3e84_83ee, 0x3f77_46ea),
),
(
(0.174_532_92, 0.349_065_84, std::f32::consts::FRAC_PI_6),
(0x3e02_be66, 0x3e14_5aef, 0x3e89_7d87, 0x3f71_9743),
),
(
(
-std::f32::consts::FRAC_PI_4,
std::f32::consts::FRAC_PI_3,
-2.094_395_2,
),
(0xbf10_d58a, 0xbd65_8d0f, 0xbf49_e06a, 0x3e6f_f811),
),
(
(std::f32::consts::PI, 0.0, 0.0),
(0x3f80_0000, 0x8000_0000, 0x0000_0000, 0xb33b_bd2e),
),
(
(1.0, -2.0, 3.5),
(0xbf45_d6a5, 0xbdfc_6f29, 0x3f09_d982, 0x3e9f_f8d0),
),
];
pub(crate) type RotationAxisProbeRow = (
f32,
(u32, u32, u32, u32),
(u32, u32, u32, u32),
(u32, u32, u32, u32),
);
pub(crate) const ROTATION_AXIS_PROBE_TABLE: &[RotationAxisProbeRow] = &[
(
0.0,
(0x0000_0000, 0x0000_0000, 0x0000_0000, 0x3f80_0000),
(0x0000_0000, 0x0000_0000, 0x0000_0000, 0x3f80_0000),
(0x0000_0000, 0x0000_0000, 0x0000_0000, 0x3f80_0000),
),
(
std::f32::consts::FRAC_PI_2,
(0x3f35_04f4, 0x0000_0000, 0x0000_0000, 0x3f35_04f3),
(0x0000_0000, 0x3f35_04f4, 0x0000_0000, 0x3f35_04f3),
(0x0000_0000, 0x0000_0000, 0x3f35_04f4, 0x3f35_04f3),
),
(
-std::f32::consts::FRAC_PI_2,
(0xbf35_04f4, 0x0000_0000, 0x0000_0000, 0x3f35_04f3),
(0x0000_0000, 0xbf35_04f4, 0x0000_0000, 0x3f35_04f3),
(0x0000_0000, 0x0000_0000, 0xbf35_04f4, 0x3f35_04f3),
),
(
std::f32::consts::PI,
(0x3f80_0000, 0x0000_0000, 0x0000_0000, 0xb33b_bd2e),
(0x0000_0000, 0x3f80_0000, 0x0000_0000, 0xb33b_bd2e),
(0x0000_0000, 0x0000_0000, 0x3f80_0000, 0xb33b_bd2e),
),
(
-2.670_354, (0xbf78_ed3d, 0x0000_0000, 0x0000_0000, 0x3e6f_0c49),
(0x0000_0000, 0xbf78_ed3d, 0x0000_0000, 0x3e6f_0c49),
(0x0000_0000, 0x0000_0000, 0xbf78_ed3d, 0x3e6f_0c49),
),
];
#[cfg(test)]
mod tests {
use super::canon_angle_deg;
#[test]
#[allow(clippy::float_cmp)] fn already_canonical_positive_is_unchanged() {
assert_eq!(canon_angle_deg(10.0), 10.0);
}
#[test]
#[allow(clippy::float_cmp)] fn already_canonical_negative_is_unchanged() {
assert_eq!(canon_angle_deg(-15.0), -15.0);
}
#[test]
#[allow(clippy::float_cmp)] fn already_canonical_negative_non_integer_is_bit_identical() {
let x = -0.1_f32;
assert_eq!(canon_angle_deg(x).to_bits(), x.to_bits());
}
#[test]
#[allow(clippy::float_cmp)] fn zero_is_unchanged() {
assert_eq!(canon_angle_deg(0.0), 0.0);
}
#[test]
#[allow(clippy::float_cmp)] fn exactly_180_stays_180() {
assert_eq!(canon_angle_deg(180.0), 180.0);
}
#[test]
#[allow(clippy::float_cmp)] fn exactly_negative_180_wraps_to_positive_180() {
assert_eq!(canon_angle_deg(-180.0), 180.0);
}
#[test]
#[allow(clippy::float_cmp)] fn one_billion_degrees_reduces_to_the_true_remainder() {
assert_eq!(canon_angle_deg(1e9), 280.0 - 360.0);
}
#[test]
#[allow(clippy::float_cmp)] fn negative_one_billion_degrees_reduces_to_the_true_remainder() {
assert_eq!(canon_angle_deg(-1e9), -280.0 + 360.0);
}
#[test]
fn astronomically_huge_finite_value_still_reduces_exactly() {
let r = canon_angle_deg(1e30);
assert!(r.is_finite());
assert!((-180.0..=180.0).contains(&r));
}
#[test]
fn f32_max_still_reduces_exactly() {
let r = canon_angle_deg(f32::MAX);
assert!(r.is_finite());
assert!((-180.0..=180.0).contains(&r));
}
#[test]
#[allow(clippy::float_cmp)] fn subnormal_is_unchanged() {
let tiny = f32::MIN_POSITIVE / 2.0;
assert_eq!(canon_angle_deg(tiny), tiny);
}
#[test]
#[allow(clippy::float_cmp)] fn negative_subnormal_is_unchanged() {
let tiny = -(f32::MIN_POSITIVE / 2.0);
assert_eq!(canon_angle_deg(tiny), tiny);
}
#[test]
fn nan_propagates_as_nan_not_a_panic() {
assert!(canon_angle_deg(f32::NAN).is_nan());
}
#[test]
fn positive_infinity_propagates_as_nan() {
assert!(canon_angle_deg(f32::INFINITY).is_nan());
}
#[test]
fn negative_infinity_propagates_as_nan() {
assert!(canon_angle_deg(f32::NEG_INFINITY).is_nan());
}
fn f64_reference(deg: f32) -> f32 {
let r = f64::from(deg) % 360.0;
let r = if r > 180.0 {
r - 360.0
} else if r <= -180.0 {
r + 360.0
} else {
r
};
#[allow(clippy::cast_possible_truncation)]
let r = r as f32;
r
}
#[test]
#[allow(clippy::float_cmp)] fn reduction_matches_an_f64_reference_everywhere_swept() {
let cases: &[f32] = &[
0.0,
f32::MIN_POSITIVE / 2.0, 1e-30,
0.1,
1.0,
10.0,
90.0,
179.0,
179.9,
180.0,
180.1,
181.0,
270.0,
359.0,
359.9,
360.0,
361.0,
719.0,
720.0,
1000.0,
1e4,
1e5,
1e6,
1e7,
1e8,
1e9,
1e12,
1e15,
1e18,
1e20,
1e25,
1e30,
1e35,
f32::MAX,
];
for ° in cases {
for &sign in &[1.0_f32, -1.0] {
let x = deg * sign;
let got = canon_angle_deg(x);
let want = f64_reference(x);
assert_eq!(got, want, "mismatch at x={x:e}");
}
}
}
#[test]
fn every_angle_already_in_range_is_bit_identical_to_itself() {
for i in -17_999_i16..=18_000 {
let deg = f32::from(i) / 100.0;
assert_eq!(
canon_angle_deg(deg).to_bits(),
deg.to_bits(),
"deg={deg} not bit-identical to itself"
);
}
}
#[test]
fn mutant_reverting_to_rem_euclid_is_caught() {
fn old_rem_euclid_version(deg: f32) -> f32 {
let mut a = deg.rem_euclid(360.0);
if a > 180.0 {
a -= 360.0;
}
a
}
let x = -0.1_f32;
assert_ne!(
old_rem_euclid_version(x).to_bits(),
x.to_bits(),
"the old implementation is expected to NOT be bit-identical here \
(if this now fails, rem_euclid's rounding defect was fixed \
upstream and this pin should be revisited)"
);
assert_eq!(canon_angle_deg(x).to_bits(), x.to_bits());
}
}
#[cfg(test)]
mod quat_from_euler_xyz_tests {
use super::{
quat_from_euler_xyz, quat_from_rotation_x, quat_from_rotation_y, quat_from_rotation_z,
};
use glam::{EulerRot, Quat};
const DET_MATH_PINNED_VERSION: &str = "0.5.1";
const RUSTC_PINNED_VERSION: &str = "rustc 1.98.1 (48a229cea 2026-09-01)";
#[test]
fn y_component_cancellation_lattice_is_debug_release_identical_and_canonical() {
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4};
let lattice: &[(f32, f32)] = &[
(0.0, 0.0),
(FRAC_PI_2, 0.0),
(0.0, FRAC_PI_2),
(-FRAC_PI_2, 0.0),
(0.0, -FRAC_PI_2),
(FRAC_PI_4, 0.0),
(0.0, FRAC_PI_4),
(-FRAC_PI_4, 0.0),
(0.0, -FRAC_PI_4),
];
for &(x, z) in lattice {
let q = quat_from_euler_xyz(x, 0.0, z);
assert_eq!(
q.y.to_bits(),
0.0f32.to_bits(),
"(x={x}, y=+0.0, z={z}): y = {:08x}, expected the canonical +0.0 (0x00000000)",
q.y.to_bits()
);
}
}
#[test]
fn y_component_cancellation_preserves_a_negative_zero_input() {
let q = quat_from_euler_xyz(0.0, -0.0, 0.0);
assert_eq!(
q.y.to_bits(),
(-0.0f32).to_bits(),
"(x=0.0, y=-0.0, z=0.0): y = {:08x}, expected -0.0 (0x80000000)",
q.y.to_bits()
);
}
#[test]
fn non_finite_euler_angles_propagate_as_nan_without_panicking() {
let non_finite: &[f32] = &[f32::INFINITY, f32::NEG_INFINITY, f32::NAN];
for &bad in non_finite {
for (x, y, z) in [(bad, 0.0, 0.0), (0.0, bad, 0.0), (0.0, 0.0, bad)] {
let q = quat_from_euler_xyz(x, y, z);
assert!(
q.x.is_nan() && q.y.is_nan() && q.z.is_nan() && q.w.is_nan(),
"(x={x}, y={y}, z={z}) -> {q:?} (expected all-NaN)"
);
}
}
}
mod version_pin {
use std::path::PathBuf;
pub(super) struct Package {
pub name: String,
pub version: String,
pub dependencies: Vec<String>,
}
fn find_lock_path() -> PathBuf {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for dir in manifest_dir.ancestors().skip(1) {
let candidate = dir.join("Cargo.lock");
if candidate.is_file() {
return candidate;
}
}
panic!(
"Cargo.lock が見つからない ({} から上の祖先 directory を探索した) — \
この test は workspace 内で `cargo test` された時だけ走る前提",
manifest_dir.display()
);
}
fn parse_packages(text: &str) -> Vec<Package> {
let mut packages = Vec::new();
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
if line.trim() != "[[package]]" {
continue;
}
let mut name = String::new();
let mut version = String::new();
let mut dependencies = Vec::new();
while let Some(&next) = lines.peek() {
let t = next.trim();
if t == "[[package]]" || t.starts_with("[[") {
break;
}
lines.next();
if let Some(v) = t
.strip_prefix("name = \"")
.and_then(|s| s.strip_suffix('"'))
{
name = v.to_string();
} else if let Some(v) = t
.strip_prefix("version = \"")
.and_then(|s| s.strip_suffix('"'))
{
version = v.to_string();
} else if t == "dependencies = [" {
for dep_line in lines.by_ref() {
let dt = dep_line.trim().trim_end_matches(',');
if dt == "]" {
break;
}
if let Some(v) = dt.strip_prefix('"').and_then(|s| s.strip_suffix('"'))
{
dependencies.push(v.to_string());
}
}
}
}
if !name.is_empty() {
packages.push(Package {
name,
version,
dependencies,
});
}
}
assert!(
!packages.is_empty(),
"Cargo.lock から [[package]] を 1 件も読めなかった (形式が想定と違う)"
);
packages
}
#[allow(clippy::option_if_let_else)]
pub(super) fn resolved_det_math_version() -> String {
let lock_path = find_lock_path();
let text = std::fs::read_to_string(&lock_path)
.unwrap_or_else(|e| panic!("Cargo.lock を読めない ({}): {e}", lock_path.display()));
let blocks = parse_packages(&text);
let alice_lol = blocks
.iter()
.find(|b| b.name == "alice-lol")
.unwrap_or_else(|| panic!("Cargo.lock に [[package]] name = \"alice-lol\" が無い"));
let det_math_versions: Vec<&str> = blocks
.iter()
.filter(|b| b.name == "alice-det-math")
.map(|b| b.version.as_str())
.collect();
let Some(dep) = alice_lol
.dependencies
.iter()
.find(|d| d.starts_with("alice-det-math"))
else {
panic!("alice-lol の dependencies に alice-det-math が無い");
};
if let Some(v) = dep.strip_prefix("alice-det-math ") {
v.to_string()
} else if det_math_versions.len() == 1 {
det_math_versions[0].to_string()
} else {
panic!(
"alice-lol の依存行が 'alice-det-math' だけ (version 無し) なのに \
Cargo.lock に alice-det-math が {} 個 linked されている ({:?}) \
— disambiguate されているはずなのに読めていない",
det_math_versions.len(),
det_math_versions
);
}
}
pub(super) fn resolved_rustc_version() -> String {
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
let output = std::process::Command::new(&rustc)
.arg("--version")
.output()
.unwrap_or_else(|e| panic!("`{rustc} --version` を実行できない: {e}"));
assert!(
output.status.success(),
"`{rustc} --version` が失敗 (status {:?}): {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let s = String::from_utf8(output.stdout)
.unwrap_or_else(|e| panic!("`{rustc} --version` の出力が UTF-8 でない: {e}"))
.trim()
.to_string();
assert!(!s.is_empty(), "`{rustc} --version` が空文字を返した");
s
}
}
#[test]
fn oracle_table_pinned_version_matches_resolved_det_math() {
let resolved = version_pin::resolved_det_math_version();
assert_eq!(
resolved, DET_MATH_PINNED_VERSION,
"Cargo.lock resolves alice-det-math {resolved} but the oracle table below was pinned \
for {DET_MATH_PINNED_VERSION} -- re-measure every row (not just the first one that \
fails), then update both this constant and the table"
);
}
#[test]
fn oracle_table_pinned_version_matches_resolved_rustc() {
let resolved = version_pin::resolved_rustc_version();
assert_eq!(
resolved, RUSTC_PINNED_VERSION,
"building with {resolved} but the y-component formula's debug/release stability was \
last verified under {RUSTC_PINNED_VERSION} -- re-run \
y_component_cancellation_lattice_is_debug_release_identical_and_canonical under both \
`cargo test` and `cargo test --release` before trusting it, then update this constant"
);
}
#[test]
fn matches_glam_from_euler_on_a_sweep() {
let angles: &[f32] = &[-3.0, -1.5, -0.7, -0.1, 0.0, 0.05, 0.3, 0.9, 1.57, 2.0, 2.9];
let mut max_diff = 0.0_f32;
for &x in angles {
for &y in angles {
for &z in angles {
let want = Quat::from_euler(EulerRot::XYZ, x, y, z);
let got = quat_from_euler_xyz(x, y, z);
for (a, b) in [
(want.x, got.x),
(want.y, got.y),
(want.z, got.z),
(want.w, got.w),
] {
max_diff = max_diff.max((a - b).abs());
}
}
}
}
assert!(
max_diff < 1e-5,
"quat_from_euler_xyz formula does not match glam's XYZ case: max_diff={max_diff:e}"
);
}
#[test]
fn is_bit_identical_across_repeated_calls() {
for (x, y, z) in [
(0.0_f32, 0.0, 0.0),
(1.0, -2.0, 3.5),
(std::f32::consts::PI, 0.1, -0.1),
] {
let a = quat_from_euler_xyz(x, y, z);
let b = quat_from_euler_xyz(x, y, z);
assert_eq!(a.x.to_bits(), b.x.to_bits());
assert_eq!(a.y.to_bits(), b.y.to_bits());
assert_eq!(a.z.to_bits(), b.z.to_bits());
assert_eq!(a.w.to_bits(), b.w.to_bits());
}
}
#[test]
fn quat_from_euler_xyz_is_bit_identical_across_architectures() {
for &((x, y, z), (ex, ey, ez, ew)) in super::EULER_XYZ_PROBE_TABLE {
let q = quat_from_euler_xyz(x, y, z);
assert_eq!(q.x.to_bits(), ex, "x component at ({x}, {y}, {z})");
assert_eq!(q.y.to_bits(), ey, "y component at ({x}, {y}, {z})");
assert_eq!(q.z.to_bits(), ez, "z component at ({x}, {y}, {z})");
assert_eq!(q.w.to_bits(), ew, "w component at ({x}, {y}, {z})");
}
}
#[test]
fn quat_from_rotation_axes_are_bit_identical_across_architectures() {
for &(a, (xx, xy, xz, xw), (yx, yy, yz, yw), (zx, zy, zz, zw)) in
super::ROTATION_AXIS_PROBE_TABLE
{
let qx = quat_from_rotation_x(a);
assert_eq!(
(
qx.x.to_bits(),
qx.y.to_bits(),
qx.z.to_bits(),
qx.w.to_bits()
),
(xx, xy, xz, xw),
"quat_from_rotation_x({a})"
);
let qy = quat_from_rotation_y(a);
assert_eq!(
(
qy.x.to_bits(),
qy.y.to_bits(),
qy.z.to_bits(),
qy.w.to_bits()
),
(yx, yy, yz, yw),
"quat_from_rotation_y({a})"
);
let qz = quat_from_rotation_z(a);
assert_eq!(
(
qz.x.to_bits(),
qz.y.to_bits(),
qz.z.to_bits(),
qz.w.to_bits()
),
(zx, zy, zz, zw),
"quat_from_rotation_z({a})"
);
}
}
#[test]
fn quat_from_rotation_axes_match_glam_on_a_sweep() {
let angles: &[f32] = &[-3.0, -1.5, -0.7, -0.1, 0.0, 0.05, 0.3, 0.9, 1.57, 2.0, 2.9];
let mut max_diff = 0.0_f32;
for &a in angles {
for (got, want) in [
(quat_from_rotation_x(a), glam::Quat::from_rotation_x(a)),
(quat_from_rotation_y(a), glam::Quat::from_rotation_y(a)),
(quat_from_rotation_z(a), glam::Quat::from_rotation_z(a)),
] {
for (g, w) in [
(got.x, want.x),
(got.y, want.y),
(got.z, want.z),
(got.w, want.w),
] {
max_diff = max_diff.max((g - w).abs());
}
}
}
assert!(max_diff < 1e-5, "max_diff={max_diff:e}");
}
}