use indicatrix::optics::{
chromophore::ChromophoreCatalogue,
fluorescence::Fluorescence,
materials::{CrystalSystem, GemMaterial, OpticalCharacter},
};
use indicatrix_cut_core::material::{
ColorMode, LibraryMaterial, crystal_system_name, optical_character_name,
};
use indicatrix_vault::{
db::sqlite::{CustomMaterialParams, Database},
model::material::CustomMaterialRow,
};
use std::sync::Arc;
const CRYSTAL_SYSTEMS: [CrystalSystem; 7] = [
CrystalSystem::Cubic,
CrystalSystem::Tetragonal,
CrystalSystem::Hexagonal,
CrystalSystem::Trigonal,
CrystalSystem::Orthorhombic,
CrystalSystem::Monoclinic,
CrystalSystem::Triclinic,
];
const OPTICAL_CHARACTERS: [OpticalCharacter; 5] = [
OpticalCharacter::Isotropic,
OpticalCharacter::UniaxialPositive,
OpticalCharacter::UniaxialNegative,
OpticalCharacter::BiaxialPositive,
OpticalCharacter::BiaxialNegative,
];
#[must_use]
pub fn crystal_system_from_index(idx: i32) -> Option<CrystalSystem> {
usize::try_from(idx)
.ok()
.and_then(|idx| CRYSTAL_SYSTEMS.get(idx).copied())
}
#[must_use]
pub fn optical_character_from_index(idx: i32) -> Option<OpticalCharacter> {
usize::try_from(idx)
.ok()
.and_then(|idx| OPTICAL_CHARACTERS.get(idx).copied())
}
#[must_use]
pub fn crystal_system_to_index(cs: CrystalSystem) -> i32 {
CRYSTAL_SYSTEMS
.iter()
.position(|&candidate| candidate == cs)
.and_then(|idx| i32::try_from(idx).ok())
.unwrap_or(0)
}
#[must_use]
pub fn optical_character_to_index(oc: OpticalCharacter) -> i32 {
OPTICAL_CHARACTERS
.iter()
.position(|&candidate| candidate == oc)
.and_then(|idx| i32::try_from(idx).ok())
.unwrap_or(0)
}
#[must_use]
pub const fn is_biaxial(oc: OpticalCharacter) -> bool {
matches!(
oc,
OpticalCharacter::BiaxialPositive | OpticalCharacter::BiaxialNegative
)
}
#[must_use]
pub fn gem_material_from_row(row: &CustomMaterialRow) -> GemMaterial {
library_material(row).gem_material()
}
#[must_use]
pub fn library_material(row: &CustomMaterialRow) -> LibraryMaterial<'_> {
LibraryMaterial {
name: &row.name,
refractive_index: row.refractive_index,
dispersion: row.dispersion,
birefringence: row.birefringence,
absorption_rgb: row.absorption_rgb,
crystal_system: row.crystal_system.as_deref(),
optical_character: row.optical_character.as_deref(),
biaxial_delta_beta_alpha: row.biaxial_delta_beta_alpha,
specific_gravity: row.specific_gravity,
color_recipe_json: row.color_recipe_json.as_deref(),
dispersion_model_json: row.dispersion_model_json.as_deref(),
absorption_bands_json: row.absorption_bands_json.as_deref(),
}
}
#[cfg(test)]
#[expect(
clippy::too_many_arguments,
reason = "mirrors the vault row's columns; a params struct would only move the list"
)]
pub fn save_gem_material(
db: &Database,
material: &GemMaterial,
ri: f32,
dispersion: f32,
birefringence: f32,
absorption_rgb: [f32; 3],
specific_gravity: Option<f32>,
color_recipe_json: Option<&str>,
) -> anyhow::Result<()> {
save_gem_material_fields(
db,
material,
&MaterialSaveFields {
ri,
dispersion,
birefringence,
absorption_rgb,
specific_gravity,
color_recipe_json,
dispersion_model_json: None,
absorption_bands_json: None,
},
)
}
#[derive(Debug, Clone, Copy)]
pub struct MaterialSaveFields<'a> {
pub ri: f32,
pub dispersion: f32,
pub birefringence: f32,
pub absorption_rgb: [f32; 3],
pub specific_gravity: Option<f32>,
pub color_recipe_json: Option<&'a str>,
pub dispersion_model_json: Option<&'a str>,
pub absorption_bands_json: Option<&'a str>,
}
pub fn save_gem_material_fields(
db: &Database,
material: &GemMaterial,
fields: &MaterialSaveFields<'_>,
) -> anyhow::Result<()> {
db.save_custom_material(&CustomMaterialParams {
name: &material.name,
refractive_index: fields.ri,
dispersion: fields.dispersion,
birefringence: fields.birefringence,
absorption_rgb: fields.absorption_rgb,
crystal_system: Some(crystal_system_name(material.crystal_system)),
optical_character: Some(optical_character_name(material.optical_character)),
biaxial_delta_beta_alpha: material.biaxial_delta_beta_alpha,
per_axis_dispersion_json: None,
specific_gravity: fields.specific_gravity,
color_recipe_json: fields.color_recipe_json,
dispersion_model_json: fields.dispersion_model_json,
absorption_bands_json: fields.absorption_bands_json,
})
}
#[must_use]
pub fn custom_material_physics_from_rows(rows: &[CustomMaterialRow]) -> Vec<String> {
rows.iter()
.filter(|row| {
row.color_recipe_json
.as_deref()
.and_then(ColorMode::from_json)
.is_some_and(|m| m.is_physics())
})
.map(|row| row.name.clone())
.collect()
}
#[must_use]
pub fn custom_material_fluorescence_from_rows(
rows: &[CustomMaterialRow],
) -> Vec<(String, Arc<Fluorescence>)> {
let catalogue = ChromophoreCatalogue::global();
rows.iter()
.filter_map(|row| {
let mode = ColorMode::from_json(row.color_recipe_json.as_deref()?)?;
let glow = mode.fluorescence(catalogue);
(!glow.is_empty()).then(|| (row.name.clone(), Arc::new(glow)))
})
.collect()
}
#[must_use]
pub fn custom_material_specific_gravity_from_rows(
rows: &[CustomMaterialRow],
) -> Vec<(String, f64)> {
rows.iter()
.filter_map(|row| {
row.specific_gravity
.map(|sg| (row.name.clone(), f64::from(sg)))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_cut_core::{
material::{crystal_system_from_name, optical_character_from_name},
native::dispersion_model_from_json,
};
#[test]
fn crystal_system_str_round_trips_all_seven_variants() {
for cs in CRYSTAL_SYSTEMS {
let s = crystal_system_name(cs);
assert_eq!(crystal_system_from_name(s), Some(cs), "variant={cs:?}");
}
}
#[test]
fn optical_character_str_round_trips_all_five_variants() {
for oc in OPTICAL_CHARACTERS {
let s = optical_character_name(oc);
assert_eq!(optical_character_from_name(s), Some(oc), "variant={oc:?}");
}
}
#[test]
fn crystal_system_index_matches_the_declared_enum_order() {
for (idx, cs) in CRYSTAL_SYSTEMS.iter().enumerate() {
assert_eq!(
crystal_system_from_index(i32::try_from(idx).unwrap()),
Some(*cs),
"idx={idx}"
);
}
}
#[test]
fn optical_character_index_matches_the_declared_enum_order() {
for (idx, oc) in OPTICAL_CHARACTERS.iter().enumerate() {
assert_eq!(
optical_character_from_index(i32::try_from(idx).unwrap()),
Some(*oc),
"idx={idx}"
);
}
}
#[test]
fn crystal_system_to_index_round_trips_through_from_index_for_every_variant() {
for cs in CRYSTAL_SYSTEMS {
let idx = crystal_system_to_index(cs);
assert_eq!(crystal_system_from_index(idx), Some(cs), "variant={cs:?}");
}
}
#[test]
fn optical_character_to_index_round_trips_through_from_index_for_every_variant() {
for oc in OPTICAL_CHARACTERS {
let idx = optical_character_to_index(oc);
assert_eq!(
optical_character_from_index(idx),
Some(oc),
"variant={oc:?}"
);
}
}
#[test]
fn unrecognized_strings_and_out_of_range_indices_return_none() {
assert_eq!(crystal_system_from_name("Amorphous"), None);
assert_eq!(optical_character_from_name(""), None);
assert_eq!(crystal_system_from_index(-1), None);
assert_eq!(crystal_system_from_index(99), None);
assert_eq!(optical_character_from_index(-1), None);
assert_eq!(optical_character_from_index(99), None);
}
#[test]
fn is_biaxial_is_true_only_for_the_two_biaxial_variants() {
assert!(is_biaxial(OpticalCharacter::BiaxialPositive));
assert!(is_biaxial(OpticalCharacter::BiaxialNegative));
assert!(!is_biaxial(OpticalCharacter::Isotropic));
assert!(!is_biaxial(OpticalCharacter::UniaxialPositive));
assert!(!is_biaxial(OpticalCharacter::UniaxialNegative));
}
#[test]
fn row_with_no_stored_crystal_optics_falls_back_to_new_custom_inference() {
let row = CustomMaterialRow {
name: "Legacy Custom Sapphire".to_string(),
refractive_index: 1.768,
dispersion: 0.018,
birefringence: -0.008,
absorption_rgb: [2.8, 1.2, 0.1],
crystal_system: None,
optical_character: None,
biaxial_delta_beta_alpha: None,
per_axis_dispersion_json: None,
specific_gravity: None,
color_recipe_json: None,
dispersion_model_json: None,
absorption_bands_json: None,
};
let from_row = gem_material_from_row(&row);
let inferred = GemMaterial::new_custom(
&row.name,
row.refractive_index,
row.dispersion,
row.birefringence,
row.absorption_rgb,
);
assert_eq!(from_row.crystal_system, inferred.crystal_system);
assert_eq!(from_row.optical_character, inferred.optical_character);
assert_eq!(
from_row.biaxial_delta_beta_alpha,
inferred.biaxial_delta_beta_alpha
);
assert_eq!(from_row.crystal_system, CrystalSystem::Trigonal);
assert_eq!(
from_row.optical_character,
OpticalCharacter::UniaxialNegative
);
}
#[test]
fn row_with_stored_crystal_optics_overrides_new_custom_inference() {
let row = CustomMaterialRow {
name: "Custom Tanzanite".to_string(),
refractive_index: 1.691,
dispersion: 0.030,
birefringence: 0.0130,
absorption_rgb: [1.8, 1.6, 0.2],
crystal_system: Some("Orthorhombic".to_string()),
optical_character: Some("BiaxialPositive".to_string()),
biaxial_delta_beta_alpha: Some(0.0070),
per_axis_dispersion_json: None,
specific_gravity: None,
color_recipe_json: None,
dispersion_model_json: None,
absorption_bands_json: None,
};
let material = gem_material_from_row(&row);
assert_eq!(material.crystal_system, CrystalSystem::Orthorhombic);
assert_eq!(
material.optical_character,
OpticalCharacter::BiaxialPositive
);
assert_eq!(material.biaxial_delta_beta_alpha, Some(0.0070));
assert!(material.gpu_supported());
}
fn row_named(name: &str, specific_gravity: Option<f32>) -> CustomMaterialRow {
CustomMaterialRow {
name: name.to_string(),
refractive_index: 1.7,
dispersion: 0.02,
birefringence: 0.0,
absorption_rgb: [0.0, 0.0, 0.0],
crystal_system: None,
optical_character: None,
biaxial_delta_beta_alpha: None,
per_axis_dispersion_json: None,
specific_gravity,
color_recipe_json: None,
dispersion_model_json: None,
absorption_bands_json: None,
}
}
#[test]
fn only_rows_with_a_recorded_sg_produce_an_entry() {
let rows = [
row_named("My Garnet", Some(3.90)),
row_named("Custom Diamond", None),
];
let entries = custom_material_specific_gravity_from_rows(&rows);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, "My Garnet");
assert!((entries[0].1 - 3.90_f64).abs() < 1e-6);
}
#[test]
fn an_empty_row_set_produces_no_entries() {
assert_eq!(
custom_material_specific_gravity_from_rows(&[]),
Vec::<(String, f64)>::new()
);
}
#[test]
fn widens_the_stored_f32_without_meaningful_precision_loss() {
let rows = [row_named("My Garnet", Some(3.9))];
let entries = custom_material_specific_gravity_from_rows(&rows);
assert!((entries[0].1 - 3.9_f64).abs() < 1e-6);
}
const BK7_JSON: &str = r#"{"kind":"sellmeier3","b":[1.0396122,0.23179235,1.0104694],"c":[0.006000699,0.020017914,103.56065]}"#;
fn row_with_model(json: Option<&str>) -> CustomMaterialRow {
CustomMaterialRow {
refractive_index: 1.5168,
dispersion: 0.008,
dispersion_model_json: json.map(str::to_string),
..row_named("My Glass", None)
}
}
#[test]
fn a_row_without_a_model_keeps_the_plain_cauchy_fit() {
let row = row_with_model(None);
let material = gem_material_from_row(&row);
let plain = GemMaterial::new_custom(
&row.name,
row.refractive_index,
row.dispersion,
row.birefringence,
row.absorption_rgb,
);
assert_eq!(material.dispersion, plain.dispersion);
assert_eq!(material, plain);
}
#[test]
fn a_row_with_a_model_builds_that_curve() {
let row = row_with_model(Some(BK7_JSON));
let material = gem_material_from_row(&row);
let model = dispersion_model_from_json(BK7_JSON).expect("the fixture is a valid model");
assert_eq!(material.dispersion, model);
assert!(matches!(
material.dispersion,
indicatrix::optics::dispersion::DispersionModel::Sellmeier3 { .. }
));
assert!((material.dispersion.n_d() - 1.5168).abs() < 0.002);
assert_eq!(material.crystal_system, CrystalSystem::Cubic);
assert_eq!(material.optical_character, OpticalCharacter::Isotropic);
}
#[test]
fn an_unusable_stored_model_falls_back_to_the_plain_path() {
let plain = gem_material_from_row(&row_with_model(None));
for json in [
r#"{"kind":"sellmeier1","b1":1.0,"c1":0.36}"#,
r#"{"kind":"cauchy","a":0.5,"b":0.0,"c":0.0}"#,
"not json",
"",
] {
let material = gem_material_from_row(&row_with_model(Some(json)));
assert_eq!(material, plain, "{json}");
}
}
#[test]
fn the_vault_round_trips_the_dispersion_model() {
let db = Database::new(Some(":memory:")).expect("in-memory database");
let model = dispersion_model_from_json(BK7_JSON).expect("valid model");
let material = GemMaterial::new_custom_with_dispersion("My Glass", model, 0.0, [0.0; 3]);
let json = indicatrix_cut_core::native::dispersion_model_to_json(&model);
let fields = MaterialSaveFields {
ri: model.n_d(),
dispersion: model.delta_f_c(),
birefringence: 0.0,
absorption_rgb: [0.0; 3],
specific_gravity: None,
color_recipe_json: None,
dispersion_model_json: Some(&json),
absorption_bands_json: None,
};
save_gem_material_fields(&db, &material, &fields).expect("save");
let rows = db.get_custom_materials().expect("read");
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].dispersion_model_json.as_deref(),
Some(json.as_str())
);
assert_eq!(gem_material_from_row(&rows[0]).dispersion, model);
assert!((rows[0].refractive_index - model.n_d()).abs() < 1e-6);
assert!((rows[0].dispersion - model.delta_f_c()).abs() < 1e-6);
save_gem_material(&db, &material, 1.5168, 0.008, 0.0, [0.0; 3], None, None)
.expect("save plain");
let rows = db.get_custom_materials().expect("read again");
assert_eq!(rows[0].dispersion_model_json, None);
}
}