use std::collections::BTreeMap;
use glam::Vec3;
use super::{
catalogue::{ChromophoreCatalogue, ChromophoreData, FluorescenceData, HostData, QuenchData},
recipe::{ColorRecipe, ResolveError},
resolve::{
MAX_PEAK_PER_MM, MAX_STRENGTH, Treated, apply_treatments, clamped_amounts,
effective_concentration, energy_params, merged_pair,
},
};
use crate::{
color::{
body_color::xyz_to_lab,
cie1931::cie_1931_cmf,
space::{ColorSpace, TransferFunction},
},
optics::{
absorption::AbsorptionBand,
fluorescence::{
EmissionBand, Fluorescence, FluorescentEmitter, MAX_BANDS, MAX_EMITTERS,
MIN_EMISSION_FWHM_NM,
},
},
};
pub const UV365_NM: f32 = 365.0;
pub const UV395_NM: f32 = 395.0;
const HUE_REFERENCE_Y: f32 = 0.4;
const STRONG_YIELD: f32 = 0.2;
const WEAK_YIELD: f32 = 0.01;
const QUENCHED_BELOW: f32 = 0.5;
#[derive(Debug, Clone, PartialEq)]
pub struct EmitterReport {
pub chromophore: String,
pub quantum_yield_0: f32,
pub quantum_yield: f32,
pub quenchers: Vec<(String, f32)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FluorescenceReport {
pub fluorescence: Fluorescence,
pub emitters: Vec<EmitterReport>,
pub background: Vec<AbsorptionBand>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct UvGlow {
pub photon_yield: f32,
pub xyz: [f32; 3],
pub hue_deg: Option<f32>,
pub srgb: [f32; 3],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlowStrength {
None,
Weak,
Strong,
}
impl UvGlow {
#[must_use]
pub fn strength(&self) -> GlowStrength {
if self.photon_yield >= STRONG_YIELD {
GlowStrength::Strong
} else if self.photon_yield >= WEAK_YIELD {
GlowStrength::Weak
} else {
GlowStrength::None
}
}
#[must_use]
pub fn color_name(&self) -> Option<&'static str> {
self.hue_deg.map(hue_name)
}
}
fn hue_name(h: f32) -> &'static str {
match h {
h if !(55.0..345.0).contains(&h) => "red",
h if h < 80.0 => "orange",
h if h < 105.0 => "yellow",
h if h < 135.0 => "yellow-green",
h if h < 180.0 => "green",
h if h < 230.0 => "cyan",
h if h < 320.0 => "blue",
_ => "magenta",
}
}
impl FluorescenceReport {
#[must_use]
pub fn dominant_quencher(&self) -> Option<&str> {
self.emitters
.iter()
.filter(|e| {
e.quantum_yield_0 > 0.0 && e.quantum_yield / e.quantum_yield_0 < QUENCHED_BELOW
})
.flat_map(|e| e.quenchers.iter())
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(by, _)| by.as_str())
}
#[must_use]
pub fn glow(&self, lamp_nm: f32, path_mm: f32) -> UvGlow {
let background: f32 = self.background.iter().map(|b| b.evaluate(lamp_nm)).sum();
uv_glow(&self.fluorescence, background, lamp_nm, path_mm)
}
}
#[must_use]
pub fn uv_glow(
fluorescence: &Fluorescence,
background_per_mm: f32,
lamp_nm: f32,
path_mm: f32,
) -> UvGlow {
let alphas: Vec<f32> = fluorescence
.emitters()
.iter()
.map(|e| e.excitation_absorption(lamp_nm))
.collect();
let total = alphas.iter().sum::<f32>() + background_per_mm.max(0.0);
let mut none = UvGlow {
photon_yield: 0.0,
xyz: [0.0; 3],
hue_deg: None,
srgb: [0.0; 3],
};
if !(total.is_finite() && total > 0.0) {
return none;
}
let absorbed = 1.0 - (-total * path_mm.max(0.0)).exp();
let mut xyz = Vec3::ZERO;
let mut photon_yield = 0.0f32;
for (e, alpha) in fluorescence.emitters().iter().zip(&alphas) {
let yield_c = absorbed * e.quantum_yield * alpha / total;
if yield_c <= 0.0 {
continue;
}
photon_yield += yield_c;
let mut flux = Vec3::ZERO;
for i in 0..=400 {
let l = 380.0 + i as f32;
let f = e.emission_density(l);
flux += f * Vec3::from(cie_1931_cmf(l));
}
xyz += yield_c * flux;
}
if !(photon_yield.is_finite() && photon_yield > 0.0 && xyz.y > 0.0) {
return none;
}
none.photon_yield = photon_yield;
none.xyz = xyz.to_array();
none.hue_deg = Some(lab_hue_deg(xyz));
none.srgb = swatch_srgb(xyz, photon_yield);
none
}
#[expect(
clippy::cast_possible_truncation,
reason = "bands, yields and hues are stored as f32; the values are wavelengths, fractions and angles in the normal float range"
)]
const fn f32_of(value: f64) -> f32 {
value as f32
}
fn lab_hue_deg(xyz: Vec3) -> f32 {
let s = f64::from(HUE_REFERENCE_Y / xyz.y);
let lab = xyz_to_lab(
[
f64::from(xyz.x) * s,
f64::from(xyz.y) * s,
f64::from(xyz.z) * s,
],
[0.950_47, 1.0, 1.088_83],
);
f32_of(lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0))
}
fn swatch_srgb(xyz: Vec3, photon_yield: f32) -> [f32; 3] {
let linear = ColorSpace::Srgb.xyz_to_linear(xyz).max(Vec3::ZERO);
let peak = linear.max_element();
if peak <= 0.0 {
return [0.0; 3];
}
#[expect(
clippy::suboptimal_flops,
reason = "kept unfused: `mul_add` rounds once and would move the swatch brightness by a last bit against the existing readouts"
)]
let gain = 0.1 + 0.9 * (photon_yield / STRONG_YIELD).min(1.0);
let lit = linear / peak * gain;
[
TransferFunction::Srgb.encode(lit.x),
TransferFunction::Srgb.encode(lit.y),
TransferFunction::Srgb.encode(lit.z),
]
}
#[must_use]
pub fn resolve_fluorescence(
catalogue: &ChromophoreCatalogue,
recipe: &ColorRecipe,
) -> Fluorescence {
fluorescence_report(catalogue, recipe).fluorescence
}
#[must_use]
pub fn fluorescence_report(
catalogue: &ChromophoreCatalogue,
recipe: &ColorRecipe,
) -> FluorescenceReport {
report_impl(catalogue, recipe).unwrap_or_else(|_| FluorescenceReport {
fluorescence: Fluorescence::new(Vec::new()),
emitters: Vec::new(),
background: Vec::new(),
})
}
fn report_impl(
catalogue: &ChromophoreCatalogue,
recipe: &ColorRecipe,
) -> Result<FluorescenceReport, ResolveError> {
let host = catalogue
.host(&recipe.host)
.ok_or_else(|| ResolveError::HostNotFound(recipe.host.clone()))?;
if !recipe.strength.is_finite() {
return Err(ResolveError::NonFinite("strength".to_string()));
}
if let Some(e) = recipe.entries.iter().find(|e| !e.amount.is_finite()) {
return Err(ResolveError::NonFinite(format!("amount of {}", e.id)));
}
let strength = recipe.strength.clamp(0.0, MAX_STRENGTH);
let mut warnings = Vec::new();
let amounts = clamped_amounts(catalogue, host, recipe, &mut warnings);
if !host.end_members.is_empty() {
let sum: f64 = host
.end_members
.iter()
.filter(|m| !m.colorless)
.map(|m| amounts.get(&m.id).copied().unwrap_or(0.0))
.sum();
if sum > 1.0 + 1e-9 {
return Err(ResolveError::EndMemberSumExceeded(sum));
}
}
let treated = apply_treatments(host, recipe, &amounts, &mut warnings);
let mut candidates: Vec<(f32, FluorescentEmitter, EmitterReport)> = Vec::new();
let mut background: Vec<AbsorptionBand> = Vec::new();
for chromo in &host.chromophores {
if !chromo.is_offered() {
continue;
}
let c_eff =
effective_concentration(host, chromo, &amounts, &treated.split, &treated.created);
if !c_eff.is_finite() || c_eff <= 1e-12 {
continue;
}
let bands = excitation_bands(host, chromo, c_eff, strength, &treated);
let Some(entry) = chromo.fluorescence.iter().find(|f| f.is_emitting()) else {
background.extend(bands);
continue;
};
if bands.is_empty() {
continue;
}
let (phi, quenchers) = effective_yield(host, entry, &amounts);
let emitter = FluorescentEmitter {
excitation: limit_bands(bands),
emission: emission_bands(entry),
quantum_yield: phi,
};
let weight = emitter.excitation.iter().map(|b| b.peak).sum::<f32>() * phi;
let report = EmitterReport {
chromophore: chromo.id.clone(),
quantum_yield_0: f32_of(entry.quantum_yield.unwrap_or(0.0)),
quantum_yield: phi,
quenchers,
};
candidates.push((weight, emitter, report));
}
if candidates.len() > MAX_EMITTERS {
candidates.sort_by(|a, b| b.0.total_cmp(&a.0));
candidates.truncate(MAX_EMITTERS);
}
let (emitters, emitter_reports): (Vec<_>, Vec<_>) =
candidates.into_iter().map(|(_, e, r)| (e, r)).unzip();
Ok(FluorescenceReport {
fluorescence: Fluorescence::new(emitters),
emitters: emitter_reports,
background,
})
}
fn excitation_bands(
host: &HostData,
chromo: &ChromophoreData,
c_eff: f64,
strength: f64,
treated: &Treated,
) -> Vec<AbsorptionBand> {
let removals = treated.removals.get(&chromo.id);
let mut out = Vec::new();
for band in chromo.usable_bands() {
let (Some(fwhm), Some(peak_coeff)) = (band.fwhm_cm1, band.peak_coeff) else {
continue;
};
let mut factor = 1.0;
for (bands_nm, f) in removals.into_iter().flatten() {
if bands_nm.is_empty() || bands_nm.iter().any(|c| (c - band.centre_nm).abs() < 1.0) {
factor *= f;
}
}
let peak_cm =
peak_coeff.mul_add(c_eff, band.quadratic_coeff.unwrap_or(0.0) * c_eff * c_eff);
let coeff_mm = (peak_cm * strength * factor / 10.0).min(MAX_PEAK_PER_MM);
let weight = |keys: &[&str], default: f64| -> f64 {
keys.iter()
.find_map(|k| band.pol.get(*k).copied())
.unwrap_or(default)
.max(0.0)
};
let w = match host.optical.as_str() {
"biaxial" => (weight(&["a"], 0.0) + weight(&["b"], 0.0) + weight(&["g"], 0.0)) / 3.0,
"uniaxial" => 2.0f64.mul_add(weight(&["o"], 0.0), weight(&["e"], 0.0)) / 3.0,
_ => weight(&["o"], 1.0),
};
let peak = coeff_mm * w;
if peak.is_finite() && peak > 1e-12 {
out.push(AbsorptionBand::energy(
f32_of(band.centre_nm),
f32_of(fwhm),
f32_of(peak),
));
}
}
out
}
fn limit_bands(mut bands: Vec<AbsorptionBand>) -> Vec<AbsorptionBand> {
bands.sort_by(|a, b| a.center_nm.total_cmp(&b.center_nm));
while bands.len() > MAX_BANDS {
let closeness = |a: &AbsorptionBand, b: &AbsorptionBand| {
let (na, sa, _) = energy_params(a);
let (nb, sb, _) = energy_params(b);
(na - nb).abs() / (sa + sb).max(1e-9)
};
let i = (0..bands.len() - 1)
.min_by(|&i, &j| {
closeness(&bands[i], &bands[i + 1]).total_cmp(&closeness(&bands[j], &bands[j + 1]))
})
.unwrap_or(0);
let merged = merged_pair(&bands[i], &bands[i + 1]);
bands[i] = merged;
bands.remove(i + 1);
}
bands
}
fn emission_bands(entry: &FluorescenceData) -> Vec<EmissionBand> {
let n = entry.emission_nm.len();
let line_weights: Vec<f64> = if entry.emission_weight.len() == n {
entry.emission_weight.clone()
} else {
vec![1.0; n]
};
let total: f64 = line_weights.iter().sum();
let sideband = entry.sideband.map_or(0.0, |s| s.weight);
let line_share = (1.0 - sideband) / if total > 0.0 { total } else { 1.0 };
let mut out: Vec<EmissionBand> = entry
.emission_nm
.iter()
.enumerate()
.map(|(i, ¢re)| {
let fwhm = entry
.emission_fwhm_nm
.get(i)
.or_else(|| entry.emission_fwhm_nm.first())
.copied()
.unwrap_or_else(|| MIN_EMISSION_FWHM_NM.into());
EmissionBand::new(
f32_of(centre),
f32_of(fwhm).max(MIN_EMISSION_FWHM_NM),
f32_of(line_weights[i] * line_share),
)
})
.collect();
if let Some(s) = entry.sideband {
out.push(EmissionBand::new(
f32_of(s.centre_nm),
f32_of(s.fwhm_nm).max(MIN_EMISSION_FWHM_NM),
f32_of(s.weight),
));
}
out
}
fn effective_yield(
host: &HostData,
entry: &FluorescenceData,
amounts: &BTreeMap<String, f64>,
) -> (f32, Vec<(String, f32)>) {
let phi0 = entry.quantum_yield.unwrap_or(0.0).clamp(0.0, 1.0);
let mut phi = phi0;
let mut quenchers = Vec::new();
for q in &entry.quench {
let factor = quench_factor(host, q, amounts);
phi *= factor;
if factor < 1.0 - 1e-6 {
quenchers.push((q.by.clone(), f32_of(factor)));
}
}
(f32_of(phi.clamp(0.0, 1.0)), quenchers)
}
fn quench_factor(host: &HostData, q: &QuenchData, amounts: &BTreeMap<String, f64>) -> f64 {
let amount = amounts.get(&q.by).copied().unwrap_or(0.0);
let (Some(unit), n_law) = (host.element_unit(&q.by), host.n_site_for_unit(&q.unit)) else {
return 1.0;
};
if amount <= 0.0 || n_law <= 0.0 {
return 1.0;
}
let c_q = amount * host.n_site_for_unit(&unit) / n_law;
1.0 / (1.0 + (c_q / q.c_half).powf(q.n))
}
#[cfg(test)]
mod tests {
use super::*;
fn cat() -> &'static ChromophoreCatalogue {
ChromophoreCatalogue::global()
}
fn recipe(host: &str, amounts: &[(&str, f64)]) -> ColorRecipe {
let mut r = ColorRecipe::new(host, cat().data_version);
for (id, a) in amounts {
r.set_amount(id, *a);
}
r
}
fn amount_of_wt_pct(host_id: &str, element: &str, unit: &str, wt_pct: f64) -> f64 {
let host = cat().host(host_id).expect("host");
let own = host.element_unit(element).expect("element unit");
wt_pct * host.n_site_for_unit(unit) / host.n_site_for_unit(&own)
}
#[test]
fn ruby_has_one_red_emitter_with_the_isolated_yield() {
let fl = resolve_fluorescence(cat(), &recipe("corundum", &[("Cr", 0.5)]));
assert_eq!(fl.emitters().len(), 1);
let e = &fl.emitters()[0];
assert!((e.quantum_yield - 0.9).abs() < 1e-3, "{}", e.quantum_yield);
assert!(
e.emission
.iter()
.any(|b| (692.0..=695.0).contains(&b.centre_nm)),
"{:?}",
e.emission
);
assert!(!e.excitation.is_empty() && e.excitation.len() <= MAX_BANDS);
fl.validate().expect("valid");
assert!(e.excitation_absorption(365.0) > 0.01);
}
#[test]
fn iron_quenches_ruby_below_a_tenth_of_its_yield() {
let fe = amount_of_wt_pct("corundum", "Fe", "wt_pct_oxide:FeO", 1.0);
let report = fluorescence_report(cat(), &recipe("corundum", &[("Cr", 0.5), ("Fe", fe)]));
let e = &report.emitters[0];
assert!(
e.quantum_yield < 0.1 * e.quantum_yield_0,
"Phi_eff {} of {}",
e.quantum_yield,
e.quantum_yield_0
);
assert_eq!(report.dominant_quencher(), Some("Fe"));
let clean = fluorescence_report(cat(), &recipe("corundum", &[("Cr", 0.5)]));
assert_eq!(clean.dominant_quencher(), None);
}
#[test]
fn recipes_without_emitters_are_empty() {
for r in [
recipe("corundum", &[("Fe", 1000.0), ("Ti", 100.0)]),
recipe("corundum", &[]),
recipe("unknown host", &[("Cr", 0.5)]),
] {
assert!(resolve_fluorescence(cat(), &r).is_empty(), "{r:?}");
}
}
#[test]
fn every_host_at_maximum_concentration_validates() {
for host in &cat().hosts {
let mut r = ColorRecipe::new(&host.id, cat().data_version);
let members: Vec<_> = host.end_members.iter().filter(|m| !m.colorless).collect();
for id in cat().selectable_elements(&host.id) {
if members.iter().any(|m| m.id == id) {
r.set_amount(&id, 1.0 / members.len() as f64);
} else {
r.set_amount(&id, host.element_conc_max(&id));
}
}
r.treatments = host.treatments.iter().map(|t| t.id.clone()).collect();
let fl = resolve_fluorescence(cat(), &r);
fl.validate().unwrap_or_else(|e| panic!("{}: {e}", host.id));
assert!(fl.emitters().len() <= MAX_EMITTERS);
}
}
#[test]
fn ruby_glows_red_under_the_365_lamp() {
let report = fluorescence_report(cat(), &recipe("corundum", &[("Cr", 0.5)]));
let glow = report.glow(UV365_NM, 5.0);
let hue = glow.hue_deg.expect("a glow");
assert!((0.0..=40.0).contains(&hue), "hue {hue}");
assert_eq!(glow.strength(), GlowStrength::Strong);
assert_eq!(glow.color_name(), Some("red"));
}
#[test]
fn quenched_ruby_reads_dimmer() {
let fe = amount_of_wt_pct("corundum", "Fe", "wt_pct_oxide:FeO", 1.0);
let clean = fluorescence_report(cat(), &recipe("corundum", &[("Cr", 0.5)]));
let iron = fluorescence_report(cat(), &recipe("corundum", &[("Cr", 0.5), ("Fe", fe)]));
let (a, b) = (clean.glow(UV365_NM, 5.0), iron.glow(UV365_NM, 5.0));
assert!(b.photon_yield < 0.1 * a.photon_yield, "{b:?} vs {a:?}");
}
#[test]
fn emerald_iron_quench_halves_the_yield_at_660_ppm_fe() {
let fe_amount = |ppmw: f64| {
amount_of_wt_pct(
"beryl",
"Fe",
"wt_pct_oxide:FeO",
ppmw * 1e-4 * 71.84 / 55.845,
)
};
let ratio = |ppmw: f64| {
let report = fluorescence_report(
cat(),
&recipe("beryl", &[("Cr", 0.3), ("Fe", fe_amount(ppmw))]),
);
let e = &report.emitters[0];
f64::from(e.quantum_yield / e.quantum_yield_0)
};
assert!((ratio(660.0) - 0.5).abs() < 0.01, "{}", ratio(660.0));
assert!((0.07..=0.10).contains(&ratio(2200.0)), "{}", ratio(2200.0));
assert!(ratio(100.0) > 0.9, "a clean emerald is not quenched");
}
#[test]
fn diamond_n3_glows_blue() {
let report = fluorescence_report(cat(), &recipe("diamond", &[("N", 500.0)]));
assert_eq!(report.fluorescence.emitters().len(), 1);
let hue = report.glow(UV365_NM, 5.0).hue_deg.expect("a glow");
assert!((230.0..=290.0).contains(&hue), "hue {hue}");
}
#[test]
fn irradiation_and_anneal_create_the_h3_and_nv_emitters() {
let mut r = recipe("diamond", &[("N", 500.0)]);
r.treatments = vec!["irradiation_anneal_h3".to_string()];
let h3 = resolve_fluorescence(cat(), &r);
assert_eq!(h3.emitters().len(), 2, "N3 and H3");
let mut r = recipe("diamond", &[("N", 500.0)]);
r.treatments = vec!["irradiation_anneal_nv".to_string()];
let nv = fluorescence_report(cat(), &r);
assert_eq!(nv.fluorescence.emitters().len(), 2, "N3 and NV");
assert!(nv.fluorescence.emitters().iter().any(|e| {
e.emission
.iter()
.any(|b| (b.centre_nm - 637.0).abs() < 1e-3)
}));
}
#[test]
fn emission_spectra_are_normalised_and_narrow_lines_widened() {
let fl = resolve_fluorescence(cat(), &recipe("corundum", &[("Cr", 0.5)]));
let e = &fl.emitters()[0];
let total: f32 = e.emission.iter().map(|b| b.weight).sum();
assert!((total - 1.0).abs() < 1e-5, "{total}");
assert!(e.emission.iter().all(|b| b.fwhm_nm >= MIN_EMISSION_FWHM_NM));
}
#[test]
fn the_glow_without_emitters_is_none() {
let glow = uv_glow(&Fluorescence::new(Vec::new()), 0.1, UV365_NM, 5.0);
assert_eq!(glow.strength(), GlowStrength::None);
assert_eq!(glow.hue_deg, None);
assert_eq!(glow.photon_yield, 0.0);
}
#[test]
fn a_competing_background_dims_the_glow() {
let report = fluorescence_report(cat(), &recipe("corundum", &[("Cr", 0.5)]));
let bare = uv_glow(&report.fluorescence, 0.0, UV365_NM, 5.0);
let crowded = uv_glow(&report.fluorescence, 10.0, UV365_NM, 5.0);
assert!(crowded.photon_yield < 0.5 * bare.photon_yield);
assert_eq!(report.background.len(), 0, "Cr is the only absorber here");
}
}