use crate::{
color::{Illuminant, body_colors},
optics::{
absorption::AbsorptionTensor,
chromophore::{ChromophoreCatalogue, ColorRecipe, resolve},
materials::{
AMETRINE_AMETHYST_FE_PPMA, AMETRINE_CITRINE_FE_PPMA, AbsorptionUnit, GemMaterial,
ZONE_SWATCH_FALLBACK_MODEL_WIDTH, ametrine_zoning, zone_swatches,
},
zoning::{ZoneShape, segment_optical_depth},
},
render_setup::{MODEL_UNIT_FACE_UP_PATH, effective_stone_width_mm},
};
use glam::DVec3;
const N_AVOGADRO: f64 = 6.022_140_76e23;
fn quartz_tensor(iron_ppma: f64, treatments: &[&str]) -> AbsorptionTensor {
let catalogue = ChromophoreCatalogue::global();
let mut recipe = ColorRecipe::new("quartz", catalogue.data_version);
assert!(
recipe.set_amount("Fe", iron_ppma),
"the iron amount is finite"
);
recipe.treatments = treatments.iter().map(|id| (*id).to_owned()).collect();
let (tensor, _warnings) = resolve(&recipe, catalogue).expect("the quartz iron recipe resolves");
tensor
}
fn hue_deg(lab: [f64; 3]) -> f64 {
lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0)
}
#[test]
fn ametrine_is_a_zoned_per_mm_quartz() {
let ametrine = GemMaterial::by_name("Ametrine").expect("Ametrine is a built-in");
assert_eq!(ametrine.name, "Ametrine");
assert_eq!(
ametrine,
GemMaterial::ametrine(),
"the table is deterministic"
);
let zoning = ametrine
.zoning
.as_ref()
.expect("Ametrine carries color zones");
assert_eq!(zoning.zones.len(), 1, "exactly one shaped zone");
let ZoneShape::HalfSpace { normal, offset } = &zoning.zones[0].shape else {
panic!("the shaped zone is a half space");
};
assert_eq!(*normal, DVec3::X);
assert_eq!(*offset, 0.0);
assert_eq!(zoning.boundary_softness_mm, 0.0, "a sharp boundary");
assert_ne!(
zoning.base.tensor, zoning.zones[0].absorption.tensor,
"the two halves differ"
);
assert_eq!(ametrine.absorption_unit, AbsorptionUnit::PerMm);
assert_eq!(
ametrine.absorption, zoning.base.tensor,
"the plain absorption is the base zone"
);
assert_eq!(zoning.validate(), Ok(()));
let citrine = GemMaterial::by_name("Citrine").expect("Citrine is a built-in");
assert_eq!(ametrine.dispersion, citrine.dispersion);
assert_eq!(
ametrine.birefringence_delta.to_bits(),
citrine.birefringence_delta.to_bits()
);
assert_eq!(
ametrine.uniaxial_extraordinary_dispersion,
citrine.uniaxial_extraordinary_dispersion
);
assert_eq!(ametrine.c_axis, citrine.c_axis);
assert_eq!(ametrine.crystal_system, citrine.crystal_system);
assert_eq!(ametrine.optical_character, citrine.optical_character);
assert!(ametrine.gpu_supported(), "the GPU zone table renders it");
}
#[test]
fn ametrine_zones_follow_the_catalogue_recipes() {
let catalogue = ChromophoreCatalogue::global();
let quartz = catalogue.host("quartz").expect("the quartz host");
assert_eq!(
quartz.element_unit("Fe").as_deref(),
Some("ppma_all"),
"the iron amounts are ppma of all atoms"
);
let anahi_ppma =
80.0e-6 * quartz.density_g_cm3 / 55.845 * N_AVOGADRO / quartz.n_site_for_unit("ppma_all");
assert!(
(AMETRINE_AMETHYST_FE_PPMA - anahi_ppma).abs() < 0.05,
"80 ppmw Fe is {anahi_ppma:.2} ppma, the constant is {AMETRINE_AMETHYST_FE_PPMA}"
);
assert_eq!(
AMETRINE_CITRINE_FE_PPMA.to_bits(),
AMETRINE_AMETHYST_FE_PPMA.to_bits(),
"the citrine half is the amethyst half annealed: the same iron"
);
let amethyst = quartz_tensor(AMETRINE_AMETHYST_FE_PPMA, &["gamma_irradiation"]);
let citrine = quartz_tensor(
AMETRINE_CITRINE_FE_PPMA,
&["gamma_irradiation", "thermal_anneal_450"],
);
let zoning = ametrine_zoning();
assert_eq!(zoning.base.tensor, amethyst);
assert_eq!(zoning.zones[0].absorption.tensor, citrine);
}
#[test]
fn ametrine_halves_are_purple_and_yellow() {
let ametrine = GemMaterial::ametrine();
let swatches = zone_swatches(&ametrine, 0.0);
assert_eq!(swatches.len(), 2, "the base zone and one shaped zone");
let mm_per_unit = f64::from(effective_stone_width_mm(0.0)) / ZONE_SWATCH_FALLBACK_MODEL_WIDTH;
let path_mm = f64::from(MODEL_UNIT_FACE_UP_PATH) * mm_per_unit;
let zoning = ametrine
.zoning
.as_ref()
.expect("Ametrine carries color zones");
let halves = [&zoning.base, &zoning.zones[0].absorption]
.map(|zone| body_colors(&zone.tensor, path_mm, Illuminant::D65).unpolarised);
for (name, half) in ["amethyst half", "citrine half"].iter().zip(&halves) {
eprintln!("{name}: Lab {:.1?}, sRGB {:?}", half.lab, half.srgb);
}
for (swatch, half) in swatches.iter().zip(&halves) {
assert_eq!(*swatch, half.srgb.map(|v| f32::from(v) / 255.0));
}
let [purple, yellow] = halves.map(|half| half.lab);
let (hue_purple, hue_yellow) = (hue_deg(purple), hue_deg(yellow));
assert!(
(270.0..=345.0).contains(&hue_purple),
"amethyst half hue {hue_purple:.1} deg (Lab {purple:.1?}), expected purple 270-345"
);
assert!(
(55.0..=100.0).contains(&hue_yellow),
"citrine half hue {hue_yellow:.1} deg (Lab {yellow:.1?}), expected yellow-orange 55-100"
);
for (name, lab) in [("amethyst half", purple), ("citrine half", yellow)] {
assert!(
lab[1].hypot(lab[2]) >= 10.0,
"{name}: chroma {:.1} is too pale (Lab {lab:.1?})",
lab[1].hypot(lab[2])
);
assert!(
(15.0..=95.0).contains(&lab[0]),
"{name}: L* {:.1} is black or clear (Lab {lab:.1?})",
lab[0]
);
}
}
#[test]
fn a_segment_on_each_side_of_the_boundary_takes_that_halfs_absorption() {
const LAMBDA_NM: f64 = 550.0;
let zoning = ametrine_zoning();
let amethyst = zoning.base.alpha(LAMBDA_NM, None);
let citrine = zoning.zones[0].absorption.alpha(LAMBDA_NM, None);
assert!(
(amethyst - citrine).abs() > 1e-6,
"test premise: the halves absorb differently at {LAMBDA_NM} nm ({amethyst} vs {citrine})"
);
let depth = |from: f64, to: f64| {
segment_optical_depth(
&zoning,
DVec3::new(from, 0.0, 0.0),
DVec3::new(to, 0.0, 0.0),
LAMBDA_NM,
)
};
assert!(
(depth(-2.0, -1.0) - amethyst).abs() < 1e-9,
"x < 0 is amethyst"
);
assert!(
(depth(1.0, 2.0) - citrine).abs() < 1e-9,
"x >= 0 is citrine"
);
assert!(
(depth(-1.0, 1.0) - (amethyst + citrine)).abs() < 1e-9,
"a segment across the boundary has one millimetre in each half"
);
}
#[test]
fn a_body_colour_drops_the_zones() {
let triple = GemMaterial::ametrine().with_body_color([0.2, 0.3, 0.1]);
assert!(triple.zoning.is_none());
assert_eq!(triple.absorption_unit, AbsorptionUnit::ModelUnit);
let banded = GemMaterial::ametrine().with_body_color_bands(&[[550.0, 30.0, 0.2]], 1.0);
assert!(banded.zoning.is_none());
assert_eq!(banded.absorption_unit, AbsorptionUnit::PerMm);
}
#[test]
fn ametrine_matches_the_quartz_targets() {
let ametrine = GemMaterial::ametrine();
let n_d = ametrine.dispersion.evaluate(589.3);
assert!((n_d - 1.544).abs() <= 0.001, "n_d {n_d}");
assert!(
(ametrine.birefringence_delta - 0.0091).abs() <= 1e-4,
"birefringence {}",
ametrine.birefringence_delta
);
}