#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum BandShape {
#[default]
GaussianWavelength,
GaussianEnergy,
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AbsorptionBand {
pub center_nm: f32,
pub width_nm: f32,
pub peak: f32,
pub shape: BandShape,
}
impl AbsorptionBand {
#[must_use]
pub const fn new(center_nm: f32, width_nm: f32, peak: f32) -> Self {
Self {
center_nm,
width_nm,
peak,
shape: BandShape::GaussianWavelength,
}
}
#[must_use]
pub const fn energy(center_nm: f32, fwhm_cm_inv: f32, peak: f32) -> Self {
const FWHM_TO_SIGMA: f32 = 2.354_82; Self {
center_nm,
width_nm: fwhm_cm_inv / FWHM_TO_SIGMA,
peak,
shape: BandShape::GaussianEnergy,
}
}
#[must_use]
pub fn evaluate(&self, lambda_nm: f32) -> f32 {
match self.shape {
BandShape::GaussianWavelength => {
let t = (lambda_nm - self.center_nm) / self.width_nm;
self.peak * (-0.5 * t * t).exp()
}
BandShape::GaussianEnergy => {
let delta_nm = self.center_nm - lambda_nm;
let nu_diff = 1.0e7 * delta_nm / (lambda_nm * self.center_nm);
let t = nu_diff / self.width_nm;
self.peak * (-0.5 * t * t).exp()
}
}
}
}
#[cfg(test)]
mod band_shape_tests {
use super::*;
#[test]
fn new_defaults_to_gaussian_wavelength() {
let band = AbsorptionBand::new(550.0, 20.0, 2.0);
assert_eq!(band.shape, BandShape::GaussianWavelength);
}
#[test]
fn energy_domain_band_is_symmetric_in_wavenumber_not_wavelength() {
let center_nm = 500.0f32;
let band = AbsorptionBand::energy(center_nm, 400.0, 1.0);
let nu0 = 1.0e7 / center_nm;
let delta_nu = 150.0f32; let lambda_plus = 1.0e7 / (nu0 + delta_nu);
let lambda_minus = 1.0e7 / (nu0 - delta_nu);
let response_plus = band.evaluate(lambda_plus);
let response_minus = band.evaluate(lambda_minus);
assert!(
(response_plus - response_minus).abs() < 5e-6,
"wavenumber-symmetric points must give near-equal response (got {response_plus} vs \
{response_minus})"
);
let actual_asymmetry_nm =
((lambda_plus - center_nm).abs() - (center_nm - lambda_minus).abs()).abs();
let leading_order_asymmetry_nm = 2.0 * delta_nu * delta_nu * 1.0e7 / (nu0 * nu0 * nu0);
assert!(
(actual_asymmetry_nm - leading_order_asymmetry_nm).abs()
< 0.05 * leading_order_asymmetry_nm,
"wavelength asymmetry ({actual_asymmetry_nm} nm) must match the leading-order \
estimate derived from this band's own center_nm/delta_nu \
({leading_order_asymmetry_nm} nm) to within 5%"
);
assert!(
actual_asymmetry_nm < 0.001 * center_nm,
"test premise: the wavelength asymmetry ({actual_asymmetry_nm} nm) must stay a \
tiny fraction of center_nm ({center_nm} nm) for the near-center approximation \
above to hold"
);
}
#[test]
fn both_shapes_agree_at_band_center() {
let wavelength_band = AbsorptionBand::new(620.0, 30.0, 1.5);
let energy_band = AbsorptionBand::energy(620.0, 200.0, 1.5);
assert_eq!(wavelength_band.evaluate(620.0), 1.5);
assert!(
(energy_band.evaluate(620.0) - 1.5).abs() < 1e-6,
"energy-domain band must also read exactly `peak` at its own center"
);
}
#[test]
fn converted_energy_width_agrees_with_wavelength_width_near_center() {
let center_nm = 550.0f32;
let sigma_lambda = 20.0f32;
let peak = 2.0f32;
let wavelength_band = AbsorptionBand::new(center_nm, sigma_lambda, peak);
let sigma_nu = sigma_lambda * 1.0e7 / (center_nm * center_nm);
let energy_band = AbsorptionBand {
center_nm,
width_nm: sigma_nu,
peak,
shape: BandShape::GaussianEnergy,
};
let probe_nm = center_nm + 2.0;
let expected = wavelength_band.evaluate(probe_nm);
let actual = energy_band.evaluate(probe_nm);
assert!(
(actual - expected).abs() < 0.01 * expected,
"converted energy-domain width must agree with the wavelength-domain band to \
within 1% near center (expected {expected}, got {actual})"
);
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AbsorptionTensor {
pub o_ray: Vec<AbsorptionBand>,
pub e_ray: Vec<AbsorptionBand>,
pub beta_ray: Option<Vec<AbsorptionBand>>,
pub is_pleochroic: bool,
}
impl AbsorptionTensor {
#[must_use]
pub fn isotropic(bands: Vec<AbsorptionBand>) -> Self {
Self {
o_ray: bands.clone(),
e_ray: bands,
beta_ray: None,
is_pleochroic: false,
}
}
#[must_use]
pub const fn uniaxial(o: Vec<AbsorptionBand>, e: Vec<AbsorptionBand>) -> Self {
Self {
o_ray: o,
e_ray: e,
beta_ray: None,
is_pleochroic: true,
}
}
#[must_use]
pub const fn biaxial(
alpha: Vec<AbsorptionBand>,
beta: Vec<AbsorptionBand>,
gamma: Vec<AbsorptionBand>,
) -> Self {
Self {
o_ray: alpha,
e_ray: gamma,
beta_ray: Some(beta),
is_pleochroic: true,
}
}
}
#[must_use]
pub fn legacy_rgb_bands(rgb: [f32; 3]) -> Vec<AbsorptionBand> {
vec![
AbsorptionBand::new(620.0, 55.0, rgb[0]),
AbsorptionBand::new(540.0, 45.0, rgb[1]),
AbsorptionBand::new(450.0, 45.0, rgb[2]),
]
}
pub const BODY_COLOR_BASIS_NM: [(f32, f32); 7] = [
(420.0, 55.0),
(460.0, 45.0),
(500.0, 45.0),
(540.0, 45.0),
(580.0, 45.0),
(620.0, 45.0),
(660.0, 55.0),
];
#[must_use]
pub fn body_color_bands(amplitudes_per_mm: [f32; 7]) -> Vec<AbsorptionBand> {
BODY_COLOR_BASIS_NM
.iter()
.zip(amplitudes_per_mm)
.filter(|(_, a)| a.is_finite() && *a > 0.0)
.map(|(&(centre, width), a)| {
let sigma_nu = width * 1.0e7 / (centre * centre);
AbsorptionBand::energy(centre, sigma_nu * 2.35482, a)
})
.collect()
}
#[cfg(test)]
mod body_color_basis_tests {
use super::*;
use crate::color::body_color::{Illuminant, body_color};
#[expect(
clippy::cast_possible_truncation,
reason = "AbsorptionBand::evaluate takes f32 nm; wavelengths are 380..=780"
)]
fn alpha(bands: &[AbsorptionBand]) -> impl Fn(f64) -> f64 + '_ {
move |l| f64::from(bands.iter().map(|b| b.evaluate(l as f32)).sum::<f32>())
}
#[test]
fn all_zero_amplitudes_make_no_bands_and_render_like_an_empty_set() {
let bands = body_color_bands([0.0; 7]);
assert_eq!(bands, Vec::<AbsorptionBand>::new());
let a = body_color(alpha(&bands), 5.0, Illuminant::D65);
let none: Vec<AbsorptionBand> = Vec::new();
let b = body_color(alpha(&none), 5.0, Illuminant::D65);
assert_eq!(a, b);
assert_eq!(a.srgb, [255, 255, 255]);
}
#[test]
fn only_positive_finite_amplitudes_become_bands() {
let bands = body_color_bands([0.0, 0.5, -1.0, f32::NAN, 0.0, 2.0, 0.0]);
assert_eq!(bands.len(), 2);
assert_eq!(bands[0].center_nm, 460.0);
assert_eq!(bands[1].center_nm, 620.0);
assert_eq!(bands[1].peak, 2.0);
}
#[test]
fn basis_fits_the_gpu_band_budget() {
assert!(BODY_COLOR_BASIS_NM.len() < 8);
}
}