use super::state::{
EditorState, ScratchDelta, builtin_preset_names, design_material_index_from_name,
gear_index_from_teeth, index_chip_items, push_rows, ri_source_text,
};
use crate::{
EditorModel, IndexChipItem, MainWindow, ViewportModel,
bridge::render_thread::RenderContext,
gui::editor::material_lookup::{
EditorMaterialLookup, MATERIAL_MATCH_TOLERANCE, material_for_refractive_index,
material_guess_candidates, nearest_built_in_material, traced_gem_material,
},
};
use indicatrix_cut_core::{Design, MaterialLookup, critical_angle_deg};
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
use std::sync::{Arc, Mutex};
fn traced_material_for(
design: &Design,
custom: &[indicatrix::optics::materials::GemMaterial],
) -> (String, Option<String>) {
if let Some(name) = &design.material.name {
return if EditorMaterialLookup::new(custom).lookup(name).is_some() {
(name.clone(), None)
} else {
(
String::new(),
Some(format!(
"This design's material '{name}' is not a built-in preset or a \
saved custom material, so there is nothing to trace -- pick a \
material in Design Settings, or re-save the missing custom \
material, rather than rendering it as something else."
)),
)
};
}
let n_d = design.effective_refractive_index();
if let Some((name, _)) = nearest_built_in_material(n_d, MATERIAL_MATCH_TOLERANCE) {
return (name, None);
}
(
String::new(),
Some(format!(
"This design names no material, and its refractive index ({n_d:.4}) matches \
no built-in preset within {MATERIAL_MATCH_TOLERANCE:.2}. Pick a material in \
Design Settings -- rendering it as something else would give you the optics \
of a different stone."
)),
)
}
fn push_material_guess(ui: &MainWindow, design: &Design, n_d: f64) {
let clear = || {
ui.global::<EditorModel>()
.set_material_guess_text(SharedString::new());
ui.global::<EditorModel>()
.set_material_guess_name(SharedString::new());
ui.global::<EditorModel>()
.set_material_guess_other_candidates_text(SharedString::new());
};
if design.material.name.is_some() {
clear();
return;
}
let Some((name, _)) = material_for_refractive_index(n_d) else {
clear();
return;
};
ui.global::<EditorModel>()
.set_material_guess_text(format!("{name}? (from RI {n_d:.2})").into());
ui.global::<EditorModel>()
.set_material_guess_name(name.clone().into());
let others: Vec<String> = material_guess_candidates(n_d, MATERIAL_MATCH_TOLERANCE)
.into_iter()
.filter(|(candidate, _)| candidate != &name)
.map(|(candidate, ri)| format!("{candidate} ({ri:.3})"))
.collect();
ui.global::<EditorModel>()
.set_material_guess_other_candidates_text(if others.is_empty() {
SharedString::new()
} else {
format!("Also within tolerance: {}", others.join(", ")).into()
});
}
pub(super) fn refresh_design_settings(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
state: &EditorState,
delta: &ScratchDelta,
) -> f64 {
let design = &state.design;
let (n_d, options) = {
let ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let n_d = design.effective_refractive_index_with(&ctx.custom_materials);
ui.global::<EditorModel>()
.set_ri_source_text(ri_source_text(&design.material, &ctx.custom_materials).into());
let options = state.material_combo_options(&ctx.custom_materials);
drop(ctx);
(n_d, options)
};
let selected_material_index = {
let mut ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
sync_viewport_material_link(ui, &mut ctx, design)
};
if let Some(idx) = selected_material_index {
ui.global::<ViewportModel>()
.set_selected_material_index(idx);
}
let current_options = ui.global::<EditorModel>().get_material_combo_options();
let unchanged = current_options.row_count() == options.len()
&& current_options
.iter()
.zip(options.iter())
.all(|(current, new)| current == new.as_str());
if !unchanged {
ui.global::<EditorModel>()
.set_material_combo_options(ModelRc::new(VecModel::from(
options
.iter()
.cloned()
.map(SharedString::from)
.collect::<Vec<_>>(),
)));
}
ui.global::<EditorModel>()
.set_new_material_options(ModelRc::new(VecModel::from(
builtin_preset_names()
.into_iter()
.map(SharedString::from)
.collect::<Vec<_>>(),
)));
if delta.material {
ui.global::<EditorModel>()
.set_material_combo_index(design_material_index_from_name(
design.material.name.as_deref(),
&options,
));
ui.global::<EditorModel>().set_ri_override_text(
design
.material
.refractive_index_override
.map_or_else(String::new, |v| format!("{v:.4}"))
.into(),
);
}
ui.global::<EditorModel>()
.set_effective_ri_text(format!("{n_d:.4}").into());
ui.global::<EditorModel>()
.set_critical_angle_text(format!("{:.2}\u{b0}", critical_angle_deg(n_d)).into());
push_material_guess(ui, design, n_d);
if delta.gear {
ui.global::<EditorModel>()
.set_gear_index(gear_index_from_teeth(design.meta.gear_teeth));
ui.global::<EditorModel>()
.set_gear_custom_text(design.meta.gear_teeth.to_string().into());
}
if delta.symmetry {
ui.global::<EditorModel>()
.set_symmetry_order_text(design.meta.symmetry_order.to_string().into());
ui.global::<EditorModel>().set_mirror(design.meta.mirror);
ui.global::<EditorModel>()
.set_symmetry_preview_text("".into());
}
if delta.meta {
let (title, extra_headers) = design
.meta
.headers
.split_first()
.map_or((String::new(), String::new()), |(title, rest)| {
(title.clone(), rest.join(";"))
});
ui.global::<EditorModel>().set_design_title(title.into());
ui.global::<EditorModel>()
.set_design_extra_headers(extra_headers.into());
ui.global::<EditorModel>()
.set_design_footnotes(design.meta.footnotes.join(";").into());
ui.global::<EditorModel>().set_design_gear_reference_angle(
format!("{}", design.meta.gear_reference_angle).into(),
);
}
n_d
}
#[must_use]
pub(in crate::gui::editor) fn sync_viewport_material_link(
ui: &MainWindow,
ctx: &mut RenderContext,
design: &Design,
) -> Option<i32> {
if !ui.global::<ViewportModel>().get_viewport_material_linked() {
return None;
}
let (name, unresolved) = traced_material_for(design, &ctx.custom_materials);
if ctx.material_unresolved != unresolved {
ctx.material_unresolved.clone_from(&unresolved);
ctx.dirty = true;
}
let resolved = unresolved
.is_none()
.then(|| {
traced_gem_material(
&name,
&design.material,
&EditorMaterialLookup::new(&ctx.custom_materials),
)
})
.flatten();
if ctx.material_override != resolved {
ctx.material_override = resolved;
ctx.dirty = true;
}
if ctx.material_name != name {
ctx.material_name.clone_from(&name);
ctx.dirty = true;
}
let options = ui.global::<ViewportModel>().get_material_options();
let selected_material_index = crate::gui::startup_settings::find_option_index(&options, &name);
let stone_width_mm = design.girdle_diameter_mm.unwrap_or(0.0) as f32;
if (ctx.stone_width_mm - stone_width_mm).abs() > f32::EPSILON {
ctx.stone_width_mm = stone_width_mm;
ctx.dirty = true;
}
selected_material_index
}
#[must_use]
fn selected_tier_chips(design: &Design, selected_tier_index: i32) -> Vec<IndexChipItem> {
usize::try_from(selected_tier_index)
.ok()
.and_then(|idx| design.tiers.get(idx))
.map_or_else(Vec::new, |tier| {
index_chip_items(&tier.indices, &tier.detached)
})
}
pub(in crate::gui::editor) fn push_selected_tier_chips(ui: &MainWindow, state: &EditorState) {
let selected = ui.global::<EditorModel>().get_selected_tier_index();
let cheater_offset_text = usize::try_from(selected)
.ok()
.and_then(|index| state.design.cheater_offset_deg(index))
.map_or_else(String::new, |deg| format!("{deg:.2}"));
ui.global::<EditorModel>()
.set_selected_tier_cheater_offset_text(cheater_offset_text.into());
let note_text = usize::try_from(selected)
.ok()
.and_then(|index| state.design.tier_note(index))
.map_or_else(String::new, str::to_string);
ui.global::<EditorModel>()
.set_selected_tier_note_text(note_text.into());
let chips = selected_tier_chips(&state.design, selected);
push_rows(
&ui.global::<EditorModel>().get_selected_tier_chips(),
chips,
|model| ui.global::<EditorModel>().set_selected_tier_chips(model),
);
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix::geometry::meet_solver::MeetConstraint;
use indicatrix_cut_core::ConstraintTier;
fn design_with_named_tiers(names: &[&str]) -> Design {
let mut state = EditorState::fresh();
for &name in names {
state.design.tiers.push(ConstraintTier {
angle_deg: -40.0,
name: name.to_string(),
indices: vec![0.0],
constraint: MeetConstraint::MeetExisting,
imported_meet: None,
original_notes: None,
detached: Vec::new(),
});
}
state.design
}
#[test]
fn selected_tier_chips_is_empty_when_nothing_is_selected() {
let design = design_with_named_tiers(&["G1"]);
assert_eq!(selected_tier_chips(&design, -1).len(), 0);
}
#[test]
fn selected_tier_chips_is_empty_for_an_out_of_range_index() {
let design = design_with_named_tiers(&["G1"]);
assert_eq!(selected_tier_chips(&design, 5).len(), 0);
}
#[test]
fn selected_tier_chips_reads_the_selected_tiers_own_indices_and_detached_set() {
let mut design = design_with_named_tiers(&["G1", "P1"]);
design.tiers[1].indices = vec![0.0, 24.0];
design.tiers[1].detached = vec![24.0];
let chips = selected_tier_chips(&design, 1);
assert_eq!(chips.len(), 2);
assert!(!chips[0].detached);
assert!(chips[1].detached);
}
#[test]
fn traced_material_for_names_a_resolvable_built_in_selection() {
let mut design = EditorState::fresh().design;
design.material.name = Some("Sapphire".to_string());
let (name, unresolved) = traced_material_for(&design, &[]);
assert_eq!(name, "Sapphire");
assert_eq!(unresolved, None);
}
#[test]
fn traced_material_for_names_a_resolvable_custom_selection() {
let mut design = EditorState::fresh().design;
design.material.name = Some("My Garnet".to_string());
let mut custom = indicatrix::optics::materials::GemMaterial::diamond();
custom.name = "My Garnet".to_string();
let (name, unresolved) = traced_material_for(&design, &[custom]);
assert_eq!(name, "My Garnet");
assert_eq!(unresolved, None);
}
#[test]
fn traced_material_for_refuses_a_name_no_catalogue_resolves() {
let mut design = EditorState::fresh().design;
design.material.name = Some("Deleted Custom Garnet".to_string());
let (name, unresolved) = traced_material_for(&design, &[]);
assert_eq!(name, "");
let reason = unresolved.expect("an unresolvable name must refuse, not substitute");
assert!(reason.contains("Deleted Custom Garnet"));
}
}