use super::*;
const OFF: MaterialOverrides = MaterialOverrides {
inclusion_sigma_s: 0.0,
c_axis_override: None,
edge_rounding_radius: 0.0,
stone_width_mm: 0.0,
};
fn sized(width_mm: f32) -> MaterialOverrides {
MaterialOverrides {
stone_width_mm: width_mm,
..OFF
}
}
#[test]
fn model_unit_materials_scale_with_the_width_over_seven_mm() {
let mut materials: Vec<GemMaterial> = GemMaterial::all_materials()
.into_iter()
.filter(|m| m.absorption_unit == AbsorptionUnit::ModelUnit)
.collect();
materials.push(GemMaterial::sapphire().with_body_color([0.2, 0.9, 0.3]));
materials.push(GemMaterial::new_custom(
"Fantasy",
1.7,
0.02,
0.0,
[0.1, 0.4, 0.9],
));
for material in materials {
assert_eq!(material.absorption_unit, AbsorptionUnit::ModelUnit);
for model_width in [None, Some(1.3), Some(2.0), Some(6.0)] {
let unset = apply_material_overrides(material.clone(), &OFF, model_width);
let calibrated = 1.0 / MODEL_UNIT_FACE_UP_PATH;
assert!(
(unset.absorption_path_scale - calibrated).abs() < 1e-7,
"{}",
material.name
);
let seven = apply_material_overrides(material.clone(), &sized(7.0), model_width);
assert_eq!(
seven.absorption_path_scale, unset.absorption_path_scale,
"{}",
material.name
);
let big = apply_material_overrides(material.clone(), &sized(10.87), model_width);
assert!(
(big.absorption_path_scale - 10.87 / 7.0 / MODEL_UNIT_FACE_UP_PATH).abs() < 1e-6,
"{}: {}",
material.name,
big.absorption_path_scale
);
}
}
}
#[test]
fn per_mm_materials_scale_with_the_width_over_the_model_width() {
let banded = GemMaterial::sapphire().with_body_color_bands(&[[550.0, 60.0, 0.3]], 1.0);
assert_eq!(banded.absorption_unit, AbsorptionUnit::PerMm);
let model_width = 2.1_f64;
let unset = apply_material_overrides(banded.clone(), &OFF, Some(model_width));
assert!((f64::from(unset.absorption_path_scale) - 7.0 / model_width).abs() < 1e-5);
let sized_up = apply_material_overrides(banded.clone(), &sized(10.87), Some(model_width));
assert!((f64::from(sized_up.absorption_path_scale) - 10.87 / model_width).abs() < 1e-5);
let unmeasured = apply_material_overrides(banded.clone(), &sized(10.87), None);
assert_eq!(
unmeasured.absorption_path_scale,
banded.absorption_path_scale
);
assert!(needs_model_width(&banded));
assert!(!needs_model_width(&GemMaterial::sapphire()));
}
#[test]
fn the_stone_size_never_changes_the_scattering_coefficient() {
let with = |width_mm: f32| {
apply_material_overrides(
GemMaterial::sapphire(),
&MaterialOverrides {
inclusion_sigma_s: 0.2,
stone_width_mm: width_mm,
..OFF
},
Some(2.0),
)
};
assert_eq!(with(0.0).scattering_sigma_s, 0.2);
assert_eq!(with(5.0).scattering_sigma_s, 0.2);
assert_eq!(with(12.0).scattering_sigma_s, 0.2);
}
#[test]
fn effective_width_defaults_to_seven_mm() {
assert_eq!(effective_stone_width_mm(0.0), 7.0);
assert_eq!(effective_stone_width_mm(5.0), 5.0);
}
const BUILT_IN_UNITS: &[(&str, AbsorptionUnit)] = &[
("Diamond", AbsorptionUnit::ModelUnit),
("Sapphire", AbsorptionUnit::ModelUnit),
("Ruby", AbsorptionUnit::ModelUnit),
("Emerald", AbsorptionUnit::ModelUnit),
("Zircon", AbsorptionUnit::ModelUnit),
("Alexandrite", AbsorptionUnit::ModelUnit),
("Topaz", AbsorptionUnit::ModelUnit),
("Spinel", AbsorptionUnit::ModelUnit),
("Quartz", AbsorptionUnit::ModelUnit),
("Tourmaline", AbsorptionUnit::ModelUnit),
("Tanzanite", AbsorptionUnit::ModelUnit),
("Synthetic Moissanite", AbsorptionUnit::ModelUnit),
("Cubic Zirconia", AbsorptionUnit::ModelUnit),
("Aquamarine", AbsorptionUnit::ModelUnit),
("Morganite", AbsorptionUnit::ModelUnit),
("Chrysoberyl (Yellow)", AbsorptionUnit::ModelUnit),
("Amethyst", AbsorptionUnit::ModelUnit),
("Citrine", AbsorptionUnit::ModelUnit),
("Pyrope Garnet", AbsorptionUnit::ModelUnit),
("Almandine Garnet", AbsorptionUnit::ModelUnit),
("Spessartine Garnet", AbsorptionUnit::ModelUnit),
(
"Color-Change Garnet (Pyrope-Spessartine)",
AbsorptionUnit::ModelUnit,
),
("Grossular Garnet (Tsavorite)", AbsorptionUnit::ModelUnit),
("Andradite Garnet (Demantoid)", AbsorptionUnit::ModelUnit),
("Peridot", AbsorptionUnit::ModelUnit),
("YAG", AbsorptionUnit::ModelUnit),
("GGG", AbsorptionUnit::ModelUnit),
("Benitoite", AbsorptionUnit::ModelUnit),
("Andalusite", AbsorptionUnit::ModelUnit),
("Opal", AbsorptionUnit::ModelUnit),
("Glass (N-BK7)", AbsorptionUnit::ModelUnit),
("Glass (F2)", AbsorptionUnit::ModelUnit),
("Rutile", AbsorptionUnit::ModelUnit),
];
const ZONED_BUILT_IN_UNITS: &[(&str, AbsorptionUnit)] = &[("Ametrine", AbsorptionUnit::PerMm)];
fn density(material: &GemMaterial, lambda_nm: f32, path_units: f32) -> f64 {
let alpha: f32 = material
.absorption
.o_ray
.iter()
.map(|band| band.evaluate(lambda_nm))
.sum();
f64::from(alpha) * f64::from(material.absorption_path_scale) * f64::from(path_units)
}
fn transmittance(material: &GemMaterial, lambda_nm: f32, path_units: f32) -> f64 {
(-density(material, lambda_nm, path_units)).exp()
}
#[test]
fn every_built_in_has_a_deliberate_absorption_unit() {
let materials = GemMaterial::all_materials();
let decisions = || BUILT_IN_UNITS.iter().chain(ZONED_BUILT_IN_UNITS);
for material in &materials {
let decided = decisions()
.find(|(name, _)| *name == material.name)
.unwrap_or_else(|| {
panic!(
"built-in {} has no entry in BUILT_IN_UNITS: decide its absorption unit",
material.name
)
});
assert_eq!(material.absorption_unit, decided.1, "{}", material.name);
}
assert_eq!(
materials.len(),
decisions().count(),
"BUILT_IN_UNITS / ZONED_BUILT_IN_UNITS lists a material that no longer exists"
);
}
#[test]
fn every_built_in_scales_with_the_size_only() {
for material in GemMaterial::all_materials() {
let reference = apply_material_overrides(material.clone(), &OFF, Some(2.0));
let per_mm = material.absorption_unit == AbsorptionUnit::PerMm;
for model_width in [None, Some(1.0), Some(2.0), Some(3.7)] {
if per_mm && model_width.is_none() {
continue;
}
let unset = apply_material_overrides(material.clone(), &OFF, model_width);
let seven = apply_material_overrides(material.clone(), &sized(7.0), model_width);
let fourteen = apply_material_overrides(material.clone(), &sized(14.0), model_width);
assert_eq!(unset, seven, "{}: unset != 7 mm", material.name);
if !per_mm {
assert_eq!(unset, reference, "{}: model width mattered", material.name);
}
assert_eq!(
fourteen.absorption_path_scale,
2.0 * seven.absorption_path_scale,
"{}",
material.name
);
for lambda in [450.0_f32, 530.0, 650.0] {
assert_eq!(
density(&fourteen, lambda, 5.0),
2.0 * density(&seven, lambda, 5.0),
"{} at {lambda} nm",
material.name
);
}
}
}
}
fn green_preset() -> [f32; 3] {
crate::optics::materials::body_color::BODY_COLOR_PRESETS
.iter()
.find(|preset| preset.label.to_ascii_lowercase().contains("green"))
.expect("a green body-colour preset")
.absorption_rgb
}
fn bare(material: GemMaterial) -> GemMaterial {
material.with_absorption_path_scale(1.0)
}
fn one_unit_swatch_transmittance(material: &GemMaterial, lambda_nm: f32) -> f64 {
transmittance(&bare(material.clone()), lambda_nm, 1.0)
}
#[test]
fn regression_green_cubic_zirconia_at_10_87_mm_is_not_black() {
let green = GemMaterial::by_name("Cubic Zirconia")
.expect("built-in")
.with_body_color(green_preset());
let t1 = one_unit_swatch_transmittance(&green, 550.0);
let expected = t1.powf(f64::from(10.87_f32 / 7.0));
for model_width in [1.8, 2.0, 2.2] {
let stone = apply_material_overrides(green.clone(), &sized(10.87), Some(model_width));
let t_green = transmittance(&stone, 550.0, MODEL_UNIT_FACE_UP_PATH);
assert!(
(t_green / expected - 1.0).abs() < 1e-3,
"model width {model_width}: green T {t_green} vs analytic {expected}"
);
assert!(
t_green > 0.05,
"model width {model_width}: green T {t_green} (old behaviour: 3e-5)"
);
assert!(t_green > transmittance(&stone, 450.0, MODEL_UNIT_FACE_UP_PATH));
assert!(t_green > transmittance(&stone, 650.0, MODEL_UNIT_FACE_UP_PATH));
}
}
#[test]
fn regression_emerald_and_ruby_at_10_87_mm_stay_coloured() {
let factor = f64::from(10.87_f32 / 7.0);
let emerald = apply_material_overrides(GemMaterial::emerald(), &sized(10.87), Some(2.0));
let green = transmittance(&emerald, 530.0, MODEL_UNIT_FACE_UP_PATH);
let expected = one_unit_swatch_transmittance(&GemMaterial::emerald(), 530.0).powf(factor);
assert!(
(green / expected - 1.0).abs() < 1e-3,
"emerald {green} vs {expected}"
);
assert!(
green > 0.2,
"emerald green T {green} (old behaviour: 0.012)"
);
assert!(green > 1.5 * transmittance(&emerald, 650.0, MODEL_UNIT_FACE_UP_PATH));
let ruby = apply_material_overrides(GemMaterial::ruby(), &sized(10.87), Some(2.0));
let red = transmittance(&ruby, 650.0, MODEL_UNIT_FACE_UP_PATH);
let expected = one_unit_swatch_transmittance(&GemMaterial::ruby(), 650.0).powf(factor);
assert!(
(red / expected - 1.0).abs() < 1e-3,
"ruby {red} vs {expected}"
);
assert!(red > 0.05, "ruby red T {red}");
assert!(red > 1.5 * transmittance(&ruby, 530.0, MODEL_UNIT_FACE_UP_PATH));
}
#[test]
fn unset_and_seven_mm_face_up_path_renders_the_one_unit_swatch() {
use crate::optics::materials::body_color::BODY_COLOR_PRESETS;
let mut materials: Vec<GemMaterial> = GemMaterial::all_materials()
.into_iter()
.filter(|m| m.absorption_unit == AbsorptionUnit::ModelUnit)
.collect();
for preset in &BODY_COLOR_PRESETS {
materials.push(
GemMaterial::by_name("Cubic Zirconia")
.expect("built-in")
.with_body_color(preset.absorption_rgb),
);
}
for material in materials {
assert_eq!(material.absorption_unit, AbsorptionUnit::ModelUnit);
for overrides in [OFF, sized(7.0)] {
let stone = apply_material_overrides(material.clone(), &overrides, Some(2.0));
for lambda in [420.0_f32, 450.0, 500.0, 530.0, 580.0, 620.0, 650.0, 700.0] {
let face_up = density(&stone, lambda, MODEL_UNIT_FACE_UP_PATH);
let swatch = density(&bare(material.clone()), lambda, 1.0);
assert!(
(face_up - swatch).abs() <= 1e-5 * swatch.max(1.0),
"{} at {lambda} nm: face-up density {face_up} vs swatch {swatch}",
material.name
);
}
}
}
}
#[test]
fn the_swatch_equals_the_render_material_at_every_size() {
use crate::{
color::body_color::{Illuminant, body_colors},
optics::materials::body_color::BODY_COLOR_PRESETS,
};
for preset in &BODY_COLOR_PRESETS {
let material = GemMaterial::by_name("Cubic Zirconia")
.expect("built-in")
.with_body_color(preset.absorption_rgb);
for width_mm in [5.0_f32, 7.0, 10.87] {
let stone = apply_material_overrides(material.clone(), &sized(width_mm), Some(2.0));
let render = body_colors(
&stone.absorption,
f64::from(MODEL_UNIT_FACE_UP_PATH) * f64::from(stone.absorption_path_scale),
Illuminant::D65,
)
.unpolarised;
let swatch = body_colors(
&bare(material.clone()).absorption,
f64::from(width_mm) / 7.0,
Illuminant::D65,
)
.unpolarised;
for (r, s) in render.lab.iter().zip(swatch.lab) {
assert!(
(r - s).abs() < 1e-3,
"{} at {width_mm} mm: render {:?} vs swatch {:?}",
preset.label,
render.lab,
swatch.lab
);
}
}
}
}
#[test]
fn the_calibration_constant_never_reaches_per_mm_materials() {
for (width_mm, model_width) in [(0.0_f32, 2.0_f64), (7.0, 2.0), (10.87, 2.0), (10.87, 3.5)] {
let scale = absorption_path_scale_for(AbsorptionUnit::PerMm, width_mm, Some(model_width))
.expect("a measurable width");
let expected = f64::from(effective_stone_width_mm(width_mm)) / model_width;
assert!((f64::from(scale) - expected).abs() < 1e-5, "{width_mm} mm");
}
}
#[test]
fn model_unit_face_up_path_matches_the_measured_cuts() {
use crate::{
color::{
body_color::{Illuminant, body_colors},
metrics::evaluate_gem_optical_metrics_with_tone,
},
geometry::cuts::StandardGemCuts,
optics::raytracer::LightingPreset,
};
use std::{f32::consts::FRAC_PI_2, fmt::Write as _};
let environment = LightingPreset::LightTent.studio(1.0, 0.85, 0.95);
let cuts = [
(
"round brilliant",
StandardGemCuts::standard_round_brilliant(),
),
("emerald cut", StandardGemCuts::emerald_cut()),
];
let green = bare(
GemMaterial::by_name("Cubic Zirconia")
.expect("built-in")
.with_body_color(green_preset()),
);
let mut mean_paths = Vec::new();
let mut equivalent_paths = Vec::new();
let mut report = String::new();
for (name, planes) in &cuts {
let (_, tone) =
evaluate_gem_optical_metrics_with_tone(planes, &green, 0.0, FRAC_PI_2, environment);
let (mut low, mut high) = (0.01_f64, 80.0_f64);
for _ in 0..60 {
let mid = f64::midpoint(low, high);
let l_star = body_colors(&green.absorption, mid, Illuminant::D65)
.unpolarised
.lab[0];
if l_star > f64::from(tone.l_star) {
low = mid;
} else {
high = mid;
}
}
let equivalent = f64::midpoint(low, high);
let _ = write!(
report,
"{name}: mean_path_units {}, mixture-equivalent path {equivalent:.2}; ",
tone.mean_path_units
);
mean_paths.push(f64::from(tone.mean_path_units));
equivalent_paths.push(equivalent);
}
let median = |values: &mut Vec<f64>| {
values.sort_by(f64::total_cmp);
let mid = values.len() / 2;
if values.len().is_multiple_of(2) {
f64::midpoint(values[mid - 1], values[mid])
} else {
values[mid]
}
};
let mean_median = median(&mut mean_paths);
let equivalent_median = median(&mut equivalent_paths);
let constant = f64::from(MODEL_UNIT_FACE_UP_PATH);
assert!(
(constant / mean_median - 1.0).abs() <= 0.20,
"MODEL_UNIT_FACE_UP_PATH {constant} is not within 20 % of the median mean path \
{mean_median:.2} ({report}equivalent median {equivalent_median:.2}): re-pin it"
);
assert!(
constant > 0.4 * equivalent_median && constant < 2.5 * equivalent_median,
"MODEL_UNIT_FACE_UP_PATH {constant} is far from the mixture-equivalent path \
{equivalent_median:.2} ({report}mean median {mean_median:.2})"
);
}
#[test]
fn the_scoring_material_equals_the_render_material_for_a_sized_stone() {
use crate::{geometry::cuts::StandardGemCuts, render_setup::measure_model_width};
let planes = StandardGemCuts::standard_round_brilliant();
let width = measure_model_width(&planes);
let materials = [
GemMaterial::emerald(),
GemMaterial::sapphire().with_body_color([0.2, 0.9, 0.3]),
GemMaterial::sapphire().with_body_color_bands(&[[550.0, 60.0, 0.3]], 1.0),
];
for material in materials {
for width_mm in [0.0_f32, 7.0, 10.87] {
assert_eq!(
material_for_stone(material.clone(), width_mm, &planes),
apply_material_overrides(material.clone(), &sized(width_mm), width),
"{} at {width_mm} mm",
material.name
);
}
}
}
#[test]
fn per_mm_materials_scale_with_width_over_model_width_whatever_made_them() {
let bands = GemMaterial::sapphire().with_body_color_bands(&[[460.0, 45.0, 0.25]], 3.5);
let physics = GemMaterial::sapphire().with_chromophore_absorption(bands.absorption.clone());
for material in [bands, physics] {
assert_eq!(material.absorption_unit, AbsorptionUnit::PerMm);
for model_width in [1.0_f64, 2.0, 3.7] {
let unset = apply_material_overrides(material.clone(), &OFF, Some(model_width));
assert!((f64::from(unset.absorption_path_scale) - 7.0 / model_width).abs() < 1e-5);
let big = apply_material_overrides(material.clone(), &sized(10.87), Some(model_width));
let expected = f64::from(10.87_f32) / model_width;
assert!((f64::from(big.absorption_path_scale) - expected).abs() < 1e-5);
}
}
}
#[test]
fn the_zoned_built_in_scales_with_the_width_over_the_model_width() {
let ametrine = GemMaterial::ametrine();
assert!(needs_model_width(&ametrine));
let unset = apply_material_overrides(ametrine.clone(), &OFF, Some(2.0));
assert!(
(unset.absorption_path_scale - 3.5).abs() < 1e-6,
"7 mm / 2.0"
);
let ten = apply_material_overrides(ametrine, &sized(10.0), Some(2.0));
assert!(
(ten.absorption_path_scale - 5.0).abs() < 1e-6,
"10 mm / 2.0"
);
let zoning = crate::optics::materials::ametrine_zoning();
assert_eq!(unset.zoning.as_ref(), Some(&zoning));
assert_eq!(ten.zoning.as_ref(), Some(&zoning));
}