use crate::{
bridge::render_thread::{RenderContext, resolve_material},
gui::{
is_c_axis_override_available,
optics::{
crystal_optics::{
MaterialSaveFields, crystal_system_from_index,
custom_material_specific_gravity_from_rows, is_biaxial,
optical_character_from_index, save_gem_material_fields,
},
dispersion_editor::model_from_save_json,
},
},
};
use indicatrix::optics::{
chromophore::ChromophoreCatalogue,
fluorescence::Fluorescence,
materials::{
GemMaterial,
body_color::{BODY_COLOR_PRESETS, preset_index_for_rgb},
},
};
use indicatrix_cut_core::{
material::{ColorMode, absorption_bands_to_json, with_library_bands},
native::dispersion_model_to_json,
};
use indicatrix_vault::db::sqlite::Database;
use slint::SharedString;
use std::sync::{Arc, Mutex};
pub(super) fn built_in_name_collision(name: &str) -> Option<String> {
GemMaterial::all_materials()
.into_iter()
.map(|m| m.name)
.find(|builtin| builtin.eq_ignore_ascii_case(name))
}
pub(in crate::gui::optics) const CUSTOM_KEEP_COLOR_INDEX: i32 = 9;
pub(in crate::gui::optics) fn absorption_rgb_for_color_index(color_idx: i32) -> [f32; 3] {
usize::try_from(color_idx)
.ok()
.and_then(|i| BODY_COLOR_PRESETS.get(i))
.map_or([0.0, 0.0, 0.0], |preset| preset.absorption_rgb)
}
#[must_use]
pub(in crate::gui::optics) fn color_index_for_absorption_rgb(rgb: [f32; 3]) -> Option<i32> {
preset_index_for_rgb(rgb).and_then(|i| i32::try_from(i).ok())
}
pub(super) struct CustomMaterialForm {
pub(super) name: SharedString,
pub(super) ri: f32,
pub(super) disp: f32,
pub(super) biref: f32,
pub(super) color_idx: i32,
pub(super) crystal_system_idx: i32,
pub(super) optical_character_idx: i32,
pub(super) biaxial_delta_beta_alpha: f32,
pub(super) specific_gravity: f32,
pub(super) apply_to_live_render: bool,
pub(super) color_recipe_json: SharedString,
pub(super) dispersion_model_json: SharedString,
pub(super) body_bands: Option<Vec<[f32; 3]>>,
}
pub(super) struct SavedCustomMaterial {
pub(super) trimmed_name: String,
pub(super) overwrote_existing: bool,
pub(super) c_axis_available: Option<bool>,
pub(super) custom_list: Arc<Vec<GemMaterial>>,
pub(super) applied_to_live_render: bool,
pub(super) specific_gravity: Option<f64>,
}
fn resolve_absorption_rgb(db: &Arc<Mutex<Database>>, name: &str, color_idx: i32) -> [f32; 3] {
if color_idx == CUSTOM_KEEP_COLOR_INDEX {
db.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get_custom_materials()
.unwrap_or_default()
.into_iter()
.find(|r| r.name.eq_ignore_ascii_case(name))
.map_or([0.0f32, 0.0, 0.0], |r| r.absorption_rgb)
} else {
absorption_rgb_for_color_index(color_idx)
}
}
#[expect(
clippy::too_many_lines,
reason = "one linear validate -> color -> persist -> publish sequence; the steps share the locals"
)]
pub(super) fn apply_custom_material_save(
db: &Arc<Mutex<Database>>,
render_ctx: &Arc<Mutex<RenderContext>>,
form: CustomMaterialForm,
) -> Result<SavedCustomMaterial, String> {
let CustomMaterialForm {
name,
ri,
disp,
biref,
color_idx,
crystal_system_idx,
optical_character_idx,
biaxial_delta_beta_alpha,
specific_gravity,
apply_to_live_render,
color_recipe_json,
dispersion_model_json,
body_bands,
} = form;
let trimmed = name.trim().to_string();
if trimmed.is_empty() {
return Err("A material name is required.".to_string());
}
if let Some(builtin) = built_in_name_collision(&trimmed) {
return Err(format!(
"'{trimmed}' is a built-in material name -- saving a custom \
material under it would silently shadow '{builtin}' everywhere \
this design's material is used. Rename it first."
));
}
let dispersion_model = model_from_save_json(&dispersion_model_json)?;
let (ri, disp) = dispersion_model
.as_ref()
.map_or((ri, disp), |m| (m.n_d(), m.delta_f_c()));
let dispersion_model_text = dispersion_model.as_ref().map(dispersion_model_to_json);
let input_json = color_recipe_json.trim();
let parsed = ColorMode::from_json(input_json);
let mut mode = parsed.clone();
if let Some(mode) = mode.as_mut() {
if color_idx != CUSTOM_KEEP_COLOR_INDEX {
mode.fantasy_rgb = absorption_rgb_for_color_index(color_idx);
}
}
let abs_rgb = mode.as_ref().map_or_else(
|| resolve_absorption_rgb(db, &trimmed, color_idx),
ColorMode::fallback_rgb,
);
let color_json = mode.as_ref().map(|m| {
if parsed.as_ref() == Some(m) {
input_json.to_string()
} else {
m.to_json()
}
});
let color_recipe_opt = color_json.as_deref();
let is_physics = mode.as_ref().is_some_and(ColorMode::is_physics);
let glow = mode.as_ref().map_or_else(
|| Fluorescence::new(Vec::new()),
|m| m.fluorescence(ChromophoreCatalogue::global()),
);
let mut new_mat = dispersion_model.map_or_else(
|| GemMaterial::new_custom(&trimmed, ri, disp, biref, abs_rgb),
|model| GemMaterial::new_custom_with_dispersion(&trimmed, model, biref, abs_rgb),
);
if let Some(mode) = mode.as_ref().filter(|m| m.is_physics()) {
new_mat.absorption = mode.resolve_tensor();
}
let band_rows = body_bands.filter(|rows| !is_physics && !rows.is_empty());
let bands_json = band_rows.as_deref().and_then(absorption_bands_to_json);
if let Some(rows) = band_rows.as_deref() {
new_mat = with_library_bands(new_mat, rows);
}
if let Some(cs) = crystal_system_from_index(crystal_system_idx) {
new_mat.crystal_system = cs;
}
if let Some(oc) = optical_character_from_index(optical_character_idx) {
new_mat.optical_character = oc;
}
new_mat.biaxial_delta_beta_alpha =
is_biaxial(new_mat.optical_character).then_some(biaxial_delta_beta_alpha);
let sg = (specific_gravity > 0.0001).then_some(specific_gravity);
let save_result = save_gem_material_fields(
&db.lock().unwrap_or_else(std::sync::PoisonError::into_inner),
&new_mat,
&MaterialSaveFields {
ri,
dispersion: disp,
birefringence: biref,
absorption_rgb: abs_rgb,
specific_gravity: sg,
color_recipe_json: color_recipe_opt,
dispersion_model_json: dispersion_model_text.as_deref(),
absorption_bands_json: bands_json.as_deref(),
},
);
if let Err(e) = save_result {
return Err(format!("Could not save '{trimmed}': {e}"));
}
let c_axis_available = apply_to_live_render.then(|| is_c_axis_override_available(&new_mat));
let sg_rows = db
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get_custom_materials()
.unwrap_or_default();
let mut ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let materials = Arc::make_mut(&mut ctx.custom_materials);
let overwrote_existing = materials
.iter()
.any(|m| m.name.eq_ignore_ascii_case(&trimmed));
let saved_mat = new_mat.clone();
if let Some(pos) = materials
.iter()
.position(|m| m.name.eq_ignore_ascii_case(&trimmed))
{
materials[pos] = new_mat;
} else {
materials.push(new_mat);
}
ctx.custom_material_specific_gravity =
Arc::new(custom_material_specific_gravity_from_rows(&sg_rows));
ctx.set_custom_material_physics(&trimmed, is_physics);
ctx.set_custom_material_fluorescence(&trimmed, glow);
let saved_specific_gravity = ctx.custom_specific_gravity(&trimmed);
if apply_to_live_render {
ctx.material_name.clone_from(&trimmed);
ctx.material_override = None;
ctx.material_unresolved = None;
ctx.dirty = true;
} else if ctx
.material_override
.as_ref()
.is_some_and(|m| m.name.eq_ignore_ascii_case(&trimmed))
{
ctx.material_override = Some(saved_mat);
ctx.dirty = true;
}
let custom_list = ctx.custom_materials.clone();
drop(ctx);
Ok(SavedCustomMaterial {
trimmed_name: trimmed,
overwrote_existing,
c_axis_available,
custom_list,
applied_to_live_render: apply_to_live_render,
specific_gravity: saved_specific_gravity,
})
}
pub(super) struct DeletedCustomMaterial {
pub(super) existed: bool,
pub(super) c_axis_available: bool,
pub(super) custom_list: Arc<Vec<GemMaterial>>,
}
pub(super) fn apply_custom_material_delete(
db: &Arc<Mutex<Database>>,
render_ctx: &Arc<Mutex<RenderContext>>,
name: &str,
) -> Result<DeletedCustomMaterial, String> {
if name.is_empty() {
return Err("No material name given to delete.".to_string());
}
let delete_result = db
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.delete_custom_material(name);
if let Err(e) = delete_result {
return Err(format!("Could not delete '{name}': {e}"));
}
let sg_rows = db
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get_custom_materials()
.unwrap_or_default();
let mut ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let materials = Arc::make_mut(&mut ctx.custom_materials);
let existed = materials.iter().any(|m| m.name.eq_ignore_ascii_case(name));
materials.retain(|m| !m.name.eq_ignore_ascii_case(name));
ctx.set_custom_material_physics(name, false);
ctx.set_custom_material_fluorescence(name, Fluorescence::new(Vec::new()));
ctx.custom_material_specific_gravity =
Arc::new(custom_material_specific_gravity_from_rows(&sg_rows));
let override_matches_deleted = ctx
.material_override
.as_ref()
.is_some_and(|m| m.name.eq_ignore_ascii_case(name));
if ctx.material_name.eq_ignore_ascii_case(name) || override_matches_deleted {
ctx.material_name = "Diamond".to_string();
ctx.material_override = None;
ctx.material_unresolved = None;
}
ctx.dirty = true;
let c_axis_available = resolve_material(
&GemMaterial::all_materials(),
&ctx.custom_materials,
&ctx.material_name,
)
.as_ref()
.is_some_and(is_c_axis_override_available);
let custom_list = ctx.custom_materials.clone();
drop(ctx);
Ok(DeletedCustomMaterial {
existed,
c_axis_available,
custom_list,
})
}