use super::polarization::{StokesVector, electric_field_direction};
use glam::{Mat3, Vec3};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BirefringenceParams {
pub delta_n: f32,
pub c_axis: [f32; 3],
}
impl BirefringenceParams {
#[must_use]
pub fn new(delta_n: f32, c_axis: Vec3) -> Self {
let norm_c = c_axis.normalize_or_zero();
Self {
delta_n,
c_axis: [norm_c.x, norm_c.y, norm_c.z],
}
}
#[must_use]
pub const fn c_axis_vec3(&self) -> Vec3 {
Vec3::from_array(self.c_axis)
}
#[must_use]
pub fn effective_extraordinary_index(n_o: f32, n_e: f32, theta: f32) -> f32 {
if (n_o - n_e).abs() < 1e-5 {
return n_o;
}
let sin_t = theta.sin();
let cos_t = theta.cos();
let denom_sq = (n_e * cos_t).mul_add(n_e * cos_t, (n_o * sin_t).powi(2));
if denom_sq <= 1e-8 {
return n_o;
}
(n_o * n_e) / denom_sq.sqrt()
}
#[must_use]
pub fn walk_off_angle(n_o: f32, n_e: f32, theta: f32) -> f32 {
if (n_o - n_e).abs() < 1e-5 {
return 0.0;
}
let n_o2 = n_o * n_o;
let n_e2 = n_e * n_e;
let sin_t = theta.sin();
let cos_t = theta.cos();
let tan_rho = ((n_o2 - n_e2) * sin_t * cos_t)
/ (n_e2 * cos_t)
.mul_add(cos_t, n_o2 * sin_t * sin_t)
.max(1e-6);
tan_rho.atan()
}
#[must_use]
pub fn extraordinary_poynting_dir(wave_normal: Vec3, c_axis: Vec3, n_o: f32, n_e: f32) -> Vec3 {
let cos_theta = wave_normal.dot(c_axis).clamp(-1.0, 1.0);
let theta = cos_theta.abs().acos();
let delta = Self::walk_off_angle(n_o, n_e, theta);
if delta.abs() < 1e-5 {
return wave_normal;
}
let c_proj = (c_axis - cos_theta * wave_normal).normalize_or_zero();
if c_proj.length_squared() < 1e-6 {
return wave_normal;
}
let sign = if cos_theta >= 0.0 { 1.0 } else { -1.0 };
(wave_normal * delta.cos() - sign * c_proj * delta.sin()).normalize()
}
#[must_use]
pub fn ordinary_eigen_polarization(wave_normal: Vec3, c_axis: Vec3) -> Vec3 {
let cross = wave_normal.cross(c_axis);
if cross.length_squared() > 1e-8 {
cross.normalize()
} else {
stable_orthonormal_basis(wave_normal.normalize_or_zero()).0
}
}
#[must_use]
pub fn extraordinary_eigen_polarization(wave_normal: Vec3, c_axis: Vec3) -> Vec3 {
let o_hat = Self::ordinary_eigen_polarization(wave_normal, c_axis);
wave_normal.cross(o_hat).normalize_or_zero()
}
}
fn stable_orthonormal_basis(n: Vec3) -> (Vec3, Vec3) {
let a = if n.x.abs() > 0.9 { Vec3::Y } else { Vec3::X };
let t = (a - n * n.dot(a)).normalize_or_zero();
let b = n.cross(t);
(t, b)
}
fn canonicalize_eigenvector_sign(v: Vec3) -> Vec3 {
let ax = v.x.abs();
let ay = v.y.abs();
let az = v.z.abs();
let largest = if ax >= ay && ax >= az {
v.x
} else if ay >= az {
v.y
} else {
v.z
};
if largest < 0.0 { -v } else { v }
}
fn cross_fma(a: Vec3, b: Vec3) -> Vec3 {
Vec3::new(
a.y.mul_add(b.z, -(a.z * b.y)),
a.z.mul_add(b.x, -(a.x * b.z)),
a.x.mul_add(b.y, -(a.y * b.x)),
)
}
fn dot_fma(a: Vec3, b: Vec3) -> f32 {
a.x.mul_add(b.x, a.y.mul_add(b.y, a.z * b.z))
}
#[derive(Debug, Clone, Copy)]
pub struct AbsorptionTensor3 {
pub alpha: Vec3,
pub axes: Mat3,
}
impl AbsorptionTensor3 {
#[must_use]
pub fn uniaxial(alpha_o: f32, alpha_e: f32, c_axis: Vec3) -> Self {
let c = c_axis.normalize_or_zero();
let (a1, a2) = stable_orthonormal_basis(c);
Self {
alpha: Vec3::new(alpha_o, alpha_o, alpha_e),
axes: Mat3::from_cols(a1, a2, c),
}
}
#[must_use]
pub fn biaxial(alpha: f32, beta: f32, gamma: f32, gamma_axis: Vec3) -> Self {
let g = gamma_axis.normalize_or_zero();
let (a1, a2) = stable_orthonormal_basis(g);
Self {
alpha: Vec3::new(alpha, beta, gamma),
axes: Mat3::from_cols(a1, a2, g),
}
}
#[must_use]
pub fn quadratic_form(&self, e_hat: Vec3) -> f32 {
let local = self.axes.transpose() * e_hat;
self.alpha.dot(local * local)
}
}
#[must_use]
pub fn assigned_mode_alpha(tensor: &AbsorptionTensor3, e_mode_hat: Vec3) -> f32 {
tensor.quadratic_form(e_mode_hat)
}
#[must_use]
pub fn assigned_mode_e_field_uniaxial(
k_hat: Vec3,
c_axis: Vec3,
is_extraordinary: bool,
n_o_hero: f32,
n_e_hero: f32,
) -> Vec3 {
if !is_extraordinary {
return BirefringenceParams::ordinary_eigen_polarization(k_hat, c_axis);
}
let k_cross_c = k_hat.cross(c_axis);
if k_cross_c.length_squared() < 1e-8 {
return BirefringenceParams::extraordinary_eigen_polarization(k_hat, c_axis);
}
let s = BirefringenceParams::extraordinary_poynting_dir(k_hat, c_axis, n_o_hero, n_e_hero);
k_cross_c.cross(s).normalize()
}
#[must_use]
pub fn effective_pleochroic_alpha(
tensor: &AbsorptionTensor3,
e_hat: Vec3,
eigenmode_a: Vec3,
eigenmode_b: Vec3,
degree_of_polarization: f32,
) -> f32 {
let p = degree_of_polarization.clamp(0.0, 1.0);
let alpha_polarized = tensor.quadratic_form(e_hat);
let alpha_unpolarized = f32::midpoint(
tensor.quadratic_form(eigenmode_a),
tensor.quadratic_form(eigenmode_b),
);
p.mul_add(alpha_polarized - alpha_unpolarized, alpha_unpolarized)
}
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "argument order deliberately mirrors transport_physics.wgsl's \
pleochroic_channel_alpha/pleochroic_channel_alpha_biaxial (alpha_o, \
alpha_e, [alpha_beta,] c_axis, s_axis, propagation_dir, eigen_a, eigen_b, \
stokes) one-for-one; bundling these into a context struct here would break \
that correspondence and make it harder to check the CPU and GPU paths \
against each other, which is the whole point of this function existing \
as a direct WGSL mirror"
)]
pub fn pleochroic_channel_alpha(
alpha_o: f32,
alpha_e: f32,
alpha_beta: Option<f32>,
c_axis: Vec3,
s_axis: Vec3,
propagation_dir: Vec3,
eigenmode_a: Vec3,
eigenmode_b: Vec3,
stokes: &StokesVector,
) -> f32 {
let tensor = alpha_beta.map_or_else(
|| AbsorptionTensor3::uniaxial(alpha_o, alpha_e, c_axis),
|beta| AbsorptionTensor3::biaxial(alpha_o, beta, alpha_e, c_axis),
);
let e_hat = electric_field_direction(stokes, s_axis, propagation_dir);
effective_pleochroic_alpha(
&tensor,
e_hat,
eigenmode_a,
eigenmode_b,
stokes.degree_of_polarization(),
)
}
#[derive(Debug, Clone, Copy)]
pub struct BiaxialIndicatrix {
pub n_alpha: f32,
pub n_beta: f32,
pub n_gamma: f32,
pub axes: Mat3,
}
impl BiaxialIndicatrix {
#[must_use]
pub const fn new(n_alpha: f32, n_beta: f32, n_gamma: f32, axes: Mat3) -> Self {
Self {
n_alpha,
n_beta,
n_gamma,
axes,
}
}
#[must_use]
pub fn uniaxial(n_o: f32, n_e: f32, c_axis: Vec3) -> Self {
let c = c_axis.normalize_or_zero();
let (a1, a2) = stable_orthonormal_basis(c);
Self::new(n_o, n_o, n_e, Mat3::from_cols(a1, a2, c))
}
#[must_use]
pub fn from_gamma_axis(n_alpha: f32, n_beta: f32, n_gamma: f32, gamma_axis: Vec3) -> Self {
let g = gamma_axis.normalize_or_zero();
let (a1, a2) = stable_orthonormal_basis(g);
Self::new(n_alpha, n_beta, n_gamma, Mat3::from_cols(a1, a2, g))
}
fn b_coeffs(&self) -> [f32; 3] {
[
1.0 / (self.n_alpha * self.n_alpha),
1.0 / (self.n_beta * self.n_beta),
1.0 / (self.n_gamma * self.n_gamma),
]
}
#[must_use]
pub fn wave_indices(&self, wave_normal: Vec3) -> (f32, f32) {
let k = wave_normal.normalize_or_zero();
let alpha_beta_degenerate = Self::indices_are_degenerate(self.n_alpha, self.n_beta);
let beta_gamma_degenerate = Self::indices_are_degenerate(self.n_beta, self.n_gamma);
if alpha_beta_degenerate && beta_gamma_degenerate {
let n = (self.n_alpha + self.n_beta + self.n_gamma) / 3.0;
return (n, n);
}
if alpha_beta_degenerate {
return Self::uniaxial_wave_indices(
k,
self.axes.z_axis,
f32::midpoint(self.n_alpha, self.n_beta),
self.n_gamma,
);
}
if beta_gamma_degenerate {
return Self::uniaxial_wave_indices(
k,
self.axes.x_axis,
f32::midpoint(self.n_beta, self.n_gamma),
self.n_alpha,
);
}
let local = self.axes.transpose() * k;
let (a2, b2, g2) = (local.x * local.x, local.y * local.y, local.z * local.z);
let [b1, b2c, b3] = self.b_coeffs();
let big_b = a2.mul_add(b2c + b3, b2.mul_add(b1 + b3, g2 * (b1 + b2c)));
let big_c = a2.mul_add(b2c * b3, b2.mul_add(b1 * b3, g2 * b1 * b2c));
let disc = big_b.mul_add(big_b, -4.0 * big_c).max(0.0).sqrt();
let x_lo = 0.5 * (big_b - disc); let x_hi = f32::midpoint(big_b, disc);
let n_slow = 1.0 / x_lo.max(1e-12).sqrt();
let n_fast = 1.0 / x_hi.max(1e-12).sqrt();
(n_slow, n_fast)
}
fn uniaxial_wave_indices(k_hat: Vec3, c_axis: Vec3, n_o: f32, n_e: f32) -> (f32, f32) {
let theta = k_hat.dot(c_axis).clamp(-1.0, 1.0).abs().acos();
let n_eff = BirefringenceParams::effective_extraordinary_index(n_o, n_e, theta);
(n_o.max(n_eff), n_o.min(n_eff))
}
fn indices_are_degenerate(a: f32, b: f32) -> bool {
let scale = a.abs().max(b.abs()).max(1.0);
(a - b).abs() <= f32::EPSILON.sqrt() * scale
}
#[must_use]
pub fn eigen_polarizations(&self, wave_normal: Vec3) -> (Vec3, Vec3) {
let k = wave_normal.normalize_or_zero();
let local = self.axes.transpose() * k;
let [b1, b2, b3] = self.b_coeffs();
let (n_slow, n_fast) = self.wave_indices(wave_normal);
let x_slow = 1.0 / (n_slow * n_slow);
let x_fast = 1.0 / (n_fast * n_fast);
let d_slow = self.eigenvector_world(local, [b1, b2, b3], x_slow, k);
let d_fast = self.eigenvector_world(local, [b1, b2, b3], x_fast, k);
(d_slow, d_fast)
}
fn eigenvector_world(&self, local: Vec3, b: [f32; 3], x: f32, k_hat: Vec3) -> Vec3 {
let x = Self::precise_root_near(local, b, x);
let s = b[0].mul_add(
local.x * local.x,
b[1].mul_add(local.y * local.y, b[2] * local.z * local.z),
);
let m00 = (local.x * local.x).mul_add(2.0f32.mul_add(-b[0], s), b[0] - x);
let m11 = (local.y * local.y).mul_add(2.0f32.mul_add(-b[1], s), b[1] - x);
let m22 = (local.z * local.z).mul_add(2.0f32.mul_add(-b[2], s), b[2] - x);
let m01 = local.x * local.y * (s - b[0] - b[1]);
let m02 = local.x * local.z * (s - b[0] - b[2]);
let m12 = local.y * local.z * (s - b[1] - b[2]);
let row0 = Vec3::new(m00, m01, m02);
let row1 = Vec3::new(m01, m11, m12);
let row2 = Vec3::new(m02, m12, m22);
let c01 = cross_fma(row0, row1);
let c02 = cross_fma(row0, row2);
let c12 = cross_fma(row1, row2);
let sign02 = if dot_fma(c01, c02) < 0.0 { -1.0 } else { 1.0 };
let sign12 = if dot_fma(c01, c12) < 0.0 { -1.0 } else { 1.0 };
let v_local = c01 + c02 * sign02 + c12 * sign12;
let v_world = self.axes * v_local;
if v_world.length_squared() > 1e-12 {
canonicalize_eigenvector_sign(v_world).normalize()
} else {
stable_orthonormal_basis(k_hat).0
}
}
fn precise_root_near(local: Vec3, b: [f32; 3], x: f32) -> f32 {
let a = local.x * local.x;
let bb = local.y * local.y;
let cc = local.z * local.z;
let big_b = a.mul_add(b[1] + b[2], bb.mul_add(b[0] + b[2], cc * (b[0] + b[1])));
let xdiff = b[0] - b[1]; let ydiff = b[2] - b[0];
let a_plus_c = a + cc;
let a_plus_bb = a + bb;
let two_a_minus_bc = 2.0 * bb.mul_add(-cc, a);
let disc_sq = (a_plus_c * a_plus_c).mul_add(
xdiff * xdiff,
(a_plus_bb * a_plus_bb).mul_add(ydiff * ydiff, two_a_minus_bc * xdiff * ydiff),
);
let disc = disc_sq.max(0.0).sqrt();
let x_lo = 0.5 * (big_b - disc);
let x_hi = f32::midpoint(big_b, disc);
if (x - x_lo).abs() <= (x - x_hi).abs() {
x_lo
} else {
x_hi
}
}
#[must_use]
pub fn d_to_e_direction(&self, d_hat: Vec3) -> Vec3 {
let d_local = self.axes.transpose() * d_hat;
let e_local = Vec3::new(
d_local.x / (self.n_alpha * self.n_alpha),
d_local.y / (self.n_beta * self.n_beta),
d_local.z / (self.n_gamma * self.n_gamma),
);
(self.axes * e_local).normalize_or_zero()
}
#[must_use]
pub fn resolve_entry_mode(
&self,
incident_dir: Vec3,
normal: Vec3,
cos_i: f32,
n_seed: f32,
want_slow: bool,
) -> (f32, Vec3) {
let mut n_guess = n_seed;
let mut wave_dir = incident_dir;
for _ in 0..2 {
let eta_guess = 1.0 / n_guess;
let sin2_t_guess = eta_guess * eta_guess * cos_i.mul_add(-cos_i, 1.0);
if sin2_t_guess > 1.0 {
break;
}
let cos_t_guess = (1.0 - sin2_t_guess).max(0.0).sqrt();
wave_dir = (eta_guess * incident_dir + eta_guess.mul_add(cos_i, -cos_t_guess) * normal)
.normalize();
let (n_slow, n_fast) = self.wave_indices(wave_dir);
n_guess = if want_slow { n_slow } else { n_fast };
}
(n_guess, wave_dir)
}
#[must_use]
pub fn mode_poynting_dir(&self, wave_normal: Vec3, want_slow: bool) -> Vec3 {
let (d_slow, d_fast) = self.eigen_polarizations(wave_normal);
let d_hat = if want_slow { d_slow } else { d_fast };
let e_hat = self.d_to_e_direction(d_hat);
poynting_direction(wave_normal, e_hat)
}
#[must_use]
pub fn assigned_mode_e_field(&self, wave_normal: Vec3, is_extraordinary: bool) -> Vec3 {
let (d_slow, d_fast) = self.eigen_polarizations(wave_normal);
let d_hat = if is_extraordinary { d_slow } else { d_fast };
self.d_to_e_direction(d_hat)
}
}
#[must_use]
pub fn poynting_direction(wave_normal: Vec3, e_field_hat: Vec3) -> Vec3 {
let k_hat = wave_normal.normalize_or_zero();
let e_hat = e_field_hat.normalize_or_zero();
let perp = k_hat - e_hat * e_hat.dot(k_hat);
let len2 = perp.length_squared();
if len2 > 1e-10 {
perp / len2.sqrt()
} else {
k_hat
}
}
#[cfg(test)]
mod absorption_tensor_tests {
use super::*;
use crate::optics::polarization::StokesVector;
#[test]
fn isotropic_tensor_is_azimuth_independent() {
let tensor = AbsorptionTensor3::uniaxial(1.5, 1.5, Vec3::Y);
let dirs = [
Vec3::X,
Vec3::Y,
Vec3::Z,
Vec3::new(1.0, 1.0, 1.0).normalize(),
Vec3::new(0.3, -0.7, 0.2).normalize(),
];
for d in dirs {
let a = tensor.quadratic_form(d);
assert!(
(a - 1.5).abs() < 1e-5,
"isotropic tensor must be azimuth-independent, got {a} for {d:?}"
);
}
}
#[test]
fn uniaxial_tensor_matches_principal_coefficients_on_axis() {
let c_axis = Vec3::Y;
let tensor = AbsorptionTensor3::uniaxial(2.0, 5.0, c_axis);
assert!((tensor.quadratic_form(c_axis) - 5.0).abs() < 1e-5);
assert!((tensor.quadratic_form(Vec3::X) - 2.0).abs() < 1e-5);
assert!((tensor.quadratic_form(Vec3::Z) - 2.0).abs() < 1e-5);
}
#[test]
fn rotating_polarization_azimuth_traces_a_clean_sinusoid() {
let c_axis = Vec3::new(0.2, 0.6, 0.77).normalize(); let propagation_dir = Vec3::new(0.9, 0.1, 0.3).normalize(); let alpha_o = 0.5f32;
let alpha_e = 6.0f32; let tensor = AbsorptionTensor3::uniaxial(alpha_o, alpha_e, c_axis);
let s_axis = propagation_dir.cross(Vec3::Y).normalize();
let samples = 37;
let mut values = Vec::with_capacity(samples);
for i in 0..samples {
let psi = (i as f32) / (samples as f32 - 1.0) * std::f32::consts::PI; let stokes = StokesVector::new(1.0, (2.0 * psi).cos(), (2.0 * psi).sin(), 0.0);
let e_hat = electric_field_direction(&stokes, s_axis, propagation_dir);
let eigen_a = BirefringenceParams::ordinary_eigen_polarization(propagation_dir, c_axis);
let eigen_b =
BirefringenceParams::extraordinary_eigen_polarization(propagation_dir, c_axis);
let alpha = effective_pleochroic_alpha(&tensor, e_hat, eigen_a, eigen_b, 1.0);
values.push(alpha);
}
let start = values[0];
let end = values[samples - 1];
assert!(
(start - end).abs() < 1e-3,
"azimuth sweep must return to its starting value (start={start}, end={end})"
);
let min = values.iter().copied().fold(f32::INFINITY, f32::min);
let max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
assert!(
max - min > 1.0,
"absorption must vary substantially as azimuth rotates for a strongly dichroic material (min={min}, max={max})"
);
for &a in &values {
assert!(
a >= alpha_o - 1e-3 && a <= alpha_e + 1e-3,
"alpha={a} out of the physical [alpha_o, alpha_e]=[{alpha_o},{alpha_e}] envelope"
);
}
let span = max - min;
for w in values.windows(2) {
assert!(
(w[1] - w[0]).abs() < 0.35 * span,
"adjacent samples should vary smoothly, not jump (got delta={})",
(w[1] - w[0]).abs()
);
}
}
#[test]
fn unpolarized_light_degrades_gracefully_and_is_azimuth_independent() {
let c_axis = Vec3::Y;
let propagation_dir = Vec3::new(0.6, 0.3, 0.74).normalize();
let alpha_o = 1.0f32;
let alpha_e = 4.0f32;
let tensor = AbsorptionTensor3::uniaxial(alpha_o, alpha_e, c_axis);
let eigen_a = BirefringenceParams::ordinary_eigen_polarization(propagation_dir, c_axis);
let eigen_b =
BirefringenceParams::extraordinary_eigen_polarization(propagation_dir, c_axis);
let unpolarized = StokesVector::unpolarized(1.0);
let s_axis = propagation_dir.cross(Vec3::Z).normalize();
let e_hat = electric_field_direction(&unpolarized, s_axis, propagation_dir);
let alpha_unpolarized = effective_pleochroic_alpha(
&tensor,
e_hat,
eigen_a,
eigen_b,
unpolarized.degree_of_polarization(),
);
assert!(
alpha_unpolarized > alpha_o && alpha_unpolarized < alpha_e,
"unpolarized absorption {alpha_unpolarized} should sit strictly between alpha_o={alpha_o} and alpha_e={alpha_e}"
);
let s_axis_2 = propagation_dir.cross(Vec3::X).normalize_or_zero();
let e_hat_2 = electric_field_direction(&unpolarized, s_axis_2, propagation_dir);
let alpha_unpolarized_2 = effective_pleochroic_alpha(
&tensor,
e_hat_2,
eigen_a,
eigen_b,
unpolarized.degree_of_polarization(),
);
assert!(
(alpha_unpolarized - alpha_unpolarized_2).abs() < 1e-5,
"unpolarized result must not depend on the reference frame used to build e_hat"
);
}
#[test]
fn convention_pin_o_ray_is_perpendicular_e_ray_is_parallel_to_c_axis() {
use crate::optics::{materials::GemMaterial, raytracer::spectral_absorption};
const LAMBDA_NM: f32 = 556.0;
let ruby = GemMaterial::by_name("Ruby").expect("Ruby must be a built-in material");
let alpha_o = spectral_absorption(&ruby.absorption.o_ray, LAMBDA_NM);
let alpha_e = spectral_absorption(&ruby.absorption.e_ray, LAMBDA_NM);
assert!(
alpha_o > 0.0 && alpha_e > 0.0,
"test premise: both rays must contribute at 556nm (o={alpha_o}, e={alpha_e})"
);
assert!(
(alpha_o - alpha_e).abs() > 0.1,
"test premise: o-ray and e-ray must actually differ at 556nm for this test to be \
discriminating (o={alpha_o}, e={alpha_e})"
);
let tensor = AbsorptionTensor3::uniaxial(alpha_o, alpha_e, Vec3::Y);
let a_perp = tensor.quadratic_form(Vec3::X);
let a_parallel = tensor.quadratic_form(Vec3::Y);
assert!(
(a_perp - alpha_o).abs() < 1e-4,
"E-perp-c quadratic_form ({a_perp}) must equal the o-ray band sum ({alpha_o}) -- \
o_ray must map to the PERPENDICULAR-to-c principal coefficient"
);
assert!(
(a_parallel - alpha_e).abs() < 1e-4,
"E-parallel-c quadratic_form ({a_parallel}) must equal the e-ray band sum \
({alpha_e}) -- e_ray must map to the PARALLEL-to-c principal coefficient"
);
}
#[test]
fn polarized_probe_matches_band_sums_for_real_tourmaline_data() {
use crate::optics::{materials::GemMaterial, raytracer::spectral_absorption};
const LAMBDA_NM: f32 = 430.0; let tourmaline =
GemMaterial::by_name("Tourmaline").expect("Tourmaline must be a built-in material");
let c_axis = tourmaline.c_axis; let alpha_o = spectral_absorption(&tourmaline.absorption.o_ray, LAMBDA_NM);
let alpha_e = spectral_absorption(&tourmaline.absorption.e_ray, LAMBDA_NM);
assert!(
alpha_o > alpha_e * 2.0,
"test premise: Tourmaline's o-ray must be substantially stronger than its e-ray at \
430nm (o={alpha_o}, e={alpha_e})"
);
let propagation_dir = if c_axis.x.abs() > 0.9 {
Vec3::Y
} else {
Vec3::X
};
assert!(
propagation_dir.dot(c_axis).abs() < 1e-6,
"test premise: propagation_dir must be exactly perpendicular to c_axis"
);
let eigen_a = BirefringenceParams::ordinary_eigen_polarization(propagation_dir, c_axis);
let eigen_b =
BirefringenceParams::extraordinary_eigen_polarization(propagation_dir, c_axis);
let s_axis = propagation_dir.cross(c_axis).normalize();
let stokes_parallel = StokesVector::new(1.0, -1.0, 0.0, 0.0);
let e_parallel = electric_field_direction(&stokes_parallel, s_axis, propagation_dir);
assert!(
(e_parallel.dot(c_axis).abs() - 1.0).abs() < 1e-4,
"test premise: this Stokes vector's field direction must be parallel to c_axis, \
got {e_parallel:?} vs c_axis {c_axis:?}"
);
let alpha_measured_parallel = pleochroic_channel_alpha(
alpha_o,
alpha_e,
None,
c_axis,
s_axis,
propagation_dir,
eigen_a,
eigen_b,
&stokes_parallel,
);
assert!(
(alpha_measured_parallel - alpha_e).abs() < 1e-4,
"E-parallel-c fully-polarized probe ({alpha_measured_parallel}) must equal the \
e-ray band sum ({alpha_e})"
);
let stokes_perp = StokesVector::new(1.0, 1.0, 0.0, 0.0);
let e_perp = electric_field_direction(&stokes_perp, s_axis, propagation_dir);
assert!(
e_perp.dot(c_axis).abs() < 1e-4,
"test premise: this Stokes vector's field direction must be perpendicular to \
c_axis, got {e_perp:?} vs c_axis {c_axis:?}"
);
let alpha_measured_perp = pleochroic_channel_alpha(
alpha_o,
alpha_e,
None,
c_axis,
s_axis,
propagation_dir,
eigen_a,
eigen_b,
&stokes_perp,
);
assert!(
(alpha_measured_perp - alpha_o).abs() < 1e-4,
"E-perp-c fully-polarized probe ({alpha_measured_perp}) must equal the o-ray band \
sum ({alpha_o})"
);
}
}
#[cfg(test)]
mod biaxial_reduction_tests {
use super::*;
#[test]
fn biaxial_wave_indices_reduce_to_uniaxial_formula() {
let n_o = 1.65f32;
let n_e = 1.72f32; let c_axis = Vec3::new(0.35, 0.82, -0.45).normalize(); let indicatrix = BiaxialIndicatrix::uniaxial(n_o, n_e, c_axis);
let directions = [
Vec3::X,
Vec3::Y,
Vec3::Z,
c_axis, Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.2, 0.9, 0.1).normalize(),
Vec3::new(-0.5, 0.3, 0.8).normalize(),
Vec3::new(0.7, -0.6, 0.2).normalize(),
Vec3::new(0.1, 0.1, 0.99).normalize(),
Vec3::new(-0.9, -0.3, 0.2).normalize(),
];
for wave_normal in directions {
let (n_slow, n_fast) = indicatrix.wave_indices(wave_normal);
let cos_theta = wave_normal.normalize().dot(c_axis).clamp(-1.0, 1.0).abs();
let theta = cos_theta.acos();
let n_eff = BirefringenceParams::effective_extraordinary_index(n_o, n_e, theta);
let expected_lo = n_o.min(n_eff);
let expected_hi = n_o.max(n_eff);
assert!(
(n_fast - expected_lo).abs() < 1e-3,
"n_fast should match min(n_o, n_eff) at direction {wave_normal:?} (theta={theta}): got {n_fast}, expected {expected_lo}"
);
assert!(
(n_slow - expected_hi).abs() < 1e-3,
"n_slow should match max(n_o, n_eff) at direction {wave_normal:?} (theta={theta}): got {n_slow}, expected {expected_hi}"
);
}
}
#[test]
fn biaxial_wave_indices_reduce_to_isotropic_when_all_equal() {
let n = 1.9f32;
let indicatrix = BiaxialIndicatrix::new(n, n, n, Mat3::IDENTITY);
for wave_normal in [
Vec3::X,
Vec3::Y,
Vec3::Z,
Vec3::new(0.3, 0.5, 0.8).normalize(),
] {
let (n_slow, n_fast) = indicatrix.wave_indices(wave_normal);
assert!(
(n_slow - n).abs() < 1e-4,
"isotropic n_slow should equal {n}, got {n_slow}"
);
assert!(
(n_fast - n).abs() < 1e-4,
"isotropic n_fast should equal {n}, got {n_fast}"
);
}
}
#[test]
fn biaxial_eigen_polarizations_are_orthonormal_and_transverse() {
let axes = Mat3::from_cols(Vec3::X, Vec3::Y, Vec3::Z);
let indicatrix = BiaxialIndicatrix::new(1.60, 1.65, 1.75, axes);
for wave_normal in [
Vec3::new(0.3, 0.5, 0.8).normalize(),
Vec3::new(0.9, 0.1, -0.2).normalize(),
Vec3::new(-0.4, 0.6, 0.5).normalize(),
] {
let (d_slow, d_fast) = indicatrix.eigen_polarizations(wave_normal);
assert!(
d_slow.is_finite() && d_fast.is_finite(),
"eigenvectors must be finite (d_slow={d_slow:?}, d_fast={d_fast:?})"
);
assert!(
(d_slow.length() - 1.0).abs() < 1e-3,
"d_slow must be unit length, got {}",
d_slow.length()
);
assert!(
(d_fast.length() - 1.0).abs() < 1e-3,
"d_fast must be unit length, got {}",
d_fast.length()
);
assert!(
d_slow.dot(d_fast).abs() < 1e-2,
"eigenvectors must be mutually perpendicular, got dot={}",
d_slow.dot(d_fast)
);
assert!(
d_slow.dot(wave_normal).abs() < 1e-2,
"d_slow must be transverse to the wave normal, got dot={}",
d_slow.dot(wave_normal)
);
assert!(
d_fast.dot(wave_normal).abs() < 1e-2,
"d_fast must be transverse to the wave normal, got dot={}",
d_fast.dot(wave_normal)
);
}
}
#[test]
fn biaxial_eigenvectors_satisfy_eigen_equation_across_dense_sweep() {
use crate::optics::materials::GemMaterial;
const RESIDUAL_BOUND: f32 = 2e-4;
for name in ["Alexandrite", "Topaz", "Tanzanite"] {
let material = GemMaterial::by_name(name)
.unwrap_or_else(|| panic!("{name} must be a built-in material"));
let indicatrix = material
.biaxial_indicatrix(589.3)
.unwrap_or_else(|| panic!("{name} must expose a biaxial indicatrix"));
let b = indicatrix.b_coeffs();
let samples = 181;
let mut prev_slow: Option<Vec3> = None;
let mut prev_fast: Option<Vec3> = None;
for i in 0..samples {
let theta = (i as f32) / (samples as f32 - 1.0) * std::f32::consts::PI;
let wave_normal = (theta.cos() * Vec3::new(0.4, 0.6, 0.69).normalize()
+ theta.sin() * Vec3::new(0.8, -0.3, 0.2).normalize())
.normalize();
let k = wave_normal; let local = indicatrix.axes.transpose() * k;
let (n_slow, n_fast) = indicatrix.wave_indices(wave_normal);
let (d_slow, d_fast) = indicatrix.eigen_polarizations(wave_normal);
for (n, d_hat, prev) in [
(n_slow, d_slow, &mut prev_slow),
(n_fast, d_fast, &mut prev_fast),
] {
assert!(
(d_hat.length() - 1.0).abs() < 1e-4,
"{name}: eigenvector must be unit length at theta={theta}, got {}",
d_hat.length()
);
let x = 1.0 / (n * n);
let d_local = indicatrix.axes.transpose() * d_hat;
let e_dot_k = (local.z * b[2]).mul_add(
d_local.z,
(local.y * b[1]).mul_add(d_local.y, local.x * b[0] * d_local.x),
);
let residual = Vec3::new(
d_local.x.mul_add(x - b[0], local.x * e_dot_k),
d_local.y.mul_add(x - b[1], local.y * e_dot_k),
d_local.z.mul_add(x - b[2], local.z * e_dot_k),
);
assert!(
residual.length() < RESIDUAL_BOUND,
"{name}: eigen-equation residual too large at theta={theta} \
(wave_normal={wave_normal:?}): |residual|={} >= {RESIDUAL_BOUND}",
residual.length()
);
if let Some(p) = *prev {
let cos = p.dot(d_hat).clamp(-1.0, 1.0).abs();
assert!(
cos > 0.98,
"{name}: eigenvector direction should vary continuously \
(up to sign) between adjacent sweep samples at theta={theta}, \
got |dot|={cos}"
);
}
*prev = Some(d_hat);
}
}
}
}
#[test]
fn discriminant_reformulation_matches_naive_b2_minus_4c() {
fn naive_disc_sq(a: f64, bb: f64, cc: f64, p: f64, q: f64, r: f64) -> f64 {
let big_b = cc.mul_add(p + q, bb.mul_add(p + r, a * (q + r)));
let big_c = (cc * p).mul_add(q, (bb * p).mul_add(r, a * q * r));
4.0f64.mul_add(-big_c, big_b * big_b)
}
fn reformulated_disc_sq(a: f64, bb: f64, cc: f64, p: f64, q: f64, r: f64) -> f64 {
let xdiff = p - q;
let ydiff = r - p;
let a_plus_c = a + cc;
let a_plus_bb = a + bb;
let two_a_minus_bc = 2.0 * bb.mul_add(-cc, a);
(a_plus_bb * a_plus_bb).mul_add(
ydiff * ydiff,
(two_a_minus_bc * xdiff).mul_add(ydiff, (a_plus_c * a_plus_c) * (xdiff * xdiff)),
)
}
let cosine_triples: [(f64, f64, f64); 6] = [
(0.046_512, 0.011_628, 0.941_860),
(1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0),
(0.7, 0.2, 0.1),
(0.02, 0.9, 0.08),
(0.5, 0.0, 0.5),
(0.9999, 0.00005, 0.00005),
];
let index_triples = [
(
1.0 / 1.60_f64.powi(2),
1.0 / 1.65_f64.powi(2),
1.0 / 1.75_f64.powi(2),
),
(
1.0 / 1.740_778_4_f64.powi(2),
1.0 / 1.742_729_4_f64.powi(2),
1.0 / 1.748_378_4_f64.powi(2),
),
(
1.0 / 1.4_f64.powi(2),
1.0 / 2.4_f64.powi(2),
1.0 / 2.49_f64.powi(2),
),
];
for &(a, bb, cc) in &cosine_triples {
assert!(
(a + bb + cc - 1.0).abs() < 1e-9,
"test premise: direction cosines squared must sum to 1"
);
for &(p, q, r) in &index_triples {
let naive = naive_disc_sq(a, bb, cc, p, q, r);
let reformulated = reformulated_disc_sq(a, bb, cc, p, q, r);
let scale = naive.abs().max(reformulated.abs()).max(1e-12);
assert!(
(naive - reformulated).abs() / scale < 1e-9,
"discriminant reformulation disagrees with naive B^2-4C: \
cosines=({a},{bb},{cc}) indices=({p},{q},{r}) naive={naive:e} \
reformulated={reformulated:e}"
);
}
}
}
#[test]
fn poynting_direction_has_no_walk_off_when_e_is_transverse() {
let k_hat = Vec3::new(0.3, 0.6, 0.74).normalize();
let e_hat = k_hat.cross(Vec3::Y).normalize(); let s = poynting_direction(k_hat, e_hat);
assert!(
(s - k_hat).length() < 1e-4,
"S should equal k_hat when E is exactly transverse, got {s:?}"
);
}
#[test]
fn poynting_direction_is_finite_unit_vector_with_walk_off() {
let k_hat = Vec3::new(0.0, 0.0, 1.0);
let e_hat = Vec3::new(0.1, 0.0, 0.99).normalize(); let s = poynting_direction(k_hat, e_hat);
assert!(s.is_finite(), "S must be finite, got {s:?}");
assert!(
(s.length() - 1.0).abs() < 1e-4,
"S must be unit length, got {}",
s.length()
);
assert!(
s.dot(e_hat).abs() < 1e-3,
"S must be perpendicular to E, got dot={}",
s.dot(e_hat)
);
}
#[test]
fn absorption_frame_is_bit_identical_to_index_frame() {
let gamma_axes = [
Vec3::Y,
Vec3::X,
Vec3::Z,
Vec3::new(0.35, 0.82, -0.45).normalize(),
Vec3::new(-0.6, 0.1, 0.79).normalize(),
];
for gamma_axis in gamma_axes {
let tensor = AbsorptionTensor3::biaxial(1.0, 2.0, 3.0, gamma_axis);
let indicatrix = BiaxialIndicatrix::from_gamma_axis(1.6, 1.65, 1.75, gamma_axis);
let pairs = [
("x_axis", tensor.axes.x_axis, indicatrix.axes.x_axis),
("y_axis", tensor.axes.y_axis, indicatrix.axes.y_axis),
("z_axis", tensor.axes.z_axis, indicatrix.axes.z_axis),
];
for (label, t_col, i_col) in pairs {
assert_eq!(
t_col.x.to_bits(),
i_col.x.to_bits(),
"gamma_axis={gamma_axis:?} {label}: x component bits differ (tensor={t_col:?}, indicatrix={i_col:?})"
);
assert_eq!(
t_col.y.to_bits(),
i_col.y.to_bits(),
"gamma_axis={gamma_axis:?} {label}: y component bits differ (tensor={t_col:?}, indicatrix={i_col:?})"
);
assert_eq!(
t_col.z.to_bits(),
i_col.z.to_bits(),
"gamma_axis={gamma_axis:?} {label}: z component bits differ (tensor={t_col:?}, indicatrix={i_col:?})"
);
}
}
}
#[test]
fn absorption_frame_matches_worked_example_for_plus_y_gamma_axis() {
let tensor = AbsorptionTensor3::biaxial(1.0, 2.0, 3.0, Vec3::Y);
assert!(
(tensor.axes.x_axis - Vec3::X).length() < 1e-6,
"alpha axis should be +X, got {:?}",
tensor.axes.x_axis
);
assert!(
(tensor.axes.y_axis - (-Vec3::Z)).length() < 1e-6,
"beta axis should be -Z, got {:?}",
tensor.axes.y_axis
);
assert!(
(tensor.axes.z_axis - Vec3::Y).length() < 1e-6,
"gamma axis should be +Y, got {:?}",
tensor.axes.z_axis
);
}
#[test]
fn biaxial_tensor_quadratic_form_matches_each_principal_coefficient_on_axis() {
let gamma_axis = Vec3::new(0.2, 0.6, 0.77).normalize();
let (alpha, beta, gamma) = (1.5f32, 4.0f32, 9.0f32);
let tensor = AbsorptionTensor3::biaxial(alpha, beta, gamma, gamma_axis);
let alpha_axis = tensor.axes.x_axis;
let beta_axis = tensor.axes.y_axis;
assert!(
(tensor.quadratic_form(alpha_axis) - alpha).abs() < 1e-4,
"E along the alpha axis should give alpha ({alpha}), got {}",
tensor.quadratic_form(alpha_axis)
);
assert!(
(tensor.quadratic_form(beta_axis) - beta).abs() < 1e-4,
"E along the beta axis should give beta ({beta}), got {}",
tensor.quadratic_form(beta_axis)
);
assert!(
(tensor.quadratic_form(gamma_axis) - gamma).abs() < 1e-4,
"E along the gamma axis should give gamma ({gamma}), got {}",
tensor.quadratic_form(gamma_axis)
);
}
#[test]
fn biaxial_tensor_alpha_beta_degenerate_matches_uniaxial_bit_exact() {
let axis = Vec3::new(0.35, 0.82, -0.45).normalize();
let (a, b) = (2.5f32, 7.25f32);
let biaxial = AbsorptionTensor3::biaxial(a, a, b, axis);
let uniaxial = AbsorptionTensor3::uniaxial(a, b, axis);
assert_eq!(biaxial.alpha.x.to_bits(), uniaxial.alpha.x.to_bits());
assert_eq!(biaxial.alpha.y.to_bits(), uniaxial.alpha.y.to_bits());
assert_eq!(biaxial.alpha.z.to_bits(), uniaxial.alpha.z.to_bits());
let axis_pairs = [
("x_axis", biaxial.axes.x_axis, uniaxial.axes.x_axis),
("y_axis", biaxial.axes.y_axis, uniaxial.axes.y_axis),
("z_axis", biaxial.axes.z_axis, uniaxial.axes.z_axis),
];
for (label, b_col, u_col) in axis_pairs {
assert_eq!(b_col.x.to_bits(), u_col.x.to_bits(), "{label} x");
assert_eq!(b_col.y.to_bits(), u_col.y.to_bits(), "{label} y");
assert_eq!(b_col.z.to_bits(), u_col.z.to_bits(), "{label} z");
}
for d in [
Vec3::X,
Vec3::Y,
Vec3::Z,
Vec3::new(0.4, -0.5, 0.77).normalize(),
] {
assert_eq!(
biaxial.quadratic_form(d).to_bits(),
uniaxial.quadratic_form(d).to_bits(),
"quadratic_form must agree bit-exactly at d={d:?}"
);
}
}
}
#[cfg(test)]
mod walk_off_symmetry_tests {
use super::*;
#[test]
fn extraordinary_poynting_dir_is_axis_direction_symmetric() {
let n_o = 1.658f32;
let n_e = 1.486f32;
let c_axis = Vec3::new(0.2, 0.9, -0.35).normalize(); let perp = {
let p = c_axis.cross(Vec3::X);
if p.length_squared() < 1e-6 {
c_axis.cross(Vec3::Y).normalize()
} else {
p.normalize()
}
};
for i in 0..37 {
let theta = (i as f32) / 36.0 * std::f32::consts::PI; let wave_normal = (theta.cos() * c_axis + theta.sin() * perp).normalize();
let s_pos =
BirefringenceParams::extraordinary_poynting_dir(wave_normal, c_axis, n_o, n_e);
let s_neg =
BirefringenceParams::extraordinary_poynting_dir(wave_normal, -c_axis, n_o, n_e);
assert!(
(s_pos - s_neg).length() < 1e-4,
"S(c) must equal S(-c) at theta={} deg (got s_pos={s_pos:?}, s_neg={s_neg:?})",
theta.to_degrees()
);
}
}
#[test]
fn extraordinary_poynting_dir_matches_analytic_walk_off_both_hemispheres() {
let n_o = 1.658f32;
let n_e = 1.486f32;
let c_axis = Vec3::Y;
let theta = 45.0f32.to_radians();
let wave_normal = Vec3::new(theta.sin(), theta.cos(), 0.0);
for c in [c_axis, -c_axis] {
let s = BirefringenceParams::extraordinary_poynting_dir(wave_normal, c, n_o, n_e);
let angle_from_axis = s.dot(c_axis).clamp(-1.0, 1.0).abs().acos().to_degrees();
assert!(
(angle_from_axis - 51.23).abs() < 0.05,
"expected walk-off to land at 51.23 deg from the axis for c={c:?}, got {angle_from_axis}"
);
}
}
}