use super::{
autosave::record_recent_native_file,
design_paths::{is_autosave_design_path, schedule_name_for_design_path},
open_picker::ConvertedPick,
remember_design_location,
};
use crate::{
EditorModel, MainWindow,
bridge::render_thread::RenderContext,
gui::{
editor::{
callbacks::clear_analysis_results,
loading::LoadedDesign,
state::{
DesignFileExtras, EditorState, MaterialComboCache, PendingUnsavedAction,
PushedScratch,
},
view::{push_has_design, refresh_all},
},
show_toast,
solid_preview::preview_state::SolidPreviewState,
},
};
use indicatrix::optics::materials::GemMaterial;
use indicatrix_cut_core::{
FingerprintCheck, History, LoadPairedResult, TierOverlay,
native::{
CustomMaterialSnapshot, LoadNativeOnlyResult, LoadedDesign as DesignFileLoaded,
MaterialResolution, gem_material_from_custom_snapshot,
},
};
use indicatrix_editor::EditorSession;
use slint::ComponentHandle;
use std::{
cell::RefCell,
path::{Path, PathBuf},
rc::Rc,
sync::{Arc, Mutex, atomic::AtomicU64},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct ReplaceGuard {
generation: u64,
resumable: bool,
}
impl ReplaceGuard {
pub(super) fn capture(state: &EditorState, resumable: bool) -> Self {
Self {
generation: state.current_generation(),
resumable,
}
}
#[must_use]
pub(super) const fn discards_new_edits(self, current_generation: u64, is_dirty: bool) -> bool {
current_generation != self.generation && is_dirty
}
pub(super) fn allows_replace(self, ui: &MainWindow, state: &Rc<RefCell<EditorState>>) -> bool {
let (current_generation, is_dirty) = {
let st = state.borrow();
(st.current_generation(), st.is_dirty())
};
if !self.discards_new_edits(current_generation, is_dirty) {
return true;
}
if self.resumable {
state.borrow_mut().pending_unsaved_action = Some(PendingUnsavedAction::OpenNative);
let model = ui.global::<EditorModel>();
model.set_unsaved_dialog_message(
"The design was edited while the file was being chosen. Opening it will \
discard those unsaved changes."
.into(),
);
model.set_unsaved_dialog_open(true);
} else {
show_toast(
ui,
"The design was edited while the file was loading. Save or discard those \
changes, then open the recent file again.",
"error",
);
}
false
}
}
pub(super) struct SelfContainedLoad<'a> {
pub(super) native_path: &'a Path,
pub(super) loaded: LoadNativeOnlyResult,
pub(super) asc_filename: &'a str,
}
fn restore_custom_material(
render_ctx: &Arc<Mutex<RenderContext>>,
snapshot: Option<&CustomMaterialSnapshot>,
material_name: Option<&str>,
resolution: MaterialResolution,
) -> (Option<String>, bool) {
if let (Some(snapshot), Some(name)) = (snapshot, material_name) {
let gem: GemMaterial = gem_material_from_custom_snapshot(name, snapshot);
let mut ctx = render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let materials = Arc::make_mut(&mut ctx.custom_materials);
if let Some(pos) = materials
.iter()
.position(|m| m.name.eq_ignore_ascii_case(name))
{
materials[pos] = gem;
} else {
materials.push(gem);
}
drop(ctx);
return (
Some(format!(
" '{name}' was restored from this file's own saved material data."
)),
false,
);
}
let unresolved = matches!(resolution, MaterialResolution::Unresolved);
(unresolved.then(|| format!(" {resolution}")), unresolved)
}
pub(super) struct DesignFileLoad {
pub(super) native_path: PathBuf,
pub(super) loaded: DesignFileLoaded,
}
pub(super) fn open_design_file(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &crate::gui::editor::view::SolidLastSolved,
load: DesignFileLoad,
) {
let DesignFileLoad {
native_path,
loaded,
} = load;
let native_path_display = native_path.display().to_string();
let recovered = is_autosave_design_path(&native_path);
if recovered {
remember_design_location(None, None);
} else {
record_recent_native_file(ui, &native_path_display);
remember_design_location(Some(native_path.clone()), None);
}
let (material_note, material_unresolved) = restore_custom_material(
render_ctx,
loaded.restorable_custom_material.as_ref(),
loaded.design.material.name.as_deref(),
loaded.material_resolution,
);
let draft_note = loaded
.draft
.then_some(" It was saved as an unsolved draft; add a scale-reference tier to finish it.");
let printed_proportions = loaded.printed_proportions;
state.borrow_mut().replace_wholesale(EditorState {
deep_solve_result_generation: None,
session: EditorSession::with_history(loaded.design, History::new()),
printed_proportions,
design_epoch: Arc::new(AtomicU64::new(0)),
pending_unsaved_action: None,
after_save: None,
deep_solve: None,
optimize: None,
pending_optimize: Arc::new(Mutex::new(None)),
asc_filename: Some(schedule_name_for_design_path(&native_path)),
original_asc_text: None,
pending_gear_remap: None,
pending_retarget: None,
last_pushed_scratch: RefCell::new(PushedScratch::default()),
material_combo_cache: RefCell::new(MaterialComboCache::default()),
source_entry_id: None,
used_placeholder: false,
file_extras: DesignFileExtras::new(loaded.metadata, loaded.attachments),
has_design: true,
});
finish_state_replace(ui, render_ctx, preview_state, solid_last_solved, state);
if let Some(name) = native_path.file_name() {
ui.set_loaded_design_name(name.to_string_lossy().into_owned().into());
}
let lead = if recovered {
format!(
"Recovered '{native_path_display}' from an autosave snapshot. Save writes it to a \
new .indicatrix file."
)
} else {
format!("Loaded '{native_path_display}'.")
};
show_toast(
ui,
&format!(
"{lead}{}{}",
material_note.unwrap_or_default(),
draft_note.unwrap_or_default()
),
if material_unresolved || loaded.draft {
"warning"
} else {
"success"
},
);
}
pub(super) fn open_native_self_contained(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &crate::gui::editor::view::SolidLastSolved,
load: SelfContainedLoad<'_>,
) {
let SelfContainedLoad {
native_path,
loaded,
asc_filename,
} = load;
let native_path_display = native_path.display().to_string();
record_recent_native_file(ui, &native_path_display);
remember_design_location(None, None);
let (material_note, material_still_unresolved) = restore_custom_material(
render_ctx,
loaded.restorable_custom_material.as_ref(),
loaded.design.material.name.as_deref(),
loaded.material_resolution,
);
let newer_version_note = loaded.written_by_newer_version.then(|| {
" This file was written by a newer version of Indicatrix Cut; some settings \
may not have been understood and could be lost on your next save."
.to_string()
});
let is_mismatch = material_still_unresolved || loaded.written_by_newer_version;
let printed_proportions = loaded.printed_proportions;
state.borrow_mut().replace_wholesale(EditorState {
deep_solve_result_generation: None,
session: EditorSession::with_history(loaded.design, History::new()),
printed_proportions,
design_epoch: Arc::new(AtomicU64::new(0)),
pending_unsaved_action: None,
after_save: None,
deep_solve: None,
optimize: None,
pending_optimize: Arc::new(Mutex::new(None)),
asc_filename: Some(asc_filename.to_string()),
original_asc_text: None,
pending_gear_remap: None,
pending_retarget: None,
last_pushed_scratch: RefCell::new(PushedScratch::default()),
material_combo_cache: RefCell::new(MaterialComboCache::default()),
source_entry_id: None,
used_placeholder: false,
file_extras: DesignFileExtras::default(),
has_design: true,
});
finish_state_replace(ui, render_ctx, preview_state, solid_last_solved, state);
show_toast(
ui,
&format!(
"Recovered '{native_path_display}' -- no paired .asc file was found, so this design \
was rebuilt directly from the file's own saved data.{}{}",
material_note.unwrap_or_default(),
newer_version_note.unwrap_or_default()
),
if is_mismatch { "warning" } else { "success" },
);
}
pub(super) fn open_plain_asc(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &crate::gui::editor::view::SolidLastSolved,
asc_path: &Path,
asc_text: &str,
) {
let file_name = asc_path.file_name().map_or_else(
|| "design.asc".to_string(),
|n| n.to_string_lossy().into_owned(),
);
match crate::gui::editor::loading::design_from_asc_text(&file_name, asc_text, None) {
Ok(loaded) => {
commit_plain_design(
ui,
state,
render_ctx,
preview_state,
solid_last_solved,
loaded,
);
show_toast(
ui,
&format!(
"Loaded '{}' (plain .asc, no .indicatrix design file found -- authored meet \
constraints and detached facets are not available).",
asc_path.display()
),
"success",
);
}
Err(e) => show_toast(ui, &format!("Cannot open: {e}"), "error"),
}
}
pub(super) fn open_converted_design(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &crate::gui::editor::view::SolidLastSolved,
picked: ConvertedPick,
) {
let ConvertedPick {
source_path,
asc_file_name,
asc_text,
warnings,
} = picked;
match crate::gui::editor::loading::design_from_asc_text(&asc_file_name, &asc_text, None) {
Ok(loaded) => {
commit_plain_design(
ui,
state,
render_ctx,
preview_state,
solid_last_solved,
loaded,
);
if let Some(name) = source_path.file_name() {
ui.set_loaded_design_name(name.to_string_lossy().into_owned().into());
}
let notes = if warnings.is_empty() {
String::new()
} else {
format!(" Notes: {}.", warnings.join("; "))
};
show_toast(
ui,
&format!(
"Loaded '{}' as .asc cutting instructions. Save writes a new \
.indicatrix design file named after '{asc_file_name}'; the original file is \
never changed.{notes}",
source_path.display()
),
if warnings.is_empty() {
"success"
} else {
"warning"
},
);
}
Err(e) => show_toast(ui, &format!("Cannot open: {e}"), "error"),
}
}
fn commit_plain_design(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &crate::gui::editor::view::SolidLastSolved,
loaded: LoadedDesign,
) {
remember_design_location(None, None);
state.borrow_mut().replace_wholesale(EditorState {
session: EditorSession::with_history(loaded.design, History::new()),
printed_proportions: None,
design_epoch: Arc::new(AtomicU64::new(0)),
pending_unsaved_action: None,
after_save: None,
deep_solve: None,
optimize: None,
pending_optimize: Arc::new(Mutex::new(None)),
deep_solve_result_generation: None,
asc_filename: loaded.asc_filename,
original_asc_text: loaded.original_asc_text,
pending_gear_remap: None,
pending_retarget: None,
last_pushed_scratch: RefCell::new(PushedScratch::default()),
material_combo_cache: RefCell::new(MaterialComboCache::default()),
source_entry_id: None,
used_placeholder: false,
file_extras: DesignFileExtras::default(),
has_design: true,
});
finish_state_replace(ui, render_ctx, preview_state, solid_last_solved, state);
}
fn finish_state_replace(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &crate::gui::editor::view::SolidLastSolved,
state: &Rc<RefCell<EditorState>>,
) {
clear_analysis_results(ui);
ui.global::<EditorModel>()
.set_material_suggestion_name("".into());
ui.global::<EditorModel>()
.set_material_suggestion_text("".into());
let st = state.borrow();
refresh_all(ui, render_ctx, preview_state, solid_last_solved, &st, true);
ui.set_loaded_design_name(st.asc_filename.clone().unwrap_or_default().into());
push_has_design(ui, &st);
drop(st);
ui.global::<EditorModel>().set_selected_tier_index(-1);
let pulse = ui.global::<EditorModel>().get_form_reset_pulse();
ui.global::<EditorModel>()
.set_form_reset_pulse(pulse.wrapping_add(1));
ui.global::<EditorModel>().set_is_dirty(false);
}
pub(super) struct LoadedNativeOutcome {
pub(super) loaded: LoadPairedResult,
pub(super) native_path_display: String,
pub(super) asc_filename: String,
pub(super) asc_text: String,
pub(super) is_mismatch: bool,
}
pub(super) fn commit_loaded_native(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &crate::gui::editor::view::SolidLastSolved,
outcome: LoadedNativeOutcome,
) {
let LoadedNativeOutcome {
loaded,
native_path_display,
asc_filename,
asc_text,
is_mismatch,
} = outcome;
record_recent_native_file(ui, &native_path_display);
remember_design_location(
None,
Path::new(&native_path_display)
.parent()
.map(Path::to_path_buf),
);
let outcome_note = plain_load_outcome_text(&loaded.fingerprint, &loaded.tier_overlay);
let (material_note, material_still_unresolved) = restore_custom_material(
render_ctx,
loaded.restorable_custom_material.as_ref(),
loaded.design.material.name.as_deref(),
loaded.material_resolution,
);
let newer_version_note = loaded.written_by_newer_version.then(|| {
" This file was written by a newer version of Indicatrix Cut; some settings \
may not have been understood and could be lost on your next save."
.to_string()
});
let is_mismatch = is_mismatch || material_still_unresolved || loaded.written_by_newer_version;
let printed_proportions = loaded.printed_proportions;
state.borrow_mut().replace_wholesale(EditorState {
deep_solve_result_generation: None,
session: EditorSession::with_history(loaded.design, History::new()),
printed_proportions,
design_epoch: Arc::new(AtomicU64::new(0)),
pending_unsaved_action: None,
after_save: None,
deep_solve: None,
optimize: None,
pending_optimize: Arc::new(Mutex::new(None)),
asc_filename: Some(asc_filename),
original_asc_text: Some(asc_text),
pending_gear_remap: None,
pending_retarget: None,
last_pushed_scratch: RefCell::new(PushedScratch::default()),
material_combo_cache: RefCell::new(MaterialComboCache::default()),
source_entry_id: None,
used_placeholder: false,
file_extras: DesignFileExtras::default(),
has_design: true,
});
finish_state_replace(ui, render_ctx, preview_state, solid_last_solved, state);
show_toast(
ui,
&format!(
"Loaded '{native_path_display}'. {outcome_note}{}{} Saving writes a new \
.indicatrix file and leaves these older files untouched.",
material_note.unwrap_or_default(),
newer_version_note.unwrap_or_default()
),
if is_mismatch { "warning" } else { "success" },
);
}
pub(super) fn plain_load_outcome_text(
fingerprint: &FingerprintCheck,
tier_overlay: &TierOverlay,
) -> String {
if matches!(fingerprint, FingerprintCheck::Match) {
return match tier_overlay {
TierOverlay::Applied | TierOverlay::AppliedDespiteMismatch => {
"Your saved meet constraints were restored.".to_string()
}
TierOverlay::SkippedTierCountMismatch {
native_tiers,
asc_tiers,
} => format!(
"This file's saved tier count ({native_tiers}) does not match the .asc file's \
({asc_tiers}), so your saved meet constraints could not be restored."
),
TierOverlay::SkippedFingerprintMismatch => {
"Your saved meet constraints were restored.".to_string()
}
TierOverlay::AppliedFromDraft => "Loaded from an unsolved draft: the masts shown are \
placeholders, not a real solve."
.to_string(),
};
}
match tier_overlay {
TierOverlay::AppliedDespiteMismatch => "The .asc file changed since this sidecar was \
saved, so its geometry was used as-is; your saved meet constraints were re-applied \
anyway, at your request, and may no longer line up with the changed tiers."
.to_string(),
_ => "The .asc file changed since this sidecar was saved, so its geometry was used as-is \
and your saved meet constraints were not restored."
.to_string(),
}
}