use glam::{Mat4, Vec3, Vec4};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StokesVector {
pub i: f32,
pub q: f32,
pub u: f32,
pub v: f32,
}
impl StokesVector {
#[must_use]
pub const fn new(i: f32, q: f32, u: f32, v: f32) -> Self {
Self { i, q, u, v }
}
#[must_use]
pub const fn unpolarized(intensity: f32) -> Self {
Self::new(intensity, 0.0, 0.0, 0.0)
}
#[must_use]
pub const fn to_vec4(self) -> Vec4 {
Vec4::new(self.i, self.q, self.u, self.v)
}
#[must_use]
#[cfg_attr(
target_arch = "wasm32",
allow(
clippy::missing_const_for_fn,
reason = "const-eligible only on wasm32's scalar glam backend; see above"
)
)]
pub fn from_vec4(v: Vec4) -> Self {
Self::new(v.x, v.y, v.z, v.w)
}
#[must_use]
pub const fn intensity(&self) -> f32 {
self.i.max(0.0)
}
#[must_use]
pub fn degree_of_polarization(&self) -> f32 {
if self.i <= 1e-7 {
return 0.0;
}
(self
.v
.mul_add(self.v, self.u.mul_add(self.u, self.q * self.q))
.sqrt()
/ self.i)
.clamp(0.0, 1.0)
}
#[must_use]
pub fn polarization_azimuth(&self) -> f32 {
0.5 * self.u.atan2(self.q)
}
#[must_use]
pub fn apply_matrix(&self, m: &Mat4) -> Self {
let v = m.mul_vec4(self.to_vec4());
Self::from_vec4(v)
}
#[must_use]
pub fn scale(&self, s: f32) -> Self {
Self::new(self.i * s, self.q * s, self.u * s, self.v * s)
}
}
pub struct MuellerMatrix;
impl MuellerMatrix {
#[must_use]
pub fn frame_rotation(psi: f32) -> Mat4 {
let c2 = (2.0 * psi).cos();
let s2 = (2.0 * psi).sin();
Mat4::from_cols_array(&[
1.0, 0.0, 0.0, 0.0, 0.0, c2, -s2, 0.0, 0.0, s2, c2, 0.0, 0.0, 0.0, 0.0, 1.0,
])
}
#[must_use]
pub fn fresnel_reflection(r_s: f32, r_p: f32) -> Mat4 {
let rs2 = r_s * r_s;
let rp2 = r_p * r_p;
let a = f32::midpoint(rs2, rp2);
let b = 0.5 * (rs2 - rp2);
let c = r_s * r_p;
Mat4::from_cols_array(&[
a, b, 0.0, 0.0, b, a, 0.0, 0.0, 0.0, 0.0, c, 0.0, 0.0, 0.0, 0.0, c,
])
}
#[must_use]
pub fn fresnel_transmission(
n1: f32,
n2: f32,
cos_i: f32,
cos_t: f32,
t_s: f32,
t_p: f32,
) -> Mat4 {
let factor = (n2 * cos_t) / ((n1 * cos_i).max(1e-6));
let ts2 = t_s * t_s * factor;
let tp2 = t_p * t_p * factor;
let a = f32::midpoint(ts2, tp2);
let b = 0.5 * (ts2 - tp2);
let c = t_s * t_p * factor;
Mat4::from_cols_array(&[
a, b, 0.0, 0.0, b, a, 0.0, 0.0, 0.0, 0.0, c, 0.0, 0.0, 0.0, 0.0, c,
])
}
#[must_use]
pub fn tir_retardation(delta: f32) -> Mat4 {
let cos_d = delta.cos();
let sin_d = delta.sin();
Mat4::from_cols_array(&[
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, cos_d, -sin_d, 0.0, 0.0, sin_d, cos_d,
])
}
}
#[must_use]
pub fn electric_field_direction(
stokes: &StokesVector,
s_axis: Vec3,
propagation_dir: Vec3,
) -> Vec3 {
let k_hat = propagation_dir.normalize_or_zero();
let s_raw = s_axis - k_hat * k_hat.dot(s_axis);
let s_hat = if s_raw.length_squared() > 1e-8 {
s_raw.normalize()
} else {
arbitrary_perpendicular(k_hat)
};
let p_hat = k_hat.cross(s_hat);
let psi = stokes.polarization_azimuth();
let e = psi.cos() * s_hat + psi.sin() * p_hat;
if e.length_squared() > 1e-8 {
e.normalize()
} else {
s_hat
}
}
fn arbitrary_perpendicular(n: Vec3) -> Vec3 {
let a = if n.x.abs() > 0.9 { Vec3::Y } else { Vec3::X };
(a - n * n.dot(a)).normalize_or_zero()
}
#[cfg(test)]
mod electric_field_direction_tests {
use super::*;
#[test]
fn zero_azimuth_gives_s_axis() {
let stokes = StokesVector::new(1.0, 1.0, 0.0, 0.0);
let s_axis = Vec3::new(1.0, 0.0, 0.0);
let k_hat = Vec3::new(0.0, 0.0, 1.0);
let e = electric_field_direction(&stokes, s_axis, k_hat);
assert!(
(e.dot(s_axis).abs() - 1.0).abs() < 1e-5,
"expected e parallel to s_axis, got {e:?}"
);
}
#[test]
fn forty_five_degree_azimuth_gives_diagonal() {
let stokes = StokesVector::new(1.0, 0.0, 1.0, 0.0);
let s_axis = Vec3::new(1.0, 0.0, 0.0);
let k_hat = Vec3::new(0.0, 0.0, 1.0);
let p_axis = k_hat.cross(s_axis);
let diagonal = (s_axis + p_axis).normalize();
let e = electric_field_direction(&stokes, s_axis, k_hat);
assert!(
(e.dot(diagonal).abs() - 1.0).abs() < 1e-5,
"expected e parallel to (s_axis + p_axis) diagonal, got {e:?}"
);
}
#[test]
fn ninety_degree_azimuth_gives_p_axis() {
let stokes = StokesVector::new(1.0, -1.0, 0.0, 0.0);
let s_axis = Vec3::new(1.0, 0.0, 0.0);
let k_hat = Vec3::new(0.0, 0.0, 1.0);
let p_axis = k_hat.cross(s_axis);
let e = electric_field_direction(&stokes, s_axis, k_hat);
assert!(
(e.dot(p_axis).abs() - 1.0).abs() < 1e-5,
"expected e parallel to p_axis, got {e:?}"
);
}
#[test]
fn field_direction_is_always_perpendicular_to_propagation() {
let k_hat = Vec3::new(0.4, -0.5, 0.767).normalize();
let s_axis = k_hat.cross(Vec3::Y).normalize();
for i in 0..16 {
let psi = (i as f32) * std::f32::consts::PI / 8.0;
let stokes = StokesVector::new(1.0, (2.0 * psi).cos(), (2.0 * psi).sin(), 0.0);
let e = electric_field_direction(&stokes, s_axis, k_hat);
assert!(
e.dot(k_hat).abs() < 1e-4,
"e must stay perpendicular to k_hat (psi={psi}, got e.k={})",
e.dot(k_hat)
);
}
}
#[test]
fn degenerate_s_axis_falls_back_gracefully() {
let stokes = StokesVector::new(1.0, 0.3, 0.4, 0.0);
let k_hat = Vec3::new(0.0, 1.0, 0.0);
let e = electric_field_direction(&stokes, Vec3::ZERO, k_hat);
assert!(
e.is_finite(),
"result must be finite for degenerate s_axis, got {e:?}"
);
assert!(
(e.length() - 1.0).abs() < 1e-4,
"result must be a unit vector, got {e:?}"
);
assert!(
e.dot(k_hat).abs() < 1e-4,
"result must stay perpendicular to k_hat, got {e:?}"
);
}
}
#[cfg(test)]
mod frame_rotation_tests {
use super::*;
#[test]
fn frame_rotation_round_trip_recovers_the_original_field_direction() {
let k_hat = Vec3::new(0.3, 0.5, 0.81).normalize();
let s_axis0 = arbitrary_perpendicular(k_hat);
let p_axis0 = k_hat.cross(s_axis0);
for psi_deg in [-120.0f32, -45.0, -10.0, 10.0, 30.0, 60.0, 90.0, 150.0] {
let psi = psi_deg.to_radians();
let s_axis1 = (psi.cos() * s_axis0 + psi.sin() * p_axis0).normalize();
let stokes0 = StokesVector::new(1.0, 1.0, 0.0, 0.0);
let stokes1 = stokes0.apply_matrix(&MuellerMatrix::frame_rotation(psi));
let e_reconstructed = electric_field_direction(&stokes1, s_axis1, k_hat);
assert!(
e_reconstructed.dot(s_axis0).abs() > 0.999,
"psi={psi_deg} deg: round trip should recover the original field \
direction s_axis0={s_axis0:?}, got e={e_reconstructed:?} \
(dot={})",
e_reconstructed.dot(s_axis0)
);
}
}
}
#[cfg(test)]
mod fresnel_tir_pleochroism_sign_convention_tests {
use super::*;
use crate::optics::birefringence::AbsorptionTensor3;
#[test]
fn fresnel_reflection_sign_convention_agrees_with_pleochroic_axis_convention() {
let s_axis = Vec3::X;
let k_hat = Vec3::Z;
let p_axis = k_hat.cross(s_axis);
let alpha_o = 0.4f32;
let alpha_e = 6.0f32;
let tensor = AbsorptionTensor3::uniaxial(alpha_o, alpha_e, s_axis);
let r_s = 0.6f32;
let r_p = 0.2f32;
let refl = MuellerMatrix::fresnel_reflection(r_s, r_p);
let s_in = StokesVector::new(1.0, 1.0, 0.0, 0.0);
let s_out = s_in.apply_matrix(&refl);
assert!(
s_out.degree_of_polarization() > 0.999,
"reflecting pure s-polarized light off an isotropic interface must stay \
fully polarized (s/p are its eigenpolarizations), got dop={}",
s_out.degree_of_polarization()
);
let e_s = electric_field_direction(&s_out, s_axis, k_hat);
assert!(
e_s.dot(s_axis).abs() > 0.999,
"reflected s-polarized field must reconstruct along s_axis (up to sign), got e={e_s:?}"
);
let alpha_along_s = tensor.quadratic_form(e_s);
assert!(
(alpha_along_s - alpha_e).abs() < 1e-4,
"s-polarized field direction should read the EXTRAORDINARY coefficient \
(c_axis pinned to s_axis), got {alpha_along_s} vs alpha_e={alpha_e}"
);
let p_in = StokesVector::new(1.0, -1.0, 0.0, 0.0);
let p_out = p_in.apply_matrix(&refl);
assert!(
p_out.degree_of_polarization() > 0.999,
"reflecting pure p-polarized light off an isotropic interface must stay \
fully polarized, got dop={}",
p_out.degree_of_polarization()
);
let e_p = electric_field_direction(&p_out, s_axis, k_hat);
assert!(
e_p.dot(p_axis).abs() > 0.999,
"reflected p-polarized field must reconstruct along p_axis (up to sign), got e={e_p:?}"
);
let alpha_along_p = tensor.quadratic_form(e_p);
assert!(
(alpha_along_p - alpha_o).abs() < 1e-4,
"p-polarized field direction should read the ORDINARY coefficient \
(perpendicular to c_axis == s_axis), got {alpha_along_p} vs alpha_o={alpha_o}"
);
}
#[test]
fn tir_retardation_and_frame_rotation_preserve_dop_and_rotate_q_u_as_r_2psi() {
let stokes0 = StokesVector::new(1.0, 0.0, 1.0, 0.0);
let dop0 = stokes0.degree_of_polarization();
assert!(
(dop0 - 1.0).abs() < 1e-5,
"test premise: 45-degree linear input must be fully polarized, got {dop0}"
);
for psi_deg in [-70.0f32, -20.0, 15.0, 55.0, 100.0] {
let psi = psi_deg.to_radians();
let rotated = stokes0.apply_matrix(&MuellerMatrix::frame_rotation(psi));
let c2 = (2.0 * psi).cos();
let s2 = (2.0 * psi).sin();
let expected_q = c2.mul_add(stokes0.q, s2 * stokes0.u);
let expected_u = (-s2).mul_add(stokes0.q, c2 * stokes0.u);
assert!(
(rotated.q - expected_q).abs() < 1e-4 && (rotated.u - expected_u).abs() < 1e-4,
"psi={psi_deg} deg: frame_rotation should rotate (Q,U) exactly as R(2*psi) \
predicts, got (Q,U)=({}, {}) vs expected ({expected_q}, {expected_u})",
rotated.q,
rotated.u
);
for delta_deg in [-150.0f32, -40.0, 30.0, 120.0] {
let delta = delta_deg.to_radians();
let retarded = rotated.apply_matrix(&MuellerMatrix::tir_retardation(delta));
let dop_final = retarded.degree_of_polarization();
assert!(
(dop_final - dop0).abs() < 1e-4,
"psi={psi_deg} delta={delta_deg}: degree of polarization must be \
preserved by frame_rotation + tir_retardation (both unitary \
rotations of (Q,U,V)), got {dop_final} vs {dop0}"
);
}
}
}
#[test]
fn tir_retardation_fresnel_rhomb_pins_circular_handedness() {
let n = 1.51_f32;
let i = 54.62_f32.to_radians();
let cos_i = i.cos();
let sin_i = i.sin();
let delta = crate::optics::raytracer::refraction::tir_phase_delta(n, cos_i, sin_i);
assert!(
(delta.to_degrees() - 45.0).abs() < 0.5,
"test premise: n=1.51 at i=54.62deg must be the classic ~45-degree-per-bounce \
Fresnel rhomb design, got delta={} deg",
delta.to_degrees()
);
let linear_plus_45 = StokesVector::new(1.0, 0.0, 1.0, 0.0);
let after_one_bounce = linear_plus_45.apply_matrix(&MuellerMatrix::tir_retardation(delta));
let after_rhomb = after_one_bounce.apply_matrix(&MuellerMatrix::tir_retardation(delta));
assert!(
after_rhomb.q.abs() < 1e-3 && after_rhomb.u.abs() < 1e-3,
"one Fresnel rhomb (two TIR bounces, delta={} deg each) applied to +45-degree \
linear light must produce (near-)pure circular polarization (Q,U ~= 0), got \
Q={} U={}",
delta.to_degrees(),
after_rhomb.q,
after_rhomb.u
);
assert!(
(after_rhomb.v - (-1.0)).abs() < 1e-3,
"sign pin: with the crate's retarder convention U' = cos(delta)*U + sin(delta)*V, \
V' = -sin(delta)*U + cos(delta)*V, a +45-degree linear input through two 45-degree \
TIR retardations (one Fresnel rhomb) yields V = -1 (left circular), got V={} \
(Q={}, U={})",
after_rhomb.v,
after_rhomb.q,
after_rhomb.u
);
let after_third_bounce = after_rhomb.apply_matrix(&MuellerMatrix::tir_retardation(delta));
let after_two_rhombs =
after_third_bounce.apply_matrix(&MuellerMatrix::tir_retardation(delta));
assert!(
after_two_rhombs.q.abs() < 1e-3 && after_two_rhombs.v.abs() < 1e-3,
"two Fresnel rhombs (four TIR bounces total, a half-wave retardation) must \
return light to a linear state (Q,V ~= 0), got Q={} V={}",
after_two_rhombs.q,
after_two_rhombs.v
);
assert!(
(after_two_rhombs.u - (-1.0)).abs() < 1e-3,
"two Fresnel rhombs must rotate +45-degree linear light to -45-degree linear \
(U ~= -1), got U={} (Q={}, V={})",
after_two_rhombs.u,
after_two_rhombs.q,
after_two_rhombs.v
);
}
}