use super::super::{
auto_solve,
edit_intent::{EditIntent, EditIntentQueue},
material_lookup::{EditorMaterialLookup, resolved_gem_material},
optimize_solve::{self, OptimizeSolveHandle, OptimizeSolveOutcome},
retarget::{self, CrownShift, RetargetError, RetargetMode, RetargetProposal, RetargetRow},
stale::{self, ResultKind},
stall_guard::stall_guard,
state::{
EditorState, design_label_text, 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, SolvedTier},
optics::materials::GemMaterial,
};
use indicatrix_cut_core::{
Design, Edit, EditError, MaterialSelection, ObjectiveWeights, OptimizeConfig, ResolvedMaterial,
Risk, TierDelta, diff_tiers,
};
use slint::{Color, ComponentHandle, ModelRc, SharedString, VecModel};
use std::{
cell::RefCell,
collections::BTreeSet,
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)>,
activity_id: Option<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;
if let (Some(activity), Some(id)) = (auto_solve::activity(), self.activity_id.take()) {
activity.finish(id);
}
}
}
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(),
ui.global::<EditorModel>().get_optimize_weight_yield(),
)
.unwrap_or_else(|_| ObjectiveWeights::default());
OptimizeConfig {
weights,
..OptimizeConfig::default()
}
}
fn apply_ghost_preview_or_revert(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
design: &Design,
proposal: Option<&RetargetProposal>,
) -> bool {
if !ui.global::<RetargetModel>().get_preview_enabled() {
return false;
}
let Some(proposal) = proposal else {
return false;
};
let mut candidate = design.clone();
let edit = retarget::apply(design, proposal);
if candidate.apply_edit(edit).is_err() {
return false;
}
view::submit_design_ghost_preview(ui, render_ctx, preview_state, &candidate)
}
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());
stale::clear(ResultKind::Retarget);
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);
ui.global::<RetargetModel>().set_preview_enabled(false);
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,
);
if let Some(proposal) = proposal {
stale::stamp(ResultKind::Retarget, generation);
st.pending_retarget = Some((proposal, generation));
} else {
st.pending_retarget = None;
}
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>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &view::SolidLastSolved,
) {
{
let intent_queue = {
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();
EditIntentQueue::new(move |_intent| {
let Some(ui) = ui_weak.upgrade() else {
return;
};
apply_retarget_shift_intent(
&ui,
&state,
&render_ctx,
&preview_state,
&solid_last_solved,
);
})
};
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_proposal_changed(move || {
stall_guard("retarget_on_proposal_changed", || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
RETARGET_ASYNC.with(|cell| cell.borrow_mut().cancel_and_supersede());
if ui.global::<RetargetModel>().get_mode_index() == 1 {
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(),
};
st.pending_retarget = None;
start_optimize_run(
&ui,
&render_ctx,
&preview_state,
&solid_last_solved,
&mut st,
crown,
);
} else {
intent_queue.post(EditIntent::RetargetCrown);
}
});
});
}
{
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;
};
state.borrow_mut().pending_retarget = None;
cancel_optimize_run(&ui);
});
}
}
fn cancel_optimize_run(ui: &MainWindow) {
RETARGET_ASYNC.with(|cell| cell.borrow_mut().cancel_and_supersede());
stale::clear(ResultKind::Retarget);
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 apply_retarget_shift_intent(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &view::SolidLastSolved,
) {
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(),
};
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);
if !apply_ghost_preview_or_revert(ui, render_ctx, preview_state, &st.design, proposal.as_ref())
{
view::submit_preview_replan(
ui,
render_ctx,
preview_state,
solid_last_solved,
&st,
BTreeSet::new(),
false,
);
}
if let Some(proposal) = proposal {
stale::stamp(ResultKind::Retarget, generation);
st.pending_retarget = Some((proposal, generation));
} else {
stale::clear(ResultKind::Retarget);
st.pending_retarget = None;
}
}
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));
if let (Some(activity), Some(id)) = (
auto_solve::activity(),
RETARGET_ASYNC.with(|cell| cell.borrow().activity_id),
) {
let fraction = if max_evaluations == 0 {
super::super::activity::INDETERMINATE
} else {
evaluations as f32 / max_evaluations as f32
};
activity.progress(id, fraction);
}
}
fn start_optimize_run(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &view::SolidLastSolved,
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(),
render_ctx: Arc::clone(render_ctx),
preview_state: Arc::clone(preview_state),
};
let config = optimize_config_from_ui(ui);
let max_evaluations = config.max_evaluations;
ui.global::<RetargetModel>().set_is_busy(true);
let activity_id = auto_solve::activity().map(|a| {
let ui_weak = ui.as_weak();
a.start(
"retarget_optimize",
"Retarget Optimize",
Some(Box::new(move || {
let ui_weak = ui_weak.clone();
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::SingleShot,
std::time::Duration::ZERO,
move || {
if let Some(ui) = ui_weak.upgrade() {
cancel_optimize_run(&ui);
}
},
);
})),
)
});
RETARGET_ASYNC.with(|cell| cell.borrow_mut().activity_id = activity_id);
set_optimize_progress(ui, 0, max_evaluations);
view::submit_preview_replan(
ui,
render_ctx,
preview_state,
solid_last_solved,
st,
BTreeSet::new(),
false,
);
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>,
render_ctx: Arc<Mutex<RenderContext>>,
preview_state: Arc<SolidPreviewState>,
}
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);
if let (Some(activity), Some(id)) = (
auto_solve::activity(),
RETARGET_ASYNC.with(|cell| cell.borrow_mut().activity_id.take()),
) {
activity.finish(id);
}
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(),
});
let _ = apply_ghost_preview_or_revert(
ui,
&ctx.render_ctx,
&ctx.preview_state,
&ctx.original,
proposal.as_ref(),
);
let has_proposal = proposal.is_some();
RETARGET_ASYNC.with(|cell| {
cell.borrow_mut().pending = proposal.map(|p| (p, started_generation));
});
if has_proposal {
stale::stamp(ResultKind::Retarget, started_generation);
} else {
stale::clear(ResultKind::Retarget);
}
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 || {
stall_guard("retarget_on_apply", || {
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;
};
stale::clear(ResultKind::Retarget);
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) => {
drop(st);
view::refresh_all_now(
&ui,
&render_ctx,
&preview_state,
&solid_last_solved,
&state,
false,
);
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>>,
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_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;
stale::clear(ResultKind::Retarget);
ui.global::<RetargetModel>().set_is_open(false);
let st = state.borrow();
view::submit_preview_replan(
&ui,
&render_ctx,
&preview_state,
&solid_last_solved,
&st,
BTreeSet::new(),
false,
);
});
}
thread_local! {
static DESIGN_SNAPSHOT: RefCell<Option<DesignSnapshot>> = const { RefCell::new(None) };
}
#[derive(Clone)]
struct DesignSnapshot {
design: Design,
solved: Option<Vec<SolvedTier>>,
label: String,
}
struct DiffRowView {
tier_index: usize,
name: String,
old_angle: String,
new_angle: String,
mast_delta: String,
status_label: &'static str,
status_rgb: (u8, u8, u8),
}
const MAST_DIFF_TOLERANCE: f64 = 1e-4;
fn diff_status_label_and_rgb(delta: &TierDelta) -> (&'static str, (u8, u8, u8)) {
if delta.added() {
("Added", (0x38, 0xbd, 0xf8))
} else if delta.removed() {
("Removed", (0xf4, 0x3f, 0x5e))
} else if delta.angle_changed()
|| delta.indices_changed()
|| delta.mast_changed(MAST_DIFF_TOLERANCE)
{
("Changed", (0xf5, 0x9e, 0x0b))
} else {
("Same", (0x10, 0xb9, 0x81))
}
}
fn diff_row_view(delta: &TierDelta) -> DiffRowView {
let angle_text =
|a: Option<f64>| a.map_or_else(|| "-".to_string(), |v| format!("{v:.2}\u{b0}"));
let mast_delta = match (delta.mast_before, delta.mast_after) {
(Some(before), Some(after)) => format!("{:+.4}", after - before),
_ => "-".to_string(),
};
let (status_label, status_rgb) = diff_status_label_and_rgb(delta);
DiffRowView {
tier_index: delta.index,
name: delta.name.clone(),
old_angle: angle_text(delta.angle_before),
new_angle: angle_text(delta.angle_after),
mast_delta,
status_label,
status_rgb,
}
}
#[must_use]
fn diff_rows_from_deltas(deltas: &[TierDelta]) -> Vec<RetargetRowItem> {
deltas
.iter()
.map(diff_row_view)
.map(|v| RetargetRowItem {
tier_index: i32::try_from(v.tier_index).unwrap_or(i32::MAX),
block: "".into(),
name: v.name.into(),
old_angle: v.old_angle.into(),
new_angle: v.new_angle.into(),
margin: v.mast_delta.into(),
risk_label: v.status_label.into(),
risk_color: Color::from_rgb_u8(v.status_rgb.0, v.status_rgb.1, v.status_rgb.2),
})
.collect()
}
fn store_design_snapshot(
ui: &MainWindow,
design: &Design,
solved: Option<Vec<SolvedTier>>,
label: &str,
) {
DESIGN_SNAPSHOT.with(|cell| {
*cell.borrow_mut() = Some(DesignSnapshot {
design: design.clone(),
solved,
label: label.to_string(),
});
});
ui.global::<EditorModel>().set_has_snapshot(true);
show_toast(ui, &format!("Snapshot taken: \"{label}\"."), "success");
}
fn show_compare_to_snapshot(
ui: &MainWindow,
snapshot: &DesignSnapshot,
design: &Design,
current_label: &str,
current_solved: Option<&[SolvedTier]>,
) {
let deltas = diff_tiers(
&snapshot.design.tiers,
snapshot.solved.as_deref(),
&design.tiers,
current_solved,
);
let rows = diff_rows_from_deltas(&deltas);
ui.global::<RetargetModel>()
.set_compare_rows(ModelRc::new(VecModel::from(rows)));
ui.global::<RetargetModel>().set_compare_label(
format!("\"{}\" vs. current (\"{current_label}\")", snapshot.label).into(),
);
ui.global::<RetargetModel>().set_compare_open(true);
}
pub(in crate::gui::editor) fn setup_snapshot_callbacks(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
solid_last_solved: &view::SolidLastSolved,
) {
{
let state = Rc::clone(state);
let solid_last_solved = Arc::clone(solid_last_solved);
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_snapshot_design(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let (design, label, cached) = {
let st = state.borrow();
let cached = solid_last_solved
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.filter(|s| s.len() == st.design.tiers.len());
(
Arc::new(st.design.clone()),
design_label_text(st.asc_filename.as_deref()),
cached,
)
};
if let Some(solved) = cached {
store_design_snapshot(&ui, &design, Some(solved), &label);
return;
}
super::super::native_io::resolve_solved_then(&ui, design, move |ui, design, solved| {
store_design_snapshot(ui, &design, solved.ok(), &label);
});
});
}
{
let state = Rc::clone(state);
let solid_last_solved = Arc::clone(solid_last_solved);
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_compare_to_snapshot(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let snapshot = DESIGN_SNAPSHOT.with(|cell| cell.borrow().clone());
let Some(snapshot) = snapshot else {
show_toast(
&ui,
"No snapshot taken yet -- use Snapshot Design first.",
"error",
);
return;
};
let (design, current_label, cached) = {
let st = state.borrow();
let cached = solid_last_solved
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.filter(|s| s.len() == st.design.tiers.len());
(
Arc::new(st.design.clone()),
design_label_text(st.asc_filename.as_deref()),
cached,
)
};
if let Some(current_solved) = &cached {
show_compare_to_snapshot(
&ui,
&snapshot,
&design,
¤t_label,
Some(current_solved),
);
return;
}
super::super::native_io::resolve_solved_then(&ui, design, move |ui, design, solved| {
show_compare_to_snapshot(
ui,
&snapshot,
&design,
¤t_label,
solved.ok().as_deref(),
);
});
});
}
let ui_weak = ui.as_weak();
ui.global::<RetargetModel>().on_compare_close(move || {
if let Some(ui) = ui_weak.upgrade() {
ui.global::<RetargetModel>().set_compare_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");
}
fn tier_for_diff(name: &str, angle_deg: f64) -> ConstraintTier {
ConstraintTier {
angle_deg,
name: name.to_string(),
indices: vec![0.0],
constraint: MeetConstraint::ScaleReference(0.5),
imported_meet: None,
original_notes: None,
detached: Vec::new(),
}
}
#[test]
fn diff_row_view_labels_an_unchanged_position_same() {
let before = vec![tier_for_diff("Table", 0.0)];
let after = before.clone();
let deltas = diff_tiers(&before, None, &after, None);
let row = diff_row_view(&deltas[0]);
assert_eq!(row.status_label, "Same");
assert_eq!(row.mast_delta, "-");
}
#[test]
fn diff_row_view_labels_a_moved_angle_changed() {
let before = vec![tier_for_diff("Star", 15.0)];
let mut after = before.clone();
after[0].angle_deg = 16.0;
let deltas = diff_tiers(&before, None, &after, None);
let row = diff_row_view(&deltas[0]);
assert_eq!(row.status_label, "Changed");
assert_eq!(row.old_angle, "15.00\u{b0}");
assert_eq!(row.new_angle, "16.00\u{b0}");
}
#[test]
fn diff_row_view_reports_a_signed_mast_delta_when_both_masts_are_known() {
use indicatrix::geometry::meet_solver::SolveStrategy;
let before = vec![tier_for_diff("Table", 0.0)];
let after = before.clone();
let before_solved = vec![SolvedTier {
mast: 0.5,
strategy: SolveStrategy::ScaleReference,
detail: String::new(),
}];
let after_solved = vec![SolvedTier {
mast: 0.55,
strategy: SolveStrategy::ScaleReference,
detail: String::new(),
}];
let deltas = diff_tiers(&before, Some(&before_solved), &after, Some(&after_solved));
let row = diff_row_view(&deltas[0]);
assert_eq!(row.mast_delta, "+0.0500");
}
#[test]
fn diff_row_view_labels_added_and_removed_positions() {
let before = vec![tier_for_diff("Table", 0.0)];
let after = vec![tier_for_diff("Table", 0.0), tier_for_diff("Star", 15.0)];
let deltas = diff_tiers(&before, None, &after, None);
assert_eq!(diff_row_view(&deltas[0]).status_label, "Same");
assert_eq!(diff_row_view(&deltas[1]).status_label, "Added");
let deltas_reverse = diff_tiers(&after, None, &before, None);
assert_eq!(diff_row_view(&deltas_reverse[1]).status_label, "Removed");
}
#[test]
fn diff_rows_from_deltas_carries_the_tier_index_and_name_through() {
let before = vec![tier_for_diff("Table", 0.0), tier_for_diff("Star", 15.0)];
let mut after = before.clone();
after[1].angle_deg = 16.0;
let deltas = diff_tiers(&before, None, &after, None);
let rows = diff_rows_from_deltas(&deltas);
assert_eq!(rows.len(), 2);
assert_eq!(rows[1].tier_index, 1);
assert_eq!(rows[1].name.as_str(), "Star");
assert_eq!(rows[1].risk_label.as_str(), "Changed");
}
}