use crate::{
color::{
Illuminant,
body_color::{BodyColor, BodyColors, body_colors, lab_to_lch},
},
optics::{
absorption::{AbsorptionBand, BandShape},
materials::{AbsorptionUnit, GemMaterial},
},
renderer::buffers::MAX_ABSORPTION_BANDS,
};
const PERIDOT: &str = "Peridot";
const SPESSARTINE: &str = "Spessartine Garnet";
const TANZANITE: &str = "Tanzanite";
const RUBY: &str = "Ruby";
const SAPPHIRE: &str = "Sapphire";
const EMERALD: &str = "Emerald";
const AQUAMARINE: &str = "Aquamarine";
const DEMANTOID: &str = "Andradite Garnet (Demantoid)";
const TSAVORITE: &str = "Grossular Garnet (Tsavorite)";
fn material(name: &str) -> GemMaterial {
GemMaterial::by_name(name).unwrap_or_else(|| panic!("{name} must be a built-in material"))
}
fn swatch(name: &str, units: f64) -> BodyColors {
body_colors(&material(name).absorption, units, Illuminant::D65)
}
fn lch(color: &BodyColor) -> [f64; 3] {
lab_to_lch(color.lab)
}
fn unpolarised(name: &str, units: f64) -> [f64; 3] {
lch(&swatch(name, units).unpolarised)
}
fn alpha(bands: &[AbsorptionBand], lambda_nm: f32) -> f64 {
f64::from(bands.iter().map(|b| b.evaluate(lambda_nm)).sum::<f32>())
}
fn transmission(bands: &[AbsorptionBand], lambda_nm: f32) -> f64 {
(-alpha(bands, lambda_nm)).exp()
}
#[test]
fn peridot_swatch_is_yellow_green() {
let [l, c, h] = unpolarised(PERIDOT, 1.0);
assert!((100.0..=120.0).contains(&h), "Peridot hue {h:.1}");
assert!(c >= 45.0, "Peridot chroma {c:.1}");
assert!((65.0..=85.0).contains(&l), "Peridot lightness {l:.1}");
}
#[test]
fn peridot_hue_is_stable_with_path() {
let h1 = unpolarised(PERIDOT, 1.0)[2];
let h2 = unpolarised(PERIDOT, 2.0)[2];
assert!(
(h2 - h1).abs() <= 6.0,
"Peridot hue moved from {h1:.1} to {h2:.1} between 1 and 2 units"
);
}
#[test]
fn spessartine_swatch_is_orange() {
let [l, c, h] = unpolarised(SPESSARTINE, 1.0);
assert!((55.0..=75.0).contains(&h), "Spessartine hue {h:.1}");
assert!(c >= 60.0, "Spessartine chroma {c:.1}");
assert!((70.0..=85.0).contains(&l), "Spessartine lightness {l:.1}");
}
#[test]
fn tanzanite_swatch_is_violet_blue() {
let colors = swatch(TANZANITE, 1.0);
let [l, _, h] = lch(&colors.unpolarised);
assert!((280.0..=305.0).contains(&h), "Tanzanite hue {h:.1}");
assert!((38.0..=55.0).contains(&l), "Tanzanite lightness {l:.1}");
let [l_alpha, _, h_alpha] = lch(&colors.o_ray);
let [l_beta, _, h_beta] = lch(&colors.beta_ray.expect("Tanzanite is biaxial"));
let [l_gamma, _, h_gamma] = lch(&colors.e_ray);
assert!(
l_gamma > l_beta && l_beta > l_alpha,
"lightness must run gamma {l_gamma:.1} > beta {l_beta:.1} > alpha {l_alpha:.1}"
);
assert!(
h_alpha > h_beta && h_beta > h_gamma,
"hue must run alpha {h_alpha:.1} (most purplish) > beta {h_beta:.1} > gamma {h_gamma:.1}"
);
}
#[test]
fn ruby_swatch_is_red_not_mauve() {
let [l, c, h] = unpolarised(RUBY, 1.0);
assert!(
(345.0..360.0).contains(&h) || (0.0..=12.0).contains(&h),
"Ruby hue {h:.1} must be red, not the old mauve (340)"
);
assert!(c >= 55.0, "Ruby chroma {c:.1}");
assert!((33.0..=50.0).contains(&l), "Ruby lightness {l:.1}");
}
#[test]
fn ruby_hue_rotates_toward_red_with_path() {
let h1 = unpolarised(RUBY, 1.0)[2];
let h2 = unpolarised(RUBY, 2.0)[2];
let turn = (h2 - h1).rem_euclid(360.0);
assert!(
(5.0..=25.0).contains(&turn),
"Ruby hue turned {turn:.1} degrees from {h1:.1} to {h2:.1}, expected 5 to 25"
);
}
#[test]
fn ruby_o_ray_absorbs_more_than_e_ray_in_the_green() {
let ruby = material(RUBY);
let t_o = transmission(&ruby.absorption.o_ray, 560.0);
let t_e = transmission(&ruby.absorption.e_ray, 560.0);
assert!(
t_o < t_e,
"T_o(560) {t_o:.2e} must be below T_e(560) {t_e:.2e}"
);
}
#[test]
fn aquamarine_swatch_is_light_blue_with_a_colorless_o_ray() {
let colors = swatch(AQUAMARINE, 1.0);
let [l, c, h] = lch(&colors.unpolarised);
assert!((230.0..=260.0).contains(&h), "Aquamarine hue {h:.1}");
assert!((10.0..=25.0).contains(&c), "Aquamarine chroma {c:.1}");
assert!((80.0..=90.0).contains(&l), "Aquamarine lightness {l:.1}");
let [l_o, c_o, _] = lch(&colors.o_ray);
assert!(
l_o >= 95.0 && c_o <= 20.0,
"the o-ray must be near-colorless, got L* {l_o:.1} C* {c_o:.1}"
);
let l_e = lch(&colors.e_ray)[0];
assert!(
l_e <= 55.0,
"the e-ray must be the deep blue one, got L* {l_e:.1}"
);
let aquamarine = material(AQUAMARINE);
let t_o = transmission(&aquamarine.absorption.o_ray, 680.0);
let t_e = transmission(&aquamarine.absorption.e_ray, 680.0);
assert!(
t_e < t_o,
"e-ray T(680) {t_e:.3} must be below o-ray T(680) {t_o:.3}"
);
}
#[test]
fn demantoid_and_tsavorite_are_green_with_no_window_at_650_nm() {
let [_, _, h_demantoid] = unpolarised(DEMANTOID, 1.0);
let [_, _, h_tsavorite] = unpolarised(TSAVORITE, 1.0);
for (name, h) in [(DEMANTOID, h_demantoid), (TSAVORITE, h_tsavorite)] {
assert!((130.0..=160.0).contains(&h), "{name} hue {h:.1}");
let bands = &material(name).absorption.o_ray;
let t_650 = transmission(bands, 650.0);
assert!(t_650 < 0.2, "{name} T(650) {t_650:.3} must be below 0.2");
let t_740 = transmission(bands, 740.0);
assert!(
t_740 > 0.6,
"{name} T(740) {t_740:.3} must show the measured deep-red window"
);
}
assert!(
h_demantoid < h_tsavorite,
"demantoid ({h_demantoid:.1}) must be yellower than tsavorite ({h_tsavorite:.1})"
);
}
#[test]
fn sapphire_and_emerald_refits_keep_their_hue_class() {
let [l, _, h] = unpolarised(SAPPHIRE, 1.0);
assert!((275.0..=292.0).contains(&h), "Sapphire hue {h:.1}");
assert!((50.0..=65.0).contains(&l), "Sapphire lightness {l:.1}");
let colors = swatch(EMERALD, 1.0);
let [l, _, h] = lch(&colors.unpolarised);
assert!((160.0..=175.0).contains(&h), "Emerald hue {h:.1}");
assert!((58.0..=72.0).contains(&l), "Emerald lightness {l:.1}");
let (h_o, h_e) = (lch(&colors.o_ray)[2], lch(&colors.e_ray)[2]);
assert!(
h_o < h_e,
"Emerald o-ray hue {h_o:.1} must be yellower than the e-ray's {h_e:.1}"
);
}
#[test]
fn refitted_entries_use_energy_bands_and_kept_entries_do_not() {
let all_energy = |name: &str| {
let t = material(name).absorption;
t.o_ray
.iter()
.chain(&t.e_ray)
.chain(t.beta_ray.iter().flatten())
.all(|b| b.shape == BandShape::GaussianEnergy)
};
for name in [
PERIDOT,
SPESSARTINE,
TANZANITE,
RUBY,
SAPPHIRE,
EMERALD,
AQUAMARINE,
DEMANTOID,
TSAVORITE,
] {
assert!(all_energy(name), "{name} must carry energy-domain bands");
}
for name in ["Amethyst", "Citrine", "Tourmaline"] {
assert!(!all_energy(name), "{name} must keep its wavelength bands");
}
}
#[test]
fn unchanged_built_ins_keep_their_bands() {
let amethyst = material("Amethyst").absorption;
assert_eq!(amethyst.o_ray, vec![AbsorptionBand::new(545.0, 55.0, 1.8)]);
assert_eq!(amethyst.e_ray, amethyst.o_ray);
let citrine = material("Citrine").absorption;
assert_eq!(citrine.o_ray, vec![AbsorptionBand::new(400.0, 70.0, 1.6)]);
assert_eq!(citrine.e_ray, citrine.o_ray);
let tourmaline = material("Tourmaline").absorption;
assert_eq!(
tourmaline.o_ray,
vec![
AbsorptionBand::new(720.0, 55.0, 3.0),
AbsorptionBand::new(430.0, 45.0, 1.8),
]
);
assert_eq!(
tourmaline.e_ray,
vec![
AbsorptionBand::new(720.0, 55.0, 1.0),
AbsorptionBand::new(430.0, 45.0, 0.6),
]
);
}
#[test]
fn every_built_in_fits_the_gpu_band_limit() {
for material in GemMaterial::all_materials() {
let t = &material.absorption;
let sets = [Some(&t.o_ray), Some(&t.e_ray), t.beta_ray.as_ref()];
for (ray, set) in ["o", "e", "beta"].into_iter().zip(sets) {
let count = set.map_or(0, Vec::len);
assert!(
count <= MAX_ABSORPTION_BANDS,
"{}: the {ray} ray has {count} bands, the GPU holds {MAX_ABSORPTION_BANDS}",
material.name
);
}
}
}
#[test]
fn per_mm_built_ins_keep_their_unit_and_zoning() {
let ametrine = material("Ametrine");
assert_eq!(ametrine.absorption_unit, AbsorptionUnit::PerMm);
assert!(ametrine.zoning.is_some(), "Ametrine keeps its zoning");
let citrine = material("Citrine");
assert_eq!(ametrine.dispersion, citrine.dispersion);
assert_eq!(ametrine.c_axis, citrine.c_axis);
assert_eq!(
ametrine.birefringence_delta.to_bits(),
citrine.birefringence_delta.to_bits()
);
}
#[test]
fn colourless_built_ins_stay_empty() {
for name in ["Diamond", "Synthetic Moissanite", "Cubic Zirconia"] {
let t = material(name).absorption;
assert!(
t.o_ray.is_empty() && t.e_ray.is_empty() && t.beta_ray.is_none(),
"{name} must stay colorless"
);
}
}