use indicatrix::optics::{dispersion::DispersionModel, materials::GemMaterial};
use indicatrix_cut_core::material::{BuiltinMaterials, MaterialLookup, MaterialSelection};
pub(super) struct EditorMaterialLookup<'a> {
custom: &'a [GemMaterial],
specific_gravity_source: Option<&'a [(String, f64)]>,
}
impl<'a> EditorMaterialLookup<'a> {
pub(super) const fn new(custom: &'a [GemMaterial]) -> Self {
Self {
custom,
specific_gravity_source: None,
}
}
#[must_use]
pub(super) const fn with_specific_gravity(mut self, entries: &'a [(String, f64)]) -> Self {
self.specific_gravity_source = Some(entries);
self
}
}
impl MaterialLookup for EditorMaterialLookup<'_> {
fn lookup(&self, name: &str) -> Option<GemMaterial> {
self.custom
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
.cloned()
.or_else(|| BuiltinMaterials.lookup(name))
}
fn specific_gravity(&self, name: &str) -> Option<f64> {
self.specific_gravity_source
.and_then(|entries| {
entries
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, sg)| *sg)
})
.or_else(|| BuiltinMaterials.specific_gravity(name))
}
}
#[must_use]
pub(super) fn resolved_gem_material(
selection: &MaterialSelection,
lookup: &dyn MaterialLookup,
) -> GemMaterial {
let resolved = selection.resolve(lookup);
if selection.refractive_index_override.is_none() {
return resolved.gem;
}
let mut gem = resolved.gem;
gem.dispersion = DispersionModel::Cauchy {
a: resolved.n_d as f32,
b: 0.0,
c: 0.0,
};
gem
}
pub(in crate::gui) const MATERIAL_MATCH_TOLERANCE: f64 = 0.02;
#[must_use]
pub(in crate::gui) fn material_for_refractive_index(n_d: f64) -> Option<(String, GemMaterial)> {
let (name, _) = nearest_built_in_material(n_d, MATERIAL_MATCH_TOLERANCE)?;
let gem = BuiltinMaterials.lookup(&name)?;
Some((name, gem))
}
#[must_use]
pub(super) fn traced_gem_material(
traced_name: &str,
selection: &MaterialSelection,
lookup: &dyn MaterialLookup,
) -> Option<GemMaterial> {
let mut gem = lookup.lookup(traced_name)?;
if let Some(n_d) = selection.refractive_index_override {
gem.dispersion = DispersionModel::Cauchy {
a: n_d as f32,
b: 0.0,
c: 0.0,
};
}
Some(gem)
}
const RI_INFERENCE_CANDIDATES: [&str; 13] = [
"Diamond",
"Sapphire",
"Ruby",
"Emerald",
"Zircon",
"Alexandrite",
"Topaz",
"Spinel",
"Quartz",
"Tourmaline",
"Tanzanite",
"Synthetic Moissanite",
"Cubic Zirconia",
];
#[must_use]
pub(in crate::gui) fn nearest_built_in_material(n_d: f64, tolerance: f64) -> Option<(String, f64)> {
RI_INFERENCE_CANDIDATES
.iter()
.filter_map(|&name| {
indicatrix_cut_core::built_in_refractive_index(name).map(|ri| (name, ri))
})
.map(|(name, ri)| {
let diff = (ri - n_d).abs();
(name, ri, diff)
})
.filter(|&(_, _, diff)| diff <= tolerance)
.min_by(|a, b| a.2.total_cmp(&b.2))
.map(|(name, ri, _)| (name.to_string(), ri))
}
#[must_use]
pub(in crate::gui) fn material_guess_candidates(n_d: f64, tolerance: f64) -> Vec<(String, f64)> {
let mut matches: Vec<(&'static str, f64, f64)> = RI_INFERENCE_CANDIDATES
.iter()
.filter_map(|&name| {
indicatrix_cut_core::built_in_refractive_index(name).map(|ri| (name, ri))
})
.map(|(name, ri)| (name, ri, (ri - n_d).abs()))
.filter(|&(_, _, diff)| diff <= tolerance)
.collect();
matches.sort_by(|a, b| a.2.total_cmp(&b.2));
matches
.into_iter()
.map(|(name, ri, _)| (name.to_string(), ri))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn specific_gravity_is_none_when_not_opted_in_and_the_name_is_not_a_builtin() {
let custom_list: [GemMaterial; 0] = [];
let lookup = EditorMaterialLookup::new(&custom_list);
assert_eq!(lookup.specific_gravity("My Garnet"), None);
}
#[test]
fn specific_gravity_resolves_a_builtin_name_even_without_opting_in() {
let custom_list: [GemMaterial; 0] = [];
let lookup = EditorMaterialLookup::new(&custom_list);
assert_eq!(lookup.specific_gravity("Diamond"), Some(3.52));
}
#[test]
fn specific_gravity_resolves_a_custom_material_once_opted_in() {
let custom_list: [GemMaterial; 0] = [];
let sg_entries = [("My Garnet".to_string(), 3.90)];
let lookup = EditorMaterialLookup::new(&custom_list).with_specific_gravity(&sg_entries);
assert_eq!(lookup.specific_gravity("my garnet"), Some(3.90));
}
#[test]
fn specific_gravity_prefers_a_custom_entry_over_a_same_named_builtin() {
let custom_list: [GemMaterial; 0] = [];
let sg_entries = [("Diamond".to_string(), 3.515)];
let lookup = EditorMaterialLookup::new(&custom_list).with_specific_gravity(&sg_entries);
assert_eq!(lookup.specific_gravity("Diamond"), Some(3.515));
}
#[test]
fn specific_gravity_falls_back_to_a_builtin_when_opted_in_but_the_name_is_not_custom() {
let custom_list: [GemMaterial; 0] = [];
let sg_entries = [("My Garnet".to_string(), 3.90)];
let lookup = EditorMaterialLookup::new(&custom_list).with_specific_gravity(&sg_entries);
assert_eq!(lookup.specific_gravity("Quartz"), Some(2.65));
assert_eq!(lookup.specific_gravity("Not A Real Material"), None);
}
#[test]
fn lookup_prefers_a_custom_material_over_a_same_named_built_in() {
let mut custom = GemMaterial::diamond();
custom.name = "Diamond".to_string();
custom.dispersion = DispersionModel::Cauchy {
a: 9.0,
b: 0.0,
c: 0.0,
};
let custom_list = [custom];
let lookup = EditorMaterialLookup::new(&custom_list);
let found = lookup.lookup("Diamond").unwrap();
assert!((f64::from(found.dispersion.evaluate(589.3)) - 9.0).abs() < 1e-6);
}
#[test]
fn lookup_falls_back_to_a_built_in_when_no_custom_material_matches() {
let custom_list: [GemMaterial; 0] = [];
let lookup = EditorMaterialLookup::new(&custom_list);
assert!(lookup.lookup("Quartz").is_some());
assert!(lookup.lookup("Not A Real Material").is_none());
}
#[test]
fn material_for_refractive_index_names_the_catalogues_common_stones() {
for (n_d, expected) in [
(1.54, "Quartz"),
(1.76, "Sapphire"),
(1.72, "Spinel"),
(2.16, "Cubic Zirconia"),
] {
let (name, gem) = material_for_refractive_index(n_d)
.unwrap_or_else(|| panic!("{n_d} must resolve to a built-in"));
assert_eq!(name, expected, "n_d {n_d}");
assert_eq!(gem.name, expected, "n_d {n_d}: the resolved material too");
}
}
#[test]
fn material_for_refractive_index_refuses_when_nothing_is_close() {
assert!(material_for_refractive_index(1.90).is_none());
}
#[test]
fn traced_gem_material_honours_the_traced_name_for_an_unnamed_selection() {
let custom_list: [GemMaterial; 0] = [];
let lookup = EditorMaterialLookup::new(&custom_list);
let gem = traced_gem_material("Sapphire", &MaterialSelection::none(), &lookup)
.expect("Sapphire is a built-in");
let expected = GemMaterial::by_name("Sapphire").expect("Sapphire is a built-in");
assert_eq!(gem.name, expected.name);
assert_eq!(gem.absorption, expected.absorption);
assert!(
!gem.absorption.o_ray.is_empty(),
"test premise: Sapphire must carry real absorption bands, or this test cannot tell it apart from diamond"
);
}
#[test]
fn traced_gem_material_applies_the_refractive_index_override() {
let selection = MaterialSelection {
name: None,
specific_gravity_override: None,
refractive_index_override: Some(1.66),
};
let custom_list: [GemMaterial; 0] = [];
let lookup = EditorMaterialLookup::new(&custom_list);
let gem = traced_gem_material("Quartz", &selection, &lookup).expect("Quartz is a built-in");
assert!((f64::from(gem.dispersion.evaluate(400.0)) - 1.66).abs() < 1e-6);
assert!((f64::from(gem.dispersion.evaluate(700.0)) - 1.66).abs() < 1e-6);
}
#[test]
fn traced_gem_material_refuses_an_unknown_name() {
let custom_list: [GemMaterial; 0] = [];
let lookup = EditorMaterialLookup::new(&custom_list);
assert!(traced_gem_material("Kryptonite", &MaterialSelection::none(), &lookup).is_none());
}
#[test]
fn resolved_gem_material_uses_the_real_dispersion_curve_with_no_override() {
let selection = MaterialSelection {
name: Some("Quartz".to_string()),
specific_gravity_override: None,
refractive_index_override: None,
};
let custom_list: [GemMaterial; 0] = [];
let lookup = EditorMaterialLookup::new(&custom_list);
let gem = resolved_gem_material(&selection, &lookup);
let expected = GemMaterial::by_name("Quartz").unwrap();
assert_eq!(gem.dispersion, expected.dispersion);
}
#[test]
fn resolved_gem_material_flattens_dispersion_to_the_override_value() {
let selection = MaterialSelection {
name: Some("Diamond".to_string()),
specific_gravity_override: None,
refractive_index_override: Some(1.70),
};
let custom_list: [GemMaterial; 0] = [];
let lookup = EditorMaterialLookup::new(&custom_list);
let gem = resolved_gem_material(&selection, &lookup);
for lambda in [450.0f32, 589.3, 650.0] {
assert!((f64::from(gem.dispersion.evaluate(lambda)) - 1.70).abs() < 1e-4);
}
let base = GemMaterial::diamond();
assert_eq!(gem.crystal_system, base.crystal_system);
assert_eq!(gem.absorption, base.absorption);
}
#[test]
fn material_guess_candidates_starts_with_the_exact_match() {
let sapphire_n_d = indicatrix_cut_core::built_in_refractive_index("Sapphire").unwrap();
let candidates = material_guess_candidates(sapphire_n_d, MATERIAL_MATCH_TOLERANCE);
assert_eq!(
candidates.first().map(|(name, _)| name.as_str()),
Some("Sapphire")
);
}
#[test]
fn material_guess_candidates_is_empty_when_nothing_is_close() {
assert_eq!(
material_guess_candidates(1.90, MATERIAL_MATCH_TOLERANCE),
Vec::<(String, f64)>::new()
);
}
#[test]
fn nearest_built_in_material_finds_quartz_for_its_own_ri() {
let quartz_n_d = indicatrix_cut_core::built_in_refractive_index("Quartz").unwrap();
let (name, ri) = nearest_built_in_material(quartz_n_d, 0.01).unwrap();
assert_eq!(name, "Quartz");
assert!((ri - quartz_n_d).abs() < 1e-9);
}
#[test]
fn nearest_built_in_material_returns_none_outside_tolerance() {
assert_eq!(nearest_built_in_material(1.9999, 0.001), None);
}
#[test]
fn nearest_built_in_material_picks_the_closest_when_two_are_within_tolerance() {
let diamond_n_d = indicatrix_cut_core::built_in_refractive_index("Diamond").unwrap();
let (name, _) = nearest_built_in_material(diamond_n_d - 0.02, 0.5).unwrap();
assert_eq!(name, "Diamond");
}
#[test]
fn every_catalogue_entry_resolves_to_a_gem_material_of_its_own_name_and_ri() {
let mut custom = GemMaterial::diamond();
custom.name = "My Custom Garnet".to_string();
custom.dispersion = DispersionModel::Cauchy {
a: 1.74,
b: 0.0,
c: 0.0,
};
let custom_list = [custom];
let catalogue = indicatrix_cut_core::MaterialCatalogue::build(&custom_list);
assert!(
catalogue.len() > 30,
"premise: the catalogue must cover every built-in, not just thirteen \
common ones -- {} entries found",
catalogue.len()
);
let lookup = EditorMaterialLookup::new(&custom_list);
for entry in catalogue.entries() {
let selection = MaterialSelection {
name: Some(entry.name.clone()),
specific_gravity_override: None,
refractive_index_override: None,
};
let resolved = resolved_gem_material(&selection, &lookup);
assert_eq!(
resolved.name, entry.name,
"catalogue entry {:?} resolved to the wrong material",
entry.name
);
let resolved_ri = f64::from(resolved.dispersion.evaluate(589.3));
assert!(
(resolved_ri - entry.ri_d).abs() < 1e-6,
"{}: catalogue said RI {}, resolved material's own dispersion says {}",
entry.name,
entry.ri_d,
resolved_ri
);
}
}
#[test]
fn every_catalogue_entry_traces_as_a_gem_material_of_its_own_name() {
let catalogue = indicatrix_cut_core::MaterialCatalogue::build(&[]);
let lookup = EditorMaterialLookup::new(&[]);
for entry in catalogue.entries() {
let gem = traced_gem_material(&entry.name, &MaterialSelection::none(), &lookup)
.unwrap_or_else(|| panic!("{} must resolve via traced_gem_material", entry.name));
assert_eq!(gem.name, entry.name);
}
}
}