use super::super::{
material_lookup::{EditorMaterialLookup, resolved_gem_material},
optimize_solve::{self, OptimizeSolveHandle, OptimizeSolveOutcome},
retarget::{self, CrownShift, RetargetError, RetargetMode, RetargetProposal, RetargetRow},
state::{
EditorState, design_material_index_from_name, design_material_options,
parse_design_material_form,
},
view::{self, parse_optimize_weights},
};
use crate::{
EditorModel, MainWindow, RetargetModel, RetargetRowItem,
bridge::render_thread::RenderContext,
gui::{show_toast, solid_preview::preview_state::SolidPreviewState},
};
use indicatrix::{geometry::meet_solver::Block, optics::materials::GemMaterial};
use indicatrix_cut_core::{
Design, Edit, EditError, MaterialSelection, ObjectiveWeights, OptimizeConfig, ResolvedMaterial,
Risk,
};
use slint::{Color, ComponentHandle, ModelRc, SharedString, VecModel};
use std::{
cell::RefCell,
rc::Rc,
sync::{Arc, Mutex, atomic::Ordering as AtomicOrdering},
};
thread_local! {
static RETARGET_ASYNC: RefCell<RetargetAsyncRun> = RefCell::new(RetargetAsyncRun::default());
}
#[derive(Default)]
struct RetargetAsyncRun {
handle: Option<OptimizeSolveHandle>,
run_id: u64,
pending: Option<(RetargetProposal, u64)>,
}
impl RetargetAsyncRun {
fn cancel_and_supersede(&mut self) {
if let Some(handle) = self.handle.take() {
handle.cancel();
}
self.run_id = self.run_id.wrapping_add(1);
self.pending = None;
}
}
fn resolve_target_selection(
design: &Design,
custom: &[GemMaterial],
combo_index: i32,
ri_override_text: &str,
) -> Result<MaterialSelection, String> {
let options = design_material_options(custom);
parse_design_material_form(combo_index, ri_override_text, &options, &design.material)
}
fn target_material_selection(
design: &Design,
custom: &[GemMaterial],
combo_index: i32,
ri_override_text: &str,
) -> MaterialSelection {
resolve_target_selection(design, custom, combo_index, ri_override_text)
.unwrap_or_else(|_| design.material.clone())
}
fn resolved_material_from_selection(
selection: &MaterialSelection,
custom: &[GemMaterial],
) -> ResolvedMaterial {
let lookup = EditorMaterialLookup::new(custom);
let resolved = selection.resolve(&lookup);
let gem = resolved_gem_material(selection, &lookup);
ResolvedMaterial { gem, ..resolved }
}
struct RetargetRowView {
tier_index: usize,
block: &'static str,
name: String,
old_angle: String,
new_angle: String,
margin: String,
risk_label: &'static str,
risk_rgb: (u8, u8, u8),
}
const fn risk_label_and_rgb(risk: Risk) -> (&'static str, (u8, u8, u8)) {
match risk {
Risk::Safe => ("Safe", (0x10, 0xb9, 0x81)),
Risk::Marginal => ("Marginal", (0xf5, 0x9e, 0x0b)),
Risk::Windows => ("Windows", (0xf4, 0x3f, 0x5e)),
}
}
fn row_view(row: &RetargetRow) -> RetargetRowView {
let block = match row.block {
Block::Crown => "Crown",
Block::Pavilion => "Pavilion",
Block::Girdle => "Girdle",
};
let (risk_label, risk_rgb) = risk_label_and_rgb(row.risk);
RetargetRowView {
tier_index: row.tier_index,
block,
name: row.name.clone(),
old_angle: format!("{:.2}\u{b0}", row.old_angle),
new_angle: format!("{:.2}\u{b0}", row.new_angle),
margin: format!("{:+.2}\u{b0}", row.margin_deg),
risk_label,
risk_rgb,
}
}
fn retarget_view(
design: &Design,
target: &ResolvedMaterial,
crown: CrownShift,
mode: RetargetMode,
custom_materials: &[GemMaterial],
) -> (RetargetView, Option<RetargetProposal>) {
match retarget::build_proposal(design, target, crown, mode, custom_materials) {
Ok(proposal) => {
let rows = proposal.rows.iter().map(row_view).collect();
let view = RetargetView {
rows,
notes: proposal.notes.clone(),
anchored_errors: Vec::new(),
solve_error: String::new(),
};
(view, Some(proposal))
}
Err(RetargetError::AnchoredTiers(tiers)) => {
let anchored_errors = tiers
.iter()
.map(|(index, name)| format!("#{index} \"{name}\""))
.collect();
let view = RetargetView {
rows: Vec::new(),
notes: Vec::new(),
anchored_errors,
solve_error: String::new(),
};
(view, None)
}
Err(RetargetError::Solve(err)) => {
let view = RetargetView {
rows: Vec::new(),
notes: Vec::new(),
anchored_errors: Vec::new(),
solve_error: err.to_string(),
};
(view, None)
}
}
}
struct RetargetView {
rows: Vec<RetargetRowView>,
notes: Vec<String>,
anchored_errors: Vec<String>,
solve_error: String,
}
fn push_retarget_view(ui: &MainWindow, view: RetargetView) {
let rows: Vec<RetargetRowItem> = view
.rows
.into_iter()
.map(|r| RetargetRowItem {
tier_index: r.tier_index as i32,
block: r.block.into(),
name: r.name.into(),
old_angle: r.old_angle.into(),
new_angle: r.new_angle.into(),
margin: r.margin.into(),
risk_label: r.risk_label.into(),
risk_color: Color::from_rgb_u8(r.risk_rgb.0, r.risk_rgb.1, r.risk_rgb.2),
})
.collect();
ui.global::<RetargetModel>()
.set_rows(ModelRc::new(VecModel::from(rows)));
ui.global::<RetargetModel>()
.set_notes(ModelRc::new(VecModel::from(
view.notes
.into_iter()
.map(SharedString::from)
.collect::<Vec<_>>(),
)));
ui.global::<RetargetModel>()
.set_anchored_tier_errors(ModelRc::new(VecModel::from(
view.anchored_errors
.into_iter()
.map(SharedString::from)
.collect::<Vec<_>>(),
)));
ui.global::<RetargetModel>()
.set_solve_error(view.solve_error.into());
}
fn target_display_name(selection: &MaterialSelection) -> String {
selection.name.clone().unwrap_or_else(|| {
if selection.refractive_index_override.is_some() {
"Custom RI".to_string()
} else {
"(none)".to_string()
}
})
}
fn push_target_readout(ui: &MainWindow, selection: &MaterialSelection, target: &ResolvedMaterial) {
ui.global::<RetargetModel>()
.set_target_material_name(target_display_name(selection).into());
ui.global::<RetargetModel>()
.set_target_material_ri(format!("{:.4}", target.n_d).into());
ui.global::<RetargetModel>()
.set_target_critical_angle(format!("{:.2}\u{b0}", target.critical_angle_deg).into());
ui.global::<RetargetModel>()
.set_target_material_error("".into());
}
fn push_target_error(ui: &MainWindow, message: &str) {
ui.global::<RetargetModel>()
.set_target_material_error(message.into());
push_retarget_view(
ui,
RetargetView {
rows: Vec::new(),
notes: Vec::new(),
anchored_errors: Vec::new(),
solve_error: String::new(),
},
);
}
fn optimize_config_from_ui(ui: &MainWindow) -> OptimizeConfig {
let weights = parse_optimize_weights(
&ui.global::<EditorModel>().get_optimize_weight_windowing(),
&ui.global::<EditorModel>().get_optimize_weight_extinction(),
&ui.global::<EditorModel>()
.get_optimize_weight_tilt_brilliance(),
)
.unwrap_or_else(|_| ObjectiveWeights::default());
OptimizeConfig {
weights,
..OptimizeConfig::default()
}
}
fn rebuild_and_push(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
design: &Design,
crown: CrownShift,
mode: RetargetMode,
) -> Option<RetargetProposal> {
let combo_index = ui.global::<RetargetModel>().get_target_material_index();
let ri_text = ui.global::<RetargetModel>().get_target_ri_override_text();
let (selection, target, custom_materials) = {
let ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match resolve_target_selection(design, &ctx.custom_materials, combo_index, &ri_text) {
Ok(selection) => {
let target = resolved_material_from_selection(&selection, &ctx.custom_materials);
let custom_materials = ctx.custom_materials.as_ref().clone();
(selection, target, custom_materials)
}
Err(message) => {
drop(ctx);
push_target_error(ui, &message);
return None;
}
}
};
push_target_readout(ui, &selection, &target);
let (view, proposal) = retarget_view(design, &target, crown, mode, &custom_materials);
push_retarget_view(ui, view);
proposal
}
fn initial_target_index(material: &MaterialSelection, options: &[String]) -> i32 {
if material.name.is_none() && material.refractive_index_override.is_some() {
return i32::try_from(options.len()).unwrap_or(i32::MAX) - 1;
}
design_material_index_from_name(material.name.as_deref(), options)
}
pub(in crate::gui::editor) fn setup_retarget_open_callback(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
let state = Rc::clone(state);
let render_ctx = Arc::clone(render_ctx);
let ui_weak = ui.as_weak();
ui.global::<RetargetModel>().on_open(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
RETARGET_ASYNC.with(|cell| cell.borrow_mut().cancel_and_supersede());
let mut st = state.borrow_mut();
ui.global::<RetargetModel>().set_mode_index(0);
ui.global::<RetargetModel>().set_crown_fraction(0.0);
ui.global::<RetargetModel>().set_scale_crown_by_ratio(false);
ui.global::<RetargetModel>().set_is_busy(false);
ui.global::<RetargetModel>().set_optimize_evaluations(0);
ui.global::<RetargetModel>().set_optimize_max_evaluations(0);
let options = {
let ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
design_material_options(&ctx.custom_materials)
};
ui.global::<RetargetModel>()
.set_target_material_index(initial_target_index(&st.design.material, &options));
ui.global::<RetargetModel>().set_target_ri_override_text(
st.design
.material
.refractive_index_override
.map(|v| format!("{v}"))
.unwrap_or_default()
.into(),
);
ui.global::<RetargetModel>()
.set_material_options(ModelRc::new(VecModel::from(
options
.into_iter()
.map(SharedString::from)
.collect::<Vec<_>>(),
)));
let generation = st.generation.load(AtomicOrdering::Relaxed);
let proposal = rebuild_and_push(
&ui,
&render_ctx,
&st.design,
CrownShift::default(),
RetargetMode::Shift,
);
st.pending_retarget = proposal.map(|p| (p, generation));
ui.global::<RetargetModel>().set_is_open(true);
});
}
pub(in crate::gui::editor) fn setup_retarget_proposal_changed_callback(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
{
let state = Rc::clone(state);
let render_ctx = Arc::clone(render_ctx);
let ui_weak = ui.as_weak();
ui.global::<RetargetModel>().on_proposal_changed(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
RETARGET_ASYNC.with(|cell| cell.borrow_mut().cancel_and_supersede());
let mut st = state.borrow_mut();
let crown = CrownShift {
fraction: f64::from(ui.global::<RetargetModel>().get_crown_fraction()),
scale_by_ratio: ui.global::<RetargetModel>().get_scale_crown_by_ratio(),
};
if ui.global::<RetargetModel>().get_mode_index() == 1 {
start_optimize_run(&ui, &render_ctx, &mut st, crown);
} else {
ui.global::<RetargetModel>().set_is_busy(false);
let generation = st.generation.load(AtomicOrdering::Relaxed);
let proposal =
rebuild_and_push(&ui, &render_ctx, &st.design, crown, RetargetMode::Shift);
st.pending_retarget = proposal.map(|p| (p, generation));
}
});
}
{
let state = Rc::clone(state);
let ui_weak = ui.as_weak();
ui.global::<RetargetModel>().on_cancel_optimize(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
RETARGET_ASYNC.with(|cell| cell.borrow_mut().cancel_and_supersede());
state.borrow_mut().pending_retarget = None;
ui.global::<RetargetModel>().set_is_busy(false);
push_retarget_view(
&ui,
RetargetView {
rows: Vec::new(),
notes: vec!["Optimize cancelled.".to_string()],
anchored_errors: Vec::new(),
solve_error: String::new(),
},
);
});
}
}
fn push_anchored_refusal(ui: &MainWindow, st: &mut EditorState, anchored: &[(usize, String)]) {
let anchored_errors = anchored
.iter()
.map(|(index, name)| format!("#{index} \"{name}\""))
.collect();
push_retarget_view(
ui,
RetargetView {
rows: Vec::new(),
notes: Vec::new(),
anchored_errors,
solve_error: String::new(),
},
);
st.pending_retarget = None;
ui.global::<RetargetModel>().set_is_busy(false);
}
fn set_optimize_progress(ui: &MainWindow, evaluations: usize, max_evaluations: usize) {
ui.global::<RetargetModel>()
.set_optimize_evaluations(i32::try_from(evaluations).unwrap_or(i32::MAX));
ui.global::<RetargetModel>()
.set_optimize_max_evaluations(i32::try_from(max_evaluations).unwrap_or(i32::MAX));
}
fn start_optimize_run(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
st: &mut EditorState,
crown: CrownShift,
) {
let custom = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.custom_materials
.as_ref()
.clone();
let combo_index = ui.global::<RetargetModel>().get_target_material_index();
let ri_text = ui.global::<RetargetModel>().get_target_ri_override_text();
let selection = match resolve_target_selection(&st.design, &custom, combo_index, &ri_text) {
Ok(selection) => selection,
Err(message) => {
push_target_error(ui, &message);
st.pending_retarget = None;
ui.global::<RetargetModel>().set_is_busy(false);
return;
}
};
let target = resolved_material_from_selection(&selection, &custom);
push_target_readout(ui, &selection, &target);
let (scope, blocks) = retarget::retarget_scope(&st.design);
let anchored = retarget::anchored_tiers_in(&st.design, &scope);
if !anchored.is_empty() {
push_anchored_refusal(ui, st, &anchored);
return;
}
let n_from = st.design.effective_refractive_index_with(&custom);
let n_to = target.n_d;
let seeded = retarget::seed_shift_design(&st.design, &scope, &blocks, n_from, n_to, crown);
let ctx = OptimizeRunContext {
original: st.design.clone(),
seeded: seeded.clone(),
scope,
blocks,
n_to,
target,
custom: custom.clone(),
};
let config = optimize_config_from_ui(ui);
let max_evaluations = config.max_evaluations;
ui.global::<RetargetModel>().set_is_busy(true);
set_optimize_progress(ui, 0, max_evaluations);
let run_id = RETARGET_ASYNC.with(|cell| {
let mut run = cell.borrow_mut();
run.run_id = run.run_id.wrapping_add(1);
run.run_id
});
let started_generation = st.generation.load(AtomicOrdering::Relaxed);
let handle = optimize_solve::spawn_optimize_solve(
ui.as_weak(),
seeded,
selection,
custom,
config,
|ui: &MainWindow, progress: optimize_solve::OptimizeSolveProgress| {
set_optimize_progress(ui, progress.evaluations, progress.max_evaluations);
},
move |ui: &MainWindow, outcome: OptimizeSolveOutcome| {
finish_optimize_run(ui, outcome, run_id, started_generation, &ctx);
},
);
RETARGET_ASYNC.with(|cell| cell.borrow_mut().handle = Some(handle));
}
struct OptimizeRunContext {
original: Design,
seeded: Design,
scope: Vec<usize>,
blocks: Vec<Block>,
n_to: f64,
target: ResolvedMaterial,
custom: Vec<GemMaterial>,
}
fn finish_optimize_run(
ui: &MainWindow,
outcome: OptimizeSolveOutcome,
run_id: u64,
started_generation: u64,
ctx: &OptimizeRunContext,
) {
let still_current = RETARGET_ASYNC.with(|cell| cell.borrow().run_id == run_id);
if !still_current {
return;
}
RETARGET_ASYNC.with(|cell| cell.borrow_mut().handle = None);
let (rows, notes, solve_error) = match outcome {
OptimizeSolveOutcome::Completed { outcome }
| OptimizeSolveOutcome::Cancelled { outcome } => {
let rows = retarget::rows_from_outcome(
&ctx.original,
&ctx.seeded,
&ctx.scope,
&ctx.blocks,
ctx.n_to,
&outcome.changes,
);
let n_from = ctx.original.effective_refractive_index_with(&ctx.custom);
let notes = retarget::build_notes(
RetargetMode::Optimize(OptimizeConfig::default()),
n_from,
ctx.n_to,
);
(rows, notes, String::new())
}
OptimizeSolveOutcome::Failed { error } => (Vec::new(), Vec::new(), error.to_string()),
};
let row_views: Vec<RetargetRowView> = rows.iter().map(row_view).collect();
let proposal = (!rows.is_empty()).then(|| RetargetProposal {
rows,
target: ctx.target.clone(),
notes: notes.clone(),
});
RETARGET_ASYNC.with(|cell| {
cell.borrow_mut().pending = proposal.map(|p| (p, started_generation));
});
let view = RetargetView {
rows: row_views,
notes,
anchored_errors: Vec::new(),
solve_error,
};
push_retarget_view(ui, view);
ui.global::<RetargetModel>().set_is_busy(false);
}
enum RetargetApplyError {
Stale,
Edit(EditError),
}
fn apply_pending_retarget(
state: &mut EditorState,
pending: (RetargetProposal, u64),
material_change: Option<MaterialSelection>,
) -> Result<usize, RetargetApplyError> {
let (proposal, started_generation) = pending;
if state.generation.load(AtomicOrdering::Relaxed) != started_generation {
return Err(RetargetApplyError::Stale);
}
let angle_edit = retarget::apply(&state.design, &proposal);
let applied = proposal.rows.len();
let edit = match material_change {
Some(material) => Edit::Batch(vec![angle_edit, Edit::SetMaterial { material }]),
None => angle_edit,
};
state
.apply(edit)
.map(|()| applied)
.map_err(RetargetApplyError::Edit)
}
pub(in crate::gui::editor) fn setup_retarget_apply_callback(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &view::SolidLastSolved,
) {
let state = Rc::clone(state);
let render_ctx = Arc::clone(render_ctx);
let preview_state = Arc::clone(preview_state);
let solid_last_solved = Arc::clone(solid_last_solved);
let ui_weak = ui.as_weak();
ui.global::<RetargetModel>().on_apply(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let mut st = state.borrow_mut();
let pending = st
.pending_retarget
.take()
.or_else(|| RETARGET_ASYNC.with(|cell| cell.borrow_mut().pending.take()));
let Some(pending) = pending else {
return;
};
let custom = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.custom_materials
.as_ref()
.clone();
let combo_index = ui.global::<RetargetModel>().get_target_material_index();
let ri_text = ui.global::<RetargetModel>().get_target_ri_override_text();
let material_selection =
target_material_selection(&st.design, &custom, combo_index, &ri_text);
let target_name = target_display_name(&material_selection);
let material_change =
(material_selection != st.design.material).then_some(material_selection);
match apply_pending_retarget(&mut st, pending, material_change) {
Ok(applied) => {
view::refresh_all(&ui, &render_ctx, &preview_state, &solid_last_solved, &st);
ui.global::<RetargetModel>().set_is_open(false);
show_toast(
&ui,
&format!("Retargeted {applied} tier(s) for {target_name}."),
"success",
);
}
Err(RetargetApplyError::Stale) => {
show_toast(
&ui,
"The design changed since this retarget proposal was built -- \
re-open Retarget for material.",
"error",
);
}
Err(RetargetApplyError::Edit(e)) => {
show_toast(&ui, &e.to_string(), "error");
}
}
});
}
pub(in crate::gui::editor) fn setup_retarget_close_callback(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
) {
let state = Rc::clone(state);
let ui_weak = ui.as_weak();
ui.global::<RetargetModel>().on_close(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
RETARGET_ASYNC.with(|cell| cell.borrow_mut().cancel_and_supersede());
state.borrow_mut().pending_retarget = None;
ui.global::<RetargetModel>().set_is_open(false);
});
}
#[cfg(test)]
mod tests {
use super::{super::super::state::design_material_index_from_name, *};
use indicatrix::geometry::meet_solver::MeetConstraint;
use indicatrix_cut_core::{
ConstraintTier, History, MaterialSelection, PreformSpec, ScheduleMeta,
};
fn resolve_target_material(
design: &Design,
custom: &[GemMaterial],
combo_index: i32,
ri_override_text: &str,
) -> ResolvedMaterial {
let selection = target_material_selection(design, custom, combo_index, ri_override_text);
resolved_material_from_selection(&selection, custom)
}
fn diamond_design() -> Design {
let mut design = Design::new(
PreformSpec::block(2.0, 1.0, 2.0),
ScheduleMeta::default(),
vec![ConstraintTier {
angle_deg: -40.0,
name: "P1".to_string(),
indices: vec![0.0, 24.0],
constraint: MeetConstraint::ScaleReference(0.5),
imported_meet: None,
original_notes: None,
detached: Vec::new(),
}],
);
design.material = MaterialSelection {
name: Some("Diamond".to_string()),
specific_gravity_override: None,
refractive_index_override: None,
};
design
}
#[test]
fn resolve_target_material_defaults_to_the_designs_own_current_material() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let own_index = design_material_index_from_name(design.material.name.as_deref(), &options);
let target = resolve_target_material(&design, &custom, own_index, "");
assert_eq!(target.gem.name, "Diamond");
assert!((target.n_d - design.effective_refractive_index()).abs() < 1e-9);
}
#[test]
fn resolve_target_material_resolves_a_different_combo_selection() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let quartz_index = design_material_index_from_name(Some("Quartz"), &options);
let target = resolve_target_material(&design, &custom, quartz_index, "");
assert_eq!(target.gem.name, "Quartz");
assert!((target.n_d - design.effective_refractive_index()).abs() > 0.1);
}
#[test]
fn resolve_target_material_falls_back_when_the_ri_override_text_is_invalid() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let own_index = design_material_index_from_name(design.material.name.as_deref(), &options);
let target = resolve_target_material(&design, &custom, own_index, "not-a-number");
assert_eq!(target.gem.name, "Diamond");
}
#[test]
fn retarget_view_shift_mode_populates_rows_not_errors() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let quartz_index = design_material_index_from_name(Some("Quartz"), &options);
let target = resolve_target_material(&design, &custom, quartz_index, "");
let (view, proposal) = retarget_view(
&design,
&target,
CrownShift::default(),
RetargetMode::Shift,
&[],
);
assert!(!view.rows.is_empty());
assert_eq!(view.anchored_errors, Vec::<String>::new());
assert_eq!(view.solve_error, "");
assert!(proposal.is_some());
}
#[test]
fn retarget_view_optimize_mode_populates_anchored_errors_not_rows_when_anchored() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let quartz_index = design_material_index_from_name(Some("Quartz"), &options);
let target = resolve_target_material(&design, &custom, quartz_index, "");
let config = OptimizeConfig {
max_evaluations: 4,
..OptimizeConfig::default()
};
let (view, proposal) = retarget_view(
&design,
&target,
CrownShift::default(),
RetargetMode::Optimize(config),
&[],
);
assert!(view.rows.is_empty());
assert_ne!(view.anchored_errors, Vec::<String>::new());
assert!(view.anchored_errors[0].contains("P1"));
assert!(proposal.is_none());
}
fn fresh_state_with(design: Design) -> EditorState {
let mut state = EditorState::fresh();
state.design = design;
state.history = History::new();
state
}
#[test]
fn apply_pending_retarget_pushes_exactly_one_retarget_angles_edit_and_solves() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let quartz_index = design_material_index_from_name(Some("Quartz"), &options);
let target = resolve_target_material(&design, &custom, quartz_index, "");
let (_, proposal) = retarget_view(
&design,
&target,
CrownShift::default(),
RetargetMode::Shift,
&[],
);
let proposal = proposal.expect("shift mode never fails");
let mut state = fresh_state_with(design);
let generation = state.generation.load(AtomicOrdering::Relaxed);
let applied = apply_pending_retarget(&mut state, (proposal, generation), None)
.unwrap_or_else(|_| panic!("a fresh, matching-generation proposal must apply"));
assert_eq!(applied, 1);
assert!(state.history.can_undo());
assert!(
state.design.solve().is_ok(),
"the retargeted design must still solve"
);
}
#[test]
fn apply_pending_retarget_combines_a_material_change_into_one_undo_step() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let quartz_index = design_material_index_from_name(Some("Quartz"), &options);
let target = resolve_target_material(&design, &custom, quartz_index, "");
let (_, proposal) = retarget_view(
&design,
&target,
CrownShift::default(),
RetargetMode::Shift,
&[],
);
let proposal = proposal.expect("shift mode never fails");
let mut state = fresh_state_with(design);
let generation = state.generation.load(AtomicOrdering::Relaxed);
let material_change = MaterialSelection {
name: Some("Quartz".to_string()),
specific_gravity_override: None,
refractive_index_override: None,
};
apply_pending_retarget(&mut state, (proposal, generation), Some(material_change))
.unwrap_or_else(|_| panic!("a fresh, matching-generation proposal must apply"));
assert_eq!(state.design.material.name.as_deref(), Some("Quartz"));
assert!(state.undo().unwrap());
assert_eq!(state.design.material.name.as_deref(), Some("Diamond"));
assert!(!state.history.can_undo());
}
#[test]
fn apply_pending_retarget_refuses_a_stale_generation() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let quartz_index = design_material_index_from_name(Some("Quartz"), &options);
let target = resolve_target_material(&design, &custom, quartz_index, "");
let (_, proposal) = retarget_view(
&design,
&target,
CrownShift::default(),
RetargetMode::Shift,
&[],
);
let proposal = proposal.expect("shift mode never fails");
let mut state = fresh_state_with(design);
let stale_generation = state.generation.load(AtomicOrdering::Relaxed) + 1;
let result = apply_pending_retarget(&mut state, (proposal, stale_generation), None);
assert!(matches!(result, Err(RetargetApplyError::Stale)));
assert!(
!state.history.can_undo(),
"nothing should have been applied"
);
}
#[test]
fn initial_target_index_finds_the_designs_own_named_material() {
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let material = MaterialSelection {
name: Some("Quartz".to_string()),
specific_gravity_override: None,
refractive_index_override: None,
};
let quartz_index = design_material_index_from_name(Some("Quartz"), &options);
assert_eq!(initial_target_index(&material, &options), quartz_index);
}
#[test]
fn initial_target_index_seeds_the_custom_ri_sentinel_for_a_nameless_override() {
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let material = MaterialSelection {
name: None,
specific_gravity_override: None,
refractive_index_override: Some(1.6),
};
assert_eq!(
initial_target_index(&material, &options),
i32::try_from(options.len()).unwrap() - 1
);
assert_eq!(
options.last().map(String::as_str),
Some("Custom RI\u{2026}")
);
}
#[test]
fn initial_target_index_falls_back_to_none_for_a_plain_nameless_material() {
let custom: [GemMaterial; 0] = [];
let options = design_material_options(&custom);
let material = MaterialSelection::default();
assert_eq!(initial_target_index(&material, &options), 0);
}
#[test]
fn target_material_selection_falls_back_to_the_current_material_on_unparseable_ri() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let selection = target_material_selection(&design, &custom, 0, "not-a-number");
assert_eq!(selection, design.material);
}
#[test]
fn resolve_target_selection_reports_an_unparseable_ri_override_instead_of_falling_back() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let err = resolve_target_selection(&design, &custom, 0, "1,74")
.expect_err("a comma-separated RI override must not silently parse");
assert!(
err.contains("1,74"),
"error should name the bad text: {err}"
);
}
#[test]
fn resolve_target_selection_reports_a_non_positive_ri_override() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let err = resolve_target_selection(&design, &custom, 0, "0.5")
.expect_err("an RI override of 0.5 is not physically valid");
assert!(err.contains("greater than 1.0"), "got: {err}");
}
#[test]
fn resolve_target_selection_accepts_a_valid_ri_override() {
let design = diamond_design();
let custom: [GemMaterial; 0] = [];
let selection = resolve_target_selection(&design, &custom, 0, "1.74")
.expect("a well-formed RI override must parse");
assert_eq!(selection.refractive_index_override, Some(1.74));
}
#[test]
fn target_display_name_shows_none_for_a_nameless_uncustomized_selection() {
let selection = MaterialSelection::default();
assert_eq!(target_display_name(&selection), "(none)");
}
#[test]
fn target_display_name_shows_custom_ri_for_a_nameless_override() {
let selection = MaterialSelection {
name: None,
specific_gravity_override: None,
refractive_index_override: Some(1.74),
};
assert_eq!(target_display_name(&selection), "Custom RI");
}
#[test]
fn target_display_name_shows_the_picked_material_name() {
let selection = MaterialSelection {
name: Some("Quartz".to_string()),
specific_gravity_override: None,
refractive_index_override: None,
};
assert_eq!(target_display_name(&selection), "Quartz");
}
}