use super::{
NUM_CHANNELS,
refraction::{RayMaterialContext, RayWavelengthCache},
};
use crate::optics::{
absorption::AbsorptionBand,
birefringence::{BirefringenceParams, effective_pleochroic_alpha},
polarization::{MuellerMatrix, StokesVector, electric_field_direction},
};
use glam::Vec3;
#[must_use]
pub fn spectral_absorption(bands: &[AbsorptionBand], lambda_nm: f32) -> f32 {
bands.iter().map(|band| band.evaluate(lambda_nm)).sum()
}
#[inline]
pub(crate) fn signed_frame_rotation_psi(
prev_normal: Vec3,
current_normal: Vec3,
axis: Vec3,
) -> f32 {
let cos_psi = prev_normal.dot(current_normal).clamp(-1.0, 1.0);
let sin_psi = prev_normal
.cross(current_normal)
.dot(axis.normalize_or_zero());
sin_psi.atan2(cos_psi)
}
pub(super) fn rotate_stokes_to_plane_of_incidence(
k_hat: Vec3,
normal: Vec3,
prev_plane_normal: Option<Vec3>,
stokes: &mut [StokesVector; NUM_CHANNELS],
) -> Vec3 {
let current_plane_normal = k_hat.cross(normal).normalize_or_zero();
if let Some(prev_normal) = prev_plane_normal
&& current_plane_normal.length_squared() > 1e-6
&& prev_normal.length_squared() > 1e-6
{
let psi = signed_frame_rotation_psi(prev_normal, current_plane_normal, k_hat);
let rot_matrix = MuellerMatrix::frame_rotation(psi);
for s in stokes.iter_mut() {
*s = s.apply_matrix(&rot_matrix);
}
}
current_plane_normal
}
pub(super) fn apply_absorption(
ctx: &RayMaterialContext,
cache: &RayWavelengthCache,
current_plane_normal: Vec3,
k_hat: Vec3,
path_len: f32,
stokes: &mut [StokesVector; NUM_CHANNELS],
) {
let alphas = channel_absorption_alphas(ctx, cache, current_plane_normal, k_hat, stokes);
let scaled_path_len = path_len * ctx.material.absorption_path_scale;
let mut args = [0f32; NUM_CHANNELS];
for (a, alpha) in args.iter_mut().zip(&alphas) {
*a = -alpha * scaled_path_len;
}
let trans = crate::simd::exp_f32x8(args);
for (k, s) in stokes.iter_mut().enumerate() {
*s = s.scale(trans[k]);
}
}
pub(crate) fn channel_absorption_alphas(
ctx: &RayMaterialContext,
cache: &RayWavelengthCache,
current_plane_normal: Vec3,
k_hat: Vec3,
stokes: &[StokesVector; NUM_CHANNELS],
) -> [f32; NUM_CHANNELS] {
let c_axis = ctx.c_axis;
let indicatrix = cache.hero_indicatrix;
let (eigen_a, eigen_b) = indicatrix.map_or_else(
|| {
(
BirefringenceParams::ordinary_eigen_polarization(k_hat, c_axis),
BirefringenceParams::extraordinary_eigen_polarization(k_hat, c_axis),
)
},
|ind| ind.eigen_polarizations(k_hat),
);
let mut alphas = [0.0f32; NUM_CHANNELS];
for (k, alpha_slot) in alphas.iter_mut().enumerate() {
let e_hat = electric_field_direction(&stokes[k], current_plane_normal, k_hat);
*alpha_slot = effective_pleochroic_alpha(
&cache.tensor_ch[k],
e_hat,
eigen_a,
eigen_b,
stokes[k].degree_of_polarization(),
);
}
alphas
}
#[cfg(test)]
mod absorption_path_scale_tests {
use super::*;
use crate::optics::{
materials::GemMaterial, polarization::StokesVector,
raytracer::refraction::build_ray_wavelength_cache,
};
#[test]
fn scaled_slab_attenuates_bit_identically_to_doubled_thickness_slab() {
let ruby_like =
GemMaterial::new_custom("ruby-like slab probe", 1.76, 0.0, 0.0, [0.5, 1.0, 1.5]);
assert_eq!(
ruby_like.crystal_system,
crate::optics::materials::CrystalSystem::Cubic,
"test premise: birefringence_delta=0.0 must yield an isotropic material \
(no birefringence machinery to complicate the comparison)"
);
let d = 0.37f32;
let material_scale1_thickness2d = ruby_like.clone(); let material_scale2_thicknessd = ruby_like.with_absorption_path_scale(2.0);
let lambdas: [f32; NUM_CHANNELS] = [420.0, 460.0, 500.0, 540.0, 580.0, 620.0, 660.0, 700.0];
let ray_dir = Vec3::new(0.0, -1.0, 0.0);
let current_plane_normal = Vec3::new(1.0, 0.0, 0.0);
let stokes_in = [StokesVector::unpolarized(1.0); NUM_CHANNELS];
let ctx_a = RayMaterialContext {
material: &material_scale1_thickness2d,
lambdas,
hero_idx: 0,
c_axis: Vec3::Y,
is_anisotropic: false,
enable_internal_mode_coupling: true,
};
let ctx_b = RayMaterialContext {
material: &material_scale2_thicknessd,
lambdas,
hero_idx: 0,
c_axis: Vec3::Y,
is_anisotropic: false,
enable_internal_mode_coupling: true,
};
let cache_a = build_ray_wavelength_cache(&ctx_a);
let cache_b = build_ray_wavelength_cache(&ctx_b);
let mut stokes_a = stokes_in;
let mut stokes_b = stokes_in;
apply_absorption(
&ctx_a,
&cache_a,
current_plane_normal,
ray_dir,
2.0 * d, &mut stokes_a,
);
apply_absorption(
&ctx_b,
&cache_b,
current_plane_normal,
ray_dir,
d, &mut stokes_b,
);
for k in 0..NUM_CHANNELS {
let a = (stokes_a[k].i, stokes_a[k].q, stokes_a[k].u, stokes_a[k].v);
let b = (stokes_b[k].i, stokes_b[k].q, stokes_b[k].u, stokes_b[k].v);
assert_eq!(
a, b,
"channel {k}: scale=1.0/thickness=2d must attenuate bit-identically to \
scale=2.0/thickness=d (got {a:?} vs {b:?})"
);
}
}
}
#[cfg(test)]
mod frame_rotation_sign_tests {
use super::*;
#[test]
fn same_plane_of_incidence_gives_zero_psi() {
let prev_normal = Vec3::new(0.0, 0.0, 1.0);
let current_normal = Vec3::new(0.0, 0.0, 1.0);
let axis = Vec3::new(1.0, 0.0, 0.0);
let psi = signed_frame_rotation_psi(prev_normal, current_normal, axis);
assert!(
psi.abs() < 1e-5,
"psi should be ~0 for identical plane-of-incidence normals (got {psi})"
);
}
#[test]
fn reversing_plane_normal_order_flips_psi_sign() {
let normal_a = Vec3::new(1.0, 0.0, 0.0);
let normal_b = Vec3::new(0.0, 1.0, 0.0).normalize();
let axis = Vec3::new(0.0, 0.0, 1.0);
let psi_forward = signed_frame_rotation_psi(normal_a, normal_b, axis);
let psi_reversed = signed_frame_rotation_psi(normal_b, normal_a, axis);
assert!(
psi_forward.abs() > 0.1,
"psi_forward should be a genuine nonzero rotation (got {psi_forward})"
);
assert!(
(psi_forward + psi_reversed).abs() < 1e-4,
"reversing the normal order should flip the sign of psi (forward={psi_forward}, reversed={psi_reversed})"
);
}
#[test]
fn psi_magnitude_matches_geometric_angle_between_normals() {
let normal_a = Vec3::new(1.0, 0.0, 0.0);
let normal_b = Vec3::new(0.0, 1.0, 0.0);
let axis = Vec3::new(0.0, 0.0, 1.0);
let psi = signed_frame_rotation_psi(normal_a, normal_b, axis);
assert!(
(psi.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-4,
"psi magnitude should match the 90 degree angle between the normals (got {})",
psi.to_degrees()
);
}
}