use super::{
callbacks::clear_analysis_results,
state::{EditorState, PendingUnsavedAction, PushedScratch},
view::refresh_all,
};
use crate::{
EditorModel, MainWindow,
bridge::{library::source::LibrarySource, render_thread::RenderContext},
gui::{show_toast, solid_preview::preview_state::SolidPreviewState},
};
use indicatrix_cut_core::{
Design, FingerprintCheck, History, LoadPairedResult, NativeDesignFile, TierOverlay,
load_paired, native_path_for_asc, save_paired,
};
use indicatrix_vault::db::sqlite::Database;
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
use std::{
cell::RefCell,
path::{Path, PathBuf},
rc::Rc,
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use tracing::warn;
fn suggested_file_name(st: &EditorState) -> String {
if let Some(name) = &st.asc_filename {
return name.clone();
}
match st.design.meta.headers.first() {
Some(header) if !header.trim().is_empty() => {
format!(
"{}.asc",
crate::gui::library::local::sanitize_filename(header)
)
}
_ => "edited_design.asc".to_string(),
}
}
fn seed_default_export_dir(dialog: rfd::FileDialog) -> rfd::FileDialog {
let default_dir = std::path::Path::new("exports");
if default_dir.is_dir() {
dialog.set_directory(default_dir)
} else {
dialog
}
}
enum StatusConfirm {
Fine,
ConfirmedWithReason(String),
Declined,
}
const NOT_CLOSED_SOLID_MARKER: &str = "NOT A CLOSED SOLID";
fn degenerate_marker_header(headers: &[String], message: &str) -> Option<String> {
let already_marked = headers
.first()
.is_some_and(|h| h.starts_with(NOT_CLOSED_SOLID_MARKER));
(!already_marked).then(|| format!("{NOT_CLOSED_SOLID_MARKER} -- {message}"))
}
fn confirm_status_before_write(design: &Design, action: &str) -> StatusConfirm {
let (message, is_problem) = super::state::status_text_and_is_problem(design);
if !is_problem {
return StatusConfirm::Fine;
}
let choice = rfd::MessageDialog::new()
.set_title("This design is not a closed solid")
.set_description(format!(
"{message}\n\n{action} anyway? The written file will note this in its own header."
))
.set_level(rfd::MessageLevel::Warning)
.set_buttons(rfd::MessageButtons::YesNo)
.show();
if choice == rfd::MessageDialogResult::Yes {
StatusConfirm::ConfirmedWithReason(message)
} else {
StatusConfirm::Declined
}
}
fn stamp_source_entry_footnote(footnotes: &mut Vec<String>, source_entry_id: Option<i64>) {
footnotes.retain(|f| !f.starts_with(indicatrix_vault::local::SOURCE_ENTRY_FOOTNOTE_PREFIX));
if let Some(id) = source_entry_id {
footnotes.push(indicatrix_vault::local::format_source_entry_footnote(id));
}
}
enum CatalogueWriteBack {
UpdatedExisting,
NewRow,
SourceRowGoneNewRowCreated,
}
fn write_back_to_catalogue(
db: &Arc<Mutex<Database>>,
source_entry_id: Option<i64>,
asc_filename: &str,
asc_text: &str,
native_filename: &str,
native_toml: &str,
) -> Result<(i64, CatalogueWriteBack), String> {
let mut parsed = indicatrix_vault::local::import_asc(
asc_filename,
asc_text,
Some((native_filename, native_toml.as_bytes())),
)
.map_err(|e| format!("Saved to disk, but could not update the catalogue: {e}"))?;
crate::gui::library::local::apply_measured_metadata(&mut parsed.detail);
let db = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
write_back_locked(&db, source_entry_id, &mut parsed)
}
fn write_back_locked(
db: &Database,
source_entry_id: Option<i64>,
parsed: &mut indicatrix_vault::local::ImportedAsc,
) -> Result<(i64, CatalogueWriteBack), String> {
if let Some(existing_id) = source_entry_id {
match db.get_diagram_full(existing_id) {
Ok(Some(existing)) => {
crate::gui::library::local::merge_reimport_metadata(&mut parsed.detail, &existing);
db.update_diagram_entry_url(existing_id, &parsed.entry.url)
.map_err(|e| e.to_string())?;
db.save_diagram_detail(&parsed.detail, existing_id)
.map_err(|e| e.to_string())?;
if let Err(e) = db.delete_preview_images(existing_id) {
warn!(
"Save Native: failed to invalidate stale preview cache for entry \
#{existing_id}: {e}"
);
}
if let Err(e) = db.delete_tilt_curves(existing_id) {
warn!(
"Save Native: failed to invalidate stale tilt-curve cache for entry \
#{existing_id}: {e}"
);
}
return Ok((existing_id, CatalogueWriteBack::UpdatedExisting));
}
Ok(None) => {
}
Err(e) => {
return Err(format!(
"Saved to disk, but could not read this design's catalogue row to \
update it: {e}"
));
}
}
}
let new_id = db
.save_diagram_entry(&parsed.entry, indicatrix_vault::local::LOCAL_SOURCE_ID)
.map_err(|e| e.to_string())?;
db.save_diagram_detail(&parsed.detail, new_id)
.map_err(|e| e.to_string())?;
let outcome = if source_entry_id.is_some() {
CatalogueWriteBack::SourceRowGoneNewRowCreated
} else {
CatalogueWriteBack::NewRow
};
Ok((new_id, outcome))
}
pub(super) fn setup_export_cutting_sheet_callback(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
) {
let state = Rc::clone(state);
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_export_cutting_sheet(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let (design, base_name) = {
let st = state.borrow();
(st.design.clone(), suggested_file_name(&st))
};
let solved = match design.solve() {
Ok(solved) => solved,
Err(missing) => {
show_toast(
&ui,
&format!("Cannot build a cutting sheet: {missing}."),
"error",
);
return;
}
};
let base = base_name.trim_end_matches(".asc");
match super::cut_sheet::write_cutting_sheet_html(&design, &solved, base) {
Ok(Some(path)) => show_toast(
&ui,
&format!("Wrote cutting sheet to {}", path.display()),
"success",
),
Ok(None) => {}
Err(message) => show_toast(&ui, &message, "error"),
}
});
}
pub(super) fn setup_export_diagram_callback(ui: &MainWindow, state: &Rc<RefCell<EditorState>>) {
let state = Rc::clone(state);
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_export_diagram(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let (design, base_name) = {
let st = state.borrow();
(st.design.clone(), suggested_file_name(&st))
};
let solved = match design.solve() {
Ok(solved) => solved,
Err(missing) => {
show_toast(
&ui,
&format!("Cannot draw this design: {missing}."),
"error",
);
return;
}
};
let base = base_name.trim_end_matches(".asc");
match super::cut_sheet::write_diagram_png(&design, &solved, base) {
Ok(Some(path)) => {
show_toast(
&ui,
&format!("Wrote diagram to {}", path.display()),
"success",
);
}
Ok(None) => {}
Err(message) => show_toast(&ui, &message, "error"),
}
});
}
pub(super) fn setup_export_asc_callback(ui: &MainWindow, state: &Rc<RefCell<EditorState>>) {
let state = Rc::clone(state);
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_export_asc(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let (mut schedule, default_file_name, design, used_placeholder) = {
let st = state.borrow();
let mut schedule = match st.design.to_asc_schedule() {
Ok(schedule) => schedule,
Err(missing) => {
show_toast(&ui, &format!("Cannot export: {missing}."), "error");
return;
}
};
stamp_source_entry_footnote(&mut schedule.footnotes, st.source_entry_id);
(
schedule,
suggested_file_name(&st),
st.design.clone(),
st.used_placeholder,
)
};
if used_placeholder {
indicatrix_formats::asc::mark_reconstructed(
&mut schedule,
"angle-table reconstruction, no attached .asc",
);
}
match confirm_status_before_write(&design, "Export") {
StatusConfirm::Fine => {}
StatusConfirm::ConfirmedWithReason(message) => {
if let Some(header) = degenerate_marker_header(&schedule.headers, &message) {
schedule.headers.insert(0, header);
}
}
StatusConfirm::Declined => {
return;
}
}
let text = indicatrix_formats::asc::to_asc_string(&schedule);
let dest_path = seed_default_export_dir(
rfd::FileDialog::new()
.set_file_name(&default_file_name)
.add_filter(".asc design", &["asc"]),
)
.save_file();
let Some(dest_path) = dest_path else {
return;
};
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&dest_path, text) {
Ok(()) => {
record_last_saved_path(&ui, &dest_path);
show_toast(
&ui,
&format!("Exported edited schedule to {}", dest_path.display()),
"success",
);
}
Err(e) => show_toast(
&ui,
&format!("Failed to write {}: {e}", dest_path.display()),
"error",
),
}
});
}
fn confirm_overwrite_unrelated_native_file(native_path: &Path) -> bool {
let is_own_file =
CURRENT_NATIVE_PATH.with(|cell| cell.borrow().as_deref() == Some(native_path));
if !native_path.is_file() || is_own_file {
return true;
}
let choice = rfd::MessageDialog::new()
.set_title("Overwrite existing file?")
.set_description(format!(
"'{}' already exists and belongs to a different save (or a design this one \
was never opened from). Overwriting it replaces that design's native \
sidecar with this one's.",
native_path.display()
))
.set_level(rfd::MessageLevel::Warning)
.set_buttons(rfd::MessageButtons::YesNo)
.show();
choice == rfd::MessageDialogResult::Yes
}
fn known_save_target(st: &EditorState) -> Option<(PathBuf, PathBuf)> {
let native_path = CURRENT_NATIVE_PATH.with(|cell| cell.borrow().clone())?;
let asc_filename = st.asc_filename.as_deref()?;
Some((native_path.with_file_name(asc_filename), native_path))
}
fn quick_save_native(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
dest_path: &Path,
native_path: &Path,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
) {
let (
mut design,
asc_filename,
original_asc_text,
printed_proportions,
source_entry_id,
used_placeholder,
) = {
let st = state.borrow();
(
st.design.clone(),
st.asc_filename.clone().unwrap_or_default(),
st.original_asc_text.clone(),
st.printed_proportions,
st.source_entry_id,
st.used_placeholder,
)
};
stamp_source_entry_footnote(&mut design.meta.footnotes, source_entry_id);
match confirm_status_before_write(&design, "Save") {
StatusConfirm::Fine => {}
StatusConfirm::ConfirmedWithReason(message) => {
if let Some(header) = degenerate_marker_header(&design.meta.headers, &message) {
design.meta.headers.insert(0, header);
}
}
StatusConfirm::Declined => {
return;
}
}
let placeholder_note =
used_placeholder.then_some("angle-table reconstruction, no attached .asc");
let paired = match save_paired(
&design,
asc_filename.clone(),
original_asc_text.as_deref(),
placeholder_note,
printed_proportions.as_ref(),
) {
Ok(paired) => paired,
Err(e) => {
show_toast(ui, &format!("Cannot save: {e}"), "error");
return;
}
};
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(message) = write_pair_atomically(
dest_path,
native_path,
&paired.asc_text,
&paired.native_toml,
) {
show_toast(ui, &message, "error");
return;
}
finish_save_native_success(
ui,
state,
SavedPaths {
dest_path,
native_path,
asc_filename,
},
paired,
db,
source,
);
}
fn save_native_via_dialog(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
) {
let (
mut design,
default_asc_name,
original_asc_text,
printed_proportions,
source_entry_id,
used_placeholder,
) = {
let st = state.borrow();
(
st.design.clone(),
suggested_file_name(&st),
st.original_asc_text.clone(),
st.printed_proportions,
st.source_entry_id,
st.used_placeholder,
)
};
stamp_source_entry_footnote(&mut design.meta.footnotes, source_entry_id);
match confirm_status_before_write(&design, "Save") {
StatusConfirm::Fine => {}
StatusConfirm::ConfirmedWithReason(message) => {
if let Some(header) = degenerate_marker_header(&design.meta.headers, &message) {
design.meta.headers.insert(0, header);
}
}
StatusConfirm::Declined => {
return;
}
}
let dest_path = seed_default_export_dir(
rfd::FileDialog::new()
.set_file_name(&default_asc_name)
.add_filter(".asc design", &["asc"]),
)
.save_file();
let Some(dest_path) = dest_path else {
return;
};
let asc_filename = dest_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or(default_asc_name);
let native_path = native_path_for_asc(&dest_path);
if !confirm_overwrite_unrelated_native_file(&native_path) {
return;
}
let placeholder_note =
used_placeholder.then_some("angle-table reconstruction, no attached .asc");
let paired = match save_paired(
&design,
asc_filename.clone(),
original_asc_text.as_deref(),
placeholder_note,
printed_proportions.as_ref(),
) {
Ok(paired) => paired,
Err(e) => {
show_toast(ui, &format!("Cannot save: {e}"), "error");
return;
}
};
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(message) = write_pair_atomically(
&dest_path,
&native_path,
&paired.asc_text,
&paired.native_toml,
) {
show_toast(ui, &message, "error");
return;
}
finish_save_native_success(
ui,
state,
SavedPaths {
dest_path: &dest_path,
native_path: &native_path,
asc_filename,
},
paired,
db,
source,
);
}
pub(super) fn setup_save_native_callback(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
) {
setup_dirty_tracking(ui, state);
setup_autosave_timer(ui, state);
let state_save = Rc::clone(state);
let db_save = Arc::clone(db);
let source_save = Arc::clone(source);
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_save_native(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let known = {
let st = state_save.borrow();
known_save_target(&st)
};
match known {
Some((dest_path, native_path)) => {
quick_save_native(
&ui,
&state_save,
&dest_path,
&native_path,
&db_save,
&source_save,
);
}
None => save_native_via_dialog(&ui, &state_save, &db_save, &source_save),
}
});
let state_save_as = Rc::clone(state);
let db_save_as = Arc::clone(db);
let source_save_as = Arc::clone(source);
let ui_weak_as = ui.as_weak();
ui.global::<EditorModel>().on_save_native_as(move || {
let Some(ui) = ui_weak_as.upgrade() else {
return;
};
save_native_via_dialog(&ui, &state_save_as, &db_save_as, &source_save_as);
});
}
fn record_last_saved_path(ui: &MainWindow, path: &Path) {
let full = path.display().to_string();
let label = path
.file_name()
.map_or_else(|| full.clone(), |name| name.to_string_lossy().into_owned());
let model = ui.global::<EditorModel>();
model.set_last_saved_path(full.into());
model.set_last_saved_label(label.into());
}
struct SavedPaths<'a> {
dest_path: &'a Path,
native_path: &'a Path,
asc_filename: String,
}
fn finish_save_native_success(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
saved: SavedPaths<'_>,
paired: indicatrix_cut_core::native::PairedSave,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
) {
let SavedPaths {
dest_path,
native_path,
asc_filename,
} = saved;
let _ = std::fs::remove_file(autosave_path(Some(asc_filename.as_str())));
record_recent_native_file(ui, &native_path.display().to_string());
CURRENT_NATIVE_PATH.with(|cell| *cell.borrow_mut() = Some(native_path.to_path_buf()));
ui.set_loaded_design_name(asc_filename.clone().into());
record_last_saved_path(ui, dest_path);
let asc_filename_for_catalogue = asc_filename.clone();
let asc_text_for_catalogue = paired.asc_text.clone();
let native_toml_for_catalogue = paired.native_toml.clone();
let native_filename_for_catalogue = native_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
{
let mut st = state.borrow_mut();
st.asc_filename = Some(asc_filename);
st.original_asc_text = Some(paired.asc_text);
st.saved_generation = st.generation.load(Ordering::Relaxed);
}
ui.global::<EditorModel>()
.set_is_dirty(state.borrow().is_dirty());
if let Some(reason) = &paired.draft_reason {
show_toast(
ui,
&format!(
"Saved '{}' as a draft ({reason}). Add a scale-reference tier to \
finish it.",
dest_path.display()
),
"warning",
);
} else {
let asc_note = if paired.asc_preserved {
"unchanged .asc preserved"
} else {
".asc regenerated (meet notes rewritten)"
};
show_toast(
ui,
&format!(
"Saved '{}' and '{}' ({asc_note}).",
dest_path.display(),
native_path.display()
),
"success",
);
}
let source_entry_id = state.borrow().source_entry_id;
match write_back_to_catalogue(
db,
source_entry_id,
&asc_filename_for_catalogue,
&asc_text_for_catalogue,
&native_filename_for_catalogue,
&native_toml_for_catalogue,
) {
Ok((id, outcome)) => {
state.borrow_mut().source_entry_id = Some(id);
crate::gui::library::local::refresh_after_library_change(ui, db, source);
if matches!(outcome, CatalogueWriteBack::SourceRowGoneNewRowCreated) {
show_toast(
ui,
"This design's catalogue row no longer exists (it may have been \
deleted) -- saved as a new catalogue entry instead of updating it.",
"info",
);
}
}
Err(message) => show_toast(ui, &format!("Catalogue not updated: {message}"), "error"),
}
}
fn write_pair_atomically(
dest_path: &Path,
native_path: &Path,
asc_text: &str,
native_toml: &str,
) -> Result<(), String> {
let tmp_asc = temp_sibling(dest_path);
let tmp_native = temp_sibling(native_path);
if let Err(e) = std::fs::write(&tmp_asc, asc_text) {
let _ = std::fs::remove_file(&tmp_asc);
return Err(format!(
"Failed to write {}: {e}. Nothing was saved.",
dest_path.display()
));
}
if let Err(e) = std::fs::write(&tmp_native, native_toml) {
let _ = std::fs::remove_file(&tmp_asc);
let _ = std::fs::remove_file(&tmp_native);
return Err(format!(
"Failed to write {}: {e}. Nothing was saved.",
native_path.display()
));
}
if let Err(message) = backup_existing(dest_path) {
let _ = std::fs::remove_file(&tmp_asc);
let _ = std::fs::remove_file(&tmp_native);
return Err(message);
}
if let Err(message) = backup_existing(native_path) {
let _ = std::fs::remove_file(&tmp_asc);
let _ = std::fs::remove_file(&tmp_native);
return Err(message);
}
if let Err(e) = std::fs::rename(&tmp_asc, dest_path) {
let _ = std::fs::remove_file(&tmp_asc);
let _ = std::fs::remove_file(&tmp_native);
return Err(format!(
"Failed to finalize {}: {e}. Nothing was saved.",
dest_path.display()
));
}
if let Err(e) = std::fs::rename(&tmp_native, native_path) {
return Err(format!(
"Saved '{}' but failed to finalize its native sidecar {}: {e}. The .asc is on \
disk without its native sidecar -- re-save once the problem is fixed.",
dest_path.display(),
native_path.display()
));
}
Ok(())
}
fn temp_sibling(path: &Path) -> PathBuf {
let file_name = path.file_name().map_or_else(
|| std::ffi::OsString::from("save.tmp"),
|n| {
let mut s = n.to_os_string();
s.push(".tmp");
s
},
);
path.with_file_name(file_name)
}
fn backup_sibling(path: &Path) -> PathBuf {
let file_name = path.file_name().map_or_else(
|| std::ffi::OsString::from("save.bak"),
|n| {
let mut s = n.to_os_string();
s.push(".bak");
s
},
);
path.with_file_name(file_name)
}
fn backup_existing(path: &Path) -> Result<(), String> {
if !path.is_file() {
return Ok(());
}
let backup = backup_sibling(path);
std::fs::copy(path, &backup).map_err(|e| {
format!(
"Failed to back up {} to {} before overwriting it: {e}. Nothing was saved.",
path.display(),
backup.display()
)
})?;
Ok(())
}
fn setup_dirty_tracking(ui: &MainWindow, state: &Rc<RefCell<EditorState>>) {
let state = Rc::clone(state);
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_recompute_dirty(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
if let Ok(st) = state.try_borrow() {
ui.global::<EditorModel>().set_is_dirty(st.is_dirty());
}
});
}
const AUTOSAVE_INTERVAL: Duration = Duration::from_secs(120);
thread_local! {
static CURRENT_NATIVE_PATH: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}
thread_local! {
static AUTOSAVE_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
}
fn setup_autosave_timer(ui: &MainWindow, state: &Rc<RefCell<EditorState>>) {
let state = Rc::clone(state);
let ui_weak = ui.as_weak();
let timer = slint::Timer::default();
timer.start(slint::TimerMode::Repeated, AUTOSAVE_INTERVAL, move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
run_autosave_tick(&ui, &state);
});
AUTOSAVE_TIMER.with(|cell| *cell.borrow_mut() = Some(timer));
}
fn run_autosave_tick(ui: &MainWindow, state: &Rc<RefCell<EditorState>>) {
let (design, asc_filename, original_asc_text, printed_proportions) = {
let st = state.borrow();
if !st.is_dirty() {
return;
}
(
st.design.clone(),
st.asc_filename.clone(),
st.original_asc_text.clone(),
st.printed_proportions,
)
};
let Ok(saved) = save_paired(
&design,
format!("{}.asc", autosave_base_name(asc_filename.as_deref())),
original_asc_text.as_deref(),
None,
printed_proportions.as_ref(),
) else {
return;
};
let path = autosave_path(asc_filename.as_deref());
if let Err(e) = write_autosave(&path, &saved.native_toml) {
show_toast(
ui,
&format!("Autosave to {} failed: {e}", path.display()),
"error",
);
}
}
fn autosave_base_name(asc_filename: Option<&str>) -> String {
let name = asc_filename.unwrap_or("untitled");
name.strip_suffix(".asc").unwrap_or(name).to_string()
}
fn autosave_path(asc_filename: Option<&str>) -> PathBuf {
let dir = crate::settings::store::default_settings_path()
.parent()
.map_or_else(std::env::temp_dir, std::path::Path::to_path_buf);
dir.join(format!(
"{}.indicatrix.autosave.toml",
autosave_base_name(asc_filename)
))
}
#[must_use]
pub(super) fn find_leftover_autosave() -> Option<PathBuf> {
let dir = crate::settings::store::default_settings_path()
.parent()
.map_or_else(std::env::temp_dir, std::path::Path::to_path_buf);
std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".indicatrix.autosave.toml"))
})
.max_by_key(|path| std::fs::metadata(path).and_then(|m| m.modified()).ok())
}
fn write_autosave(path: &Path, native_toml: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let tmp = temp_sibling(path);
std::fs::write(&tmp, native_toml).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
Ok(())
}
fn record_recent_native_file(ui: &MainWindow, native_path_display: &str) {
let settings_path = crate::settings::store::default_settings_path();
let mut file = crate::settings::store::load_or_default(&settings_path);
file.settings
.record_recent_native_file(native_path_display.to_string());
let _ = crate::settings::store::save(&settings_path, &file);
ui.set_recent_native_files(ModelRc::new(VecModel::from(
file.settings
.recent_native_files
.into_iter()
.map(SharedString::from)
.collect::<Vec<_>>(),
)));
}
pub(super) fn setup_open_native_callback(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &super::view::SolidLastSolved,
) {
setup_mismatch_dialog_callbacks(ui, state, render_ctx, preview_state, solid_last_solved);
let state_open = Rc::clone(state);
let render_ctx_open = Arc::clone(render_ctx);
let preview_state_open = Arc::clone(preview_state);
let solid_last_solved_open = Arc::clone(solid_last_solved);
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_open_native(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
if state_open.borrow().is_dirty() {
state_open.borrow_mut().pending_unsaved_action = Some(PendingUnsavedAction::OpenNative);
ui.global::<EditorModel>().set_unsaved_dialog_message(
"Opening a native file will discard the current design's unsaved changes.".into(),
);
ui.global::<EditorModel>().set_unsaved_dialog_open(true);
return;
}
do_open_native(
&ui,
&state_open,
&render_ctx_open,
&preview_state_open,
&solid_last_solved_open,
);
});
let state_recent = Rc::clone(state);
let render_ctx_recent = Arc::clone(render_ctx);
let preview_state_recent = Arc::clone(preview_state);
let solid_last_solved_recent = Arc::clone(solid_last_solved);
let ui_weak_recent = ui.as_weak();
ui.on_open_recent_native_file(move |path| {
let Some(ui) = ui_weak_recent.upgrade() else {
return;
};
open_recent_native_path(
&ui,
&state_recent,
&render_ctx_recent,
&preview_state_recent,
&solid_last_solved_recent,
PathBuf::from(path.as_str()),
);
});
}
pub(super) fn open_recent_native_path(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &super::view::SolidLastSolved,
native_path: PathBuf,
) {
if state.borrow().is_dirty() {
show_toast(
ui,
"Save or discard the current design's unsaved changes before opening a recent file.",
"error",
);
return;
}
let Some((parsed_native, asc_text, native_text)) = read_native_pair(ui, &native_path) else {
return;
};
open_native_pair(
ui,
state,
render_ctx,
preview_state,
solid_last_solved,
PickedPair {
native_path,
parsed_native,
asc_text,
native_text,
},
);
}
struct PendingMismatch {
native_path_display: String,
asc_filename: String,
asc_text: String,
native_text: String,
}
thread_local! {
static PENDING_MISMATCH: RefCell<Option<PendingMismatch>> = const { RefCell::new(None) };
}
enum PickedNative {
Pair(Box<PickedPair>),
AscOnly { asc_path: PathBuf, asc_text: String },
}
struct PickedPair {
native_path: PathBuf,
parsed_native: NativeDesignFile,
asc_text: String,
native_text: String,
}
fn read_native_pair(
ui: &MainWindow,
native_path: &Path,
) -> Option<(NativeDesignFile, String, String)> {
let native_text = match std::fs::read_to_string(native_path) {
Ok(text) => text,
Err(e) => {
show_toast(
ui,
&format!("Failed to read {}: {e}", native_path.display()),
"error",
);
return None;
}
};
let parsed_native = match indicatrix_cut_core::native::parse_toml_string(&native_text) {
Ok(n) => n,
Err(e) => {
show_toast(
ui,
&format!(
"'{}' is not a valid native design file: {e}",
native_path.display()
),
"error",
);
return None;
}
};
let asc_path = native_path.with_file_name(&parsed_native.asc_filename);
let asc_text =
resolve_paired_asc_text(ui, native_path, &asc_path, &parsed_native.asc_filename)?;
Some((parsed_native, asc_text, native_text))
}
fn resolve_paired_asc_text(
ui: &MainWindow,
native_path: &Path,
recorded_asc_path: &Path,
recorded_asc_filename: &str,
) -> Option<String> {
if let Ok(text) = std::fs::read_to_string(recorded_asc_path) {
return Some(text);
}
if let Some(guessed) = indicatrix_cut_core::asc_path_for_native(native_path)
&& guessed != recorded_asc_path
&& let Ok(text) = std::fs::read_to_string(&guessed)
{
return Some(text);
}
show_toast(
ui,
&format!(
"'{}' names a paired .asc file '{recorded_asc_filename}', but it could not be found \
next to it. Locate it to continue.",
native_path.display()
),
"info",
);
let Some(located) = rfd::FileDialog::new()
.set_title("Locate the paired .asc")
.add_filter(".asc design", &["asc"])
.pick_file()
else {
return None;
};
match std::fs::read_to_string(&located) {
Ok(text) => Some(text),
Err(e) => {
show_toast(
ui,
&format!("Failed to read {}: {e}", located.display()),
"error",
);
None
}
}
}
fn read_picked_asc(ui: &MainWindow, asc_path: &Path) -> Option<PickedNative> {
let native_path = native_path_for_asc(asc_path);
if native_path.is_file() {
let (parsed_native, asc_text, native_text) = read_native_pair(ui, &native_path)?;
return Some(PickedNative::Pair(Box::new(PickedPair {
native_path,
parsed_native,
asc_text,
native_text,
})));
}
let asc_text = match std::fs::read_to_string(asc_path) {
Ok(text) => text,
Err(e) => {
show_toast(
ui,
&format!("Failed to read {}: {e}", asc_path.display()),
"error",
);
return None;
}
};
Some(PickedNative::AscOnly {
asc_path: asc_path.to_path_buf(),
asc_text,
})
}
fn read_picked_native(ui: &MainWindow) -> Option<PickedNative> {
let Some(picked_path) = rfd::FileDialog::new()
.set_title("Open Native Design")
.add_filter(
"Indicatrix native design",
&[
indicatrix_cut_core::native::NATIVE_EXTENSION_SUFFIX,
indicatrix_cut_core::native::LEGACY_NATIVE_EXTENSION_SUFFIX,
],
)
.add_filter("All TOML files", &["toml"])
.add_filter(".asc design", &["asc"])
.pick_file()
else {
return None;
};
if picked_path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("asc"))
{
return read_picked_asc(ui, &picked_path);
}
let (parsed_native, asc_text, native_text) = read_native_pair(ui, &picked_path)?;
Some(PickedNative::Pair(Box::new(PickedPair {
native_path: picked_path,
parsed_native,
asc_text,
native_text,
})))
}
pub(in crate::gui::editor) fn do_open_native(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &super::view::SolidLastSolved,
) {
match read_picked_native(ui) {
Some(PickedNative::Pair(picked)) => {
open_native_pair(
ui,
state,
render_ctx,
preview_state,
solid_last_solved,
*picked,
);
}
Some(PickedNative::AscOnly { asc_path, asc_text }) => open_plain_asc(
ui,
state,
render_ctx,
preview_state,
solid_last_solved,
&asc_path,
&asc_text,
),
None => {}
}
}
fn open_native_pair(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &super::view::SolidLastSolved,
picked: PickedPair,
) {
let PickedPair {
native_path,
parsed_native,
asc_text,
native_text,
} = picked;
match load_paired(&asc_text, &native_text, false) {
Ok(loaded) => {
if matches!(loaded.tier_overlay, TierOverlay::SkippedFingerprintMismatch) {
let can_apply = parsed_native.tiers.len() == loaded.design.tiers.len();
PENDING_MISMATCH.with(|cell| {
*cell.borrow_mut() = Some(PendingMismatch {
native_path_display: native_path.display().to_string(),
asc_filename: parsed_native.asc_filename,
asc_text,
native_text,
});
});
ui.global::<EditorModel>()
.set_mismatch_dialog_can_apply(can_apply);
ui.global::<EditorModel>().set_mismatch_dialog_open(true);
return;
}
let is_mismatch = !matches!(loaded.fingerprint, FingerprintCheck::Match);
commit_loaded_native(
ui,
state,
render_ctx,
preview_state,
solid_last_solved,
LoadedNativeOutcome {
loaded,
native_path_display: native_path.display().to_string(),
asc_filename: parsed_native.asc_filename,
asc_text,
is_mismatch,
},
);
}
Err(e) => show_toast(ui, &format!("Cannot open: {e}"), "error"),
}
}
fn open_plain_asc(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &super::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 super::loading::design_from_asc_text(&file_name, asc_text, None) {
Ok(loaded) => {
CURRENT_NATIVE_PATH.with(|cell| *cell.borrow_mut() = None);
state.borrow_mut().replace_wholesale(EditorState {
design: loaded.design,
history: History::new(),
printed_proportions: None,
generation: Arc::new(AtomicU64::new(0)),
design_epoch: Arc::new(AtomicU64::new(0)),
saved_generation: 0,
pending_unsaved_action: None,
deep_solve: None,
optimize: None,
pending_optimize: Arc::new(Mutex::new(None)),
asc_filename: loaded.asc_filename,
original_asc_text: loaded.original_asc_text,
pending_gear_remap: None,
pending_retarget: None,
multi_selected: std::collections::BTreeSet::new(),
last_pushed_scratch: RefCell::new(PushedScratch::default()),
source_entry_id: None,
used_placeholder: false,
});
finish_state_replace(ui, render_ctx, preview_state, solid_last_solved, state);
show_toast(
ui,
&format!(
"Loaded '{}' (plain .asc, no native sidecar found -- authored meet \
constraints and detached facets are not available).",
asc_path.display()
),
"success",
);
}
Err(e) => show_toast(ui, &format!("Cannot open: {e}"), "error"),
}
}
fn finish_state_replace(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &super::view::SolidLastSolved,
state: &Rc<RefCell<EditorState>>,
) {
clear_analysis_results(ui);
let st = state.borrow();
refresh_all(ui, render_ctx, preview_state, solid_last_solved, &st);
ui.set_loaded_design_name(st.asc_filename.clone().unwrap_or_default().into());
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);
}
struct LoadedNativeOutcome {
loaded: LoadPairedResult,
native_path_display: String,
asc_filename: String,
asc_text: String,
is_mismatch: bool,
}
fn commit_loaded_native(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &super::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);
CURRENT_NATIVE_PATH.with(|cell| *cell.borrow_mut() = Some(PathBuf::from(&native_path_display)));
let fingerprint_note = loaded.fingerprint.to_string();
let overlay_note = loaded.tier_overlay.to_string();
let material_note = matches!(
loaded.material_resolution,
indicatrix_cut_core::native::MaterialResolution::Unresolved
)
.then(|| format!(" {}", 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_note.is_some() || loaded.written_by_newer_version;
let printed_proportions = loaded.printed_proportions;
state.borrow_mut().replace_wholesale(EditorState {
design: loaded.design,
history: History::new(),
printed_proportions,
generation: Arc::new(AtomicU64::new(0)),
design_epoch: Arc::new(AtomicU64::new(0)),
saved_generation: 0,
pending_unsaved_action: 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,
multi_selected: std::collections::BTreeSet::new(),
last_pushed_scratch: RefCell::new(PushedScratch::default()),
source_entry_id: None,
used_placeholder: false,
});
finish_state_replace(ui, render_ctx, preview_state, solid_last_solved, state);
show_toast(
ui,
&format!(
"Loaded '{native_path_display}': {fingerprint_note}; {overlay_note}.{}{}",
material_note.unwrap_or_default(),
newer_version_note.unwrap_or_default()
),
if is_mismatch { "warning" } else { "success" },
);
}
fn setup_mismatch_dialog_callbacks(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &super::view::SolidLastSolved,
) {
let state_apply = Rc::clone(state);
let render_ctx_apply = Arc::clone(render_ctx);
let preview_state_apply = Arc::clone(preview_state);
let solid_last_solved_apply = Arc::clone(solid_last_solved);
let ui_weak_apply = ui.as_weak();
ui.global::<EditorModel>()
.on_mismatch_dialog_apply_anyway(move || {
let Some(ui) = ui_weak_apply.upgrade() else {
return;
};
ui.global::<EditorModel>().set_mismatch_dialog_open(false);
let Some(pending) = PENDING_MISMATCH.with(RefCell::take) else {
return;
};
match load_paired(&pending.asc_text, &pending.native_text, true) {
Ok(loaded) => commit_loaded_native(
&ui,
&state_apply,
&render_ctx_apply,
&preview_state_apply,
&solid_last_solved_apply,
LoadedNativeOutcome {
loaded,
native_path_display: pending.native_path_display,
asc_filename: pending.asc_filename,
asc_text: pending.asc_text,
is_mismatch: true,
},
),
Err(e) => show_toast(&ui, &format!("Cannot open: {e}"), "error"),
}
});
let state_asc_only = Rc::clone(state);
let render_ctx_asc_only = Arc::clone(render_ctx);
let preview_state_asc_only = Arc::clone(preview_state);
let solid_last_solved_asc_only = Arc::clone(solid_last_solved);
let ui_weak_asc_only = ui.as_weak();
ui.global::<EditorModel>()
.on_mismatch_dialog_asc_only(move || {
let Some(ui) = ui_weak_asc_only.upgrade() else {
return;
};
ui.global::<EditorModel>().set_mismatch_dialog_open(false);
let Some(pending) = PENDING_MISMATCH.with(RefCell::take) else {
return;
};
match load_paired(&pending.asc_text, &pending.native_text, false) {
Ok(loaded) => commit_loaded_native(
&ui,
&state_asc_only,
&render_ctx_asc_only,
&preview_state_asc_only,
&solid_last_solved_asc_only,
LoadedNativeOutcome {
loaded,
native_path_display: pending.native_path_display,
asc_filename: pending.asc_filename,
asc_text: pending.asc_text,
is_mismatch: true,
},
),
Err(e) => show_toast(&ui, &format!("Cannot open: {e}"), "error"),
}
});
ui.global::<EditorModel>()
.on_mismatch_dialog_cancel(move || {
PENDING_MISMATCH.with(|cell| *cell.borrow_mut() = None);
});
}
#[cfg(test)]
mod tests {
use super::{NOT_CLOSED_SOLID_MARKER, degenerate_marker_header};
use indicatrix::geometry::meet_solver::MeetConstraint;
use indicatrix_cut_core::{
ConstraintTier, Design, FreshDesignSpec, MaterialSelection, PreformSpec, load_paired,
save_paired,
};
#[test]
fn gear_symmetry_mirror_and_material_all_round_trip_through_save_and_open() {
let spec = FreshDesignSpec {
gear_teeth: 80,
symmetry_order: 5,
mirror: false,
material: MaterialSelection {
name: Some("Quartz".to_string()),
specific_gravity_override: Some(2.65),
refractive_index_override: Some(1.55),
},
preform: PreformSpec::cylinder(80, 1.4, 1.0, 1.3),
};
let mut design = Design::fresh_from_spec(spec);
design.tiers.push(ConstraintTier {
angle_deg: -40.0,
name: "P1".to_string(),
indices: vec![0.0, 16.0, 32.0, 48.0, 64.0],
constraint: MeetConstraint::ScaleReference(0.5),
imported_meet: None,
original_notes: None,
detached: Vec::new(),
});
let saved = save_paired(&design, "roundtrip.asc", None, None, None)
.expect("a fresh design must save");
let loaded =
load_paired(&saved.asc_text, &saved.native_toml, false).expect("must load back");
assert_eq!(loaded.design.meta.gear_teeth, 80);
assert_eq!(loaded.design.meta.symmetry_order, 5);
assert!(!loaded.design.meta.mirror);
assert_eq!(loaded.design.material.name.as_deref(), Some("Quartz"));
assert_eq!(loaded.design.material.specific_gravity_override, Some(2.65));
assert_eq!(loaded.design.material.refractive_index_override, Some(1.55));
assert!((loaded.design.effective_refractive_index() - 1.55).abs() < 1e-9);
}
#[test]
fn degenerate_marker_header_stamps_the_reason_when_absent() {
let headers: Vec<String> = vec!["GemCad 5.0".to_string()];
let header = degenerate_marker_header(&headers, "Degenerate: only 2 distinct vertices.")
.expect("no existing marker -- must stamp one");
assert!(header.starts_with(NOT_CLOSED_SOLID_MARKER));
assert!(header.contains("Degenerate: only 2 distinct vertices."));
}
#[test]
fn degenerate_marker_header_never_stamps_twice() {
let headers = vec![format!("{NOT_CLOSED_SOLID_MARKER} -- already noted")];
assert!(degenerate_marker_header(&headers, "a different message").is_none());
}
}