use crate::{
MainWindow, SettingsModel, ViewportModel,
bridge::render_thread::{RenderContext, resolve_material},
gui::{
is_c_axis_override_available,
optics::crystal_optics::{
crystal_system_from_index, crystal_system_to_index,
custom_material_specific_gravity_from_rows, gem_material_from_row, is_biaxial,
optical_character_from_index, optical_character_to_index, save_gem_material,
},
refresh_material_options, show_toast,
},
};
use indicatrix::optics::materials::{
GemMaterial,
body_colour::{BODY_COLOUR_PRESETS, preset_index_for_rgb},
};
use indicatrix_vault::db::sqlite::Database;
use slint::{ComponentHandle, Model, SharedString};
use std::sync::{Arc, Mutex};
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(super) const CUSTOM_KEEP_COLOR_INDEX: i32 = 9;
fn absorption_rgb_for_color_index(color_idx: i32) -> [f32; 3] {
usize::try_from(color_idx)
.ok()
.and_then(|i| BODY_COLOUR_PRESETS.get(i))
.map_or([0.0, 0.0, 0.0], |preset| preset.absorption_rgb)
}
#[must_use]
fn color_index_for_absorption_rgb(rgb: [f32; 3]) -> Option<i32> {
preset_index_for_rgb(rgb).and_then(|i| i32::try_from(i).ok())
}
struct CustomMaterialForm {
name: SharedString,
ri: f32,
disp: f32,
biref: f32,
color_idx: i32,
crystal_system_idx: i32,
optical_character_idx: i32,
biaxial_delta_beta_alpha: f32,
specific_gravity: f32,
apply_to_live_render: bool,
}
struct SavedCustomMaterial {
trimmed_name: String,
overwrote_existing: bool,
c_axis_available: Option<bool>,
custom_list: Arc<Vec<GemMaterial>>,
applied_to_live_render: bool,
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)
}
}
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,
} = 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 abs_rgb = resolve_absorption_rgb(db, &trimmed, color_idx);
let mut new_mat = GemMaterial::new_custom(&trimmed, ri, disp, biref, abs_rgb);
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(
&db.lock().unwrap_or_else(std::sync::PoisonError::into_inner),
&new_mat,
ri,
disp,
biref,
abs_rgb,
sg,
);
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));
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,
})
}
struct DeletedCustomMaterial {
existed: bool,
c_axis_available: bool,
custom_list: Arc<Vec<GemMaterial>>,
}
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.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,
})
}
fn setup_material_name_probe(ui: &MainWindow, render_ctx: &Arc<Mutex<RenderContext>>) {
let render_ctx_probe = render_ctx.clone();
let ui_weak = ui.as_weak();
ui.global::<ViewportModel>()
.on_material_name_edited(move |name: SharedString| {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let trimmed = name.trim();
let collides_builtin = built_in_name_collision(trimmed).is_some();
let is_existing_custom = {
let ctx = render_ctx_probe
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
ctx.custom_materials
.iter()
.any(|m| m.name.eq_ignore_ascii_case(trimmed))
};
let model = ui.global::<ViewportModel>();
model.set_material_name_collides_with_builtin(collides_builtin);
model.set_material_name_is_existing_custom(is_existing_custom);
});
}
fn push_selected_custom_material_fields(
ui: &MainWindow,
db: &Arc<Mutex<Database>>,
selected_name: &str,
) {
let row = 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(selected_name));
let model = ui.global::<ViewportModel>();
let Some(row) = row else {
model.set_selected_custom_material_valid(false);
return;
};
let material = gem_material_from_row(&row);
model.set_selected_custom_material_valid(true);
model.set_selected_custom_material_ri(row.refractive_index);
model.set_selected_custom_material_dispersion(row.dispersion);
model.set_selected_custom_material_birefringence(row.birefringence);
model.set_selected_custom_material_specific_gravity(row.specific_gravity.unwrap_or(0.0));
model.set_selected_custom_material_crystal_system_idx(crystal_system_to_index(
material.crystal_system,
));
model.set_selected_custom_material_optical_character_idx(optical_character_to_index(
material.optical_character,
));
model.set_selected_custom_material_biaxial_delta_beta_alpha(
material.biaxial_delta_beta_alpha.unwrap_or(0.0),
);
model.set_selected_custom_material_color_idx(
color_index_for_absorption_rgb(row.absorption_rgb).unwrap_or(CUSTOM_KEEP_COLOR_INDEX),
);
}
fn setup_selected_material_prefill_callback(ui: &MainWindow, db: &Arc<Mutex<Database>>) {
let db = Arc::clone(db);
let ui_weak = ui.as_weak();
ui.global::<ViewportModel>()
.on_selected_material_changed_for_editor_prefill(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let idx = usize::try_from(ui.global::<ViewportModel>().get_selected_material_index())
.unwrap_or(0);
let name = ui
.global::<ViewportModel>()
.get_material_options()
.row_data(idx)
.unwrap_or_default();
push_selected_custom_material_fields(&ui, &db, &name);
});
}
fn handle_saved_custom_material(ui: &MainWindow, outcome: &SavedCustomMaterial) {
refresh_material_options(ui, &outcome.custom_list);
if let Some(c_axis_available) = outcome.c_axis_available {
ui.global::<SettingsModel>()
.set_c_axis_override_available(c_axis_available);
}
if outcome.applied_to_live_render {
let viewport = ui.global::<ViewportModel>();
if viewport.get_viewport_material_linked() {
viewport.set_viewport_material_linked(false);
}
if let Some(index) = crate::gui::startup_settings::find_option_index(
&viewport.get_material_options(),
&outcome.trimmed_name,
) {
viewport.set_selected_material_index(index);
}
}
let verb = if outcome.overwrote_existing {
"Overwrote"
} else {
"Saved"
};
let suffix = if outcome.applied_to_live_render {
" and applied"
} else {
""
};
let sg_suffix = outcome
.specific_gravity
.map(|sg| format!(" (SG {sg:.2})"))
.unwrap_or_default();
show_toast(
ui,
&format!(
"{verb}{suffix} custom material '{}'{sg_suffix}",
outcome.trimmed_name
),
"success",
);
}
pub(in crate::gui) fn setup_custom_material_callbacks(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
db: &Arc<Mutex<Database>>,
) {
setup_material_name_probe(ui, render_ctx);
setup_selected_material_prefill_callback(ui, db);
let render_ctx_save = render_ctx.clone();
let db_save = db.clone();
let ui_weak_save = ui.as_weak();
ui.global::<ViewportModel>().on_save_custom_material(
move |name: SharedString,
ri: f32,
disp: f32,
biref: f32,
color_idx: i32,
crystal_system_idx: i32,
optical_character_idx: i32,
biaxial_delta_beta_alpha: f32,
specific_gravity: f32,
apply_to_live_render: bool| {
let result = apply_custom_material_save(
&db_save,
&render_ctx_save,
CustomMaterialForm {
name,
ri,
disp,
biref,
color_idx,
crystal_system_idx,
optical_character_idx,
biaxial_delta_beta_alpha,
specific_gravity,
apply_to_live_render,
},
);
let Some(ui) = ui_weak_save.upgrade() else {
return;
};
match result {
Ok(outcome) => handle_saved_custom_material(&ui, &outcome),
Err(e) => show_toast(&ui, &e, "error"),
}
},
);
let render_ctx_del = render_ctx.clone();
let db_del = db.clone();
let ui_weak_del = ui.as_weak();
ui.global::<ViewportModel>()
.on_delete_custom_material(move |name: SharedString| {
let trimmed = name.trim();
let result = apply_custom_material_delete(&db_del, &render_ctx_del, trimmed);
let Some(ui) = ui_weak_del.upgrade() else {
return;
};
match result {
Ok(outcome) => {
refresh_material_options(&ui, &outcome.custom_list);
ui.global::<SettingsModel>()
.set_c_axis_override_available(outcome.c_axis_available);
if outcome.existed {
show_toast(&ui, &format!("Deleted material '{trimmed}'"), "info");
} else {
show_toast(
&ui,
&format!(
"'{trimmed}' was not a saved custom material -- nothing to delete."
),
"error",
);
}
}
Err(e) => show_toast(&ui, &e, "error"),
}
});
}