use super::{
callbacks::clear_analysis_results,
state::{EditorState, MaterialComboCache, 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::{
geometry::{meet_solver::SolvedTier, stone_metrics::ExternalProportions},
optics::materials::GemMaterial,
};
use indicatrix_cut_core::{
Design, FingerprintCheck, History, LoadPairedResult, NativeDesignFile, TierOverlay,
built_in_refractive_index, load_paired, native_path_for_asc,
};
use indicatrix_cut_core::native::{
CustomMaterialSnapshot, LoadNativeOnlyResult, PairedSave, SaveError, SaveExtras,
gem_material_from_custom_snapshot, load_native_only, save_native_only_toml,
save_paired_extended, save_paired_extended_from_solved,
};
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;
use super::solve_service::{SolveKind, SolveOutcome, SolveRequest, SolveService};
#[must_use]
fn solve_matches_design(cached: Option<&[SolvedTier]>, design: &Design) -> Option<Vec<SolvedTier>> {
let cached = cached?;
(cached.len() == design.tiers.len()).then(|| cached.to_vec())
}
fn cached_solve_matching(design: &Design) -> Option<Vec<SolvedTier>> {
let cache = super::auto_solve::solid_last_solved()?;
let guard = cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
solve_matches_design(guard.as_deref(), design)
}
type SolveContinuation = Box<dyn FnOnce(&MainWindow, Result<Vec<SolvedTier>, String>)>;
thread_local! {
static SOLVE_SERVICE: RefCell<Option<SolveService>> = const { RefCell::new(None) };
static PENDING_SOLVES: RefCell<std::collections::HashMap<u64, SolveContinuation>> =
RefCell::new(std::collections::HashMap::new());
static NEXT_SOLVE_KEY: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
fn ensure_solve_service(ui: &MainWindow) {
SOLVE_SERVICE.with(|cell| {
if cell.borrow().is_some() {
return;
}
let service = SolveService::new(
ui.as_weak(),
|_ui, _progress| {},
|ui, result| {
let Some(on_done) =
PENDING_SOLVES.with(|c| c.borrow_mut().remove(&result.generation))
else {
return;
};
let outcome = match result.outcome {
SolveOutcome::Solved(Ok(solved)) => Ok(solved),
SolveOutcome::Solved(Err(e)) => Err(e.to_string()),
SolveOutcome::Verified(_) => Err("unexpected solve result kind".to_string()),
};
on_done(ui, outcome);
},
);
*cell.borrow_mut() = Some(service);
});
}
fn submit_full_solve(
ui: &MainWindow,
design: Arc<Design>,
on_done: impl FnOnce(&MainWindow, Result<Vec<SolvedTier>, String>) + 'static,
) {
ensure_solve_service(ui);
let key = NEXT_SOLVE_KEY.with(|c| {
let key = c.get();
c.set(key + 1);
key
});
PENDING_SOLVES.with(|cell| {
cell.borrow_mut().insert(key, Box::new(on_done));
});
SOLVE_SERVICE.with(|cell| {
if let Some(service) = cell.borrow().as_ref() {
service.submit(SolveRequest {
design,
generation: key,
kind: SolveKind::Full,
});
}
});
}
pub(super) fn resolve_solved_then(
ui: &MainWindow,
design: Arc<Design>,
then: impl FnOnce(&MainWindow, Arc<Design>, Result<Vec<SolvedTier>, String>) + 'static,
) {
if let Some(solved) = cached_solve_matching(&design) {
then(ui, design, Ok(solved));
return;
}
let for_submit = Arc::clone(&design);
submit_full_solve(ui, for_submit, move |ui, result| {
then(ui, design, result);
});
}
enum StatusDecision {
Fine,
NeedsConfirm(String),
}
#[must_use]
fn decide_write_status(design: &Design, solved: Result<&[SolvedTier], &str>) -> StatusDecision {
match solved {
Ok(solved) => {
let (message, is_problem) =
super::state::status_text_and_is_problem_from_solved(design, solved);
if is_problem {
StatusDecision::NeedsConfirm(message)
} else {
StatusDecision::Fine
}
}
Err(missing) => StatusDecision::NeedsConfirm(missing.to_string()),
}
}
struct PendingWriteConfirm {
on_accept: Box<dyn FnOnce(&MainWindow)>,
suppress_key: Option<&'static str>,
}
pub(in crate::gui) mod confirm_keys {
pub(in crate::gui) const NOT_CLOSED_SOLID: &str = "write_confirm.not_closed_solid";
pub(in crate::gui) const OVERWRITE_UNRELATED_NATIVE: &str =
"write_confirm.overwrite_unrelated_native";
}
fn confirm_is_suppressed(key: &str) -> bool {
let settings_path = crate::settings::store::default_settings_path();
crate::settings::store::load_or_default(&settings_path)
.settings
.is_confirm_suppressed(key)
}
fn suppress_confirm_permanently(key: &'static str) {
let settings_path = crate::settings::store::default_settings_path();
let mut file = crate::settings::store::load_or_default(&settings_path);
file.settings.suppress_confirm(key);
let _ = crate::settings::store::save(&settings_path, &file);
}
thread_local! {
static PENDING_WRITE_CONFIRM: RefCell<Option<PendingWriteConfirm>> = const { RefCell::new(None) };
}
pub(in crate::gui) fn ask_write_confirm(
ui: &MainWindow,
heading: &str,
message: String,
primary_label: &str,
suppress_key: Option<&'static str>,
on_accept: impl FnOnce(&MainWindow) + 'static,
) {
if suppress_key.is_some_and(confirm_is_suppressed) {
on_accept(ui);
return;
}
PENDING_WRITE_CONFIRM.with(|cell| {
*cell.borrow_mut() = Some(PendingWriteConfirm {
on_accept: Box::new(on_accept),
suppress_key,
});
});
let model = ui.global::<EditorModel>();
model.set_write_confirm_heading(heading.into());
model.set_write_confirm_message(message.into());
model.set_write_confirm_primary_label(primary_label.into());
model.set_write_confirm_show_dont_ask(suppress_key.is_some());
model.set_write_confirm_dont_ask(false);
model.set_write_confirm_open(true);
}
fn setup_write_confirm_dialog_callbacks(ui: &MainWindow) {
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_write_confirm_accept(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let model = ui.global::<EditorModel>();
model.set_write_confirm_open(false);
let Some(pending) = PENDING_WRITE_CONFIRM.with(RefCell::take) else {
return;
};
if let Some(key) = pending.suppress_key
&& model.get_write_confirm_dont_ask()
{
suppress_confirm_permanently(key);
}
super::stall_guard::stall_guard("write_confirm_accept", || (pending.on_accept)(&ui));
});
ui.global::<EditorModel>().on_write_confirm_cancel(move || {
PENDING_WRITE_CONFIRM.with(|cell| *cell.borrow_mut() = None);
});
}
enum PickKind {
SaveAsc { default_name: String },
SaveCuttingSheet { default_name: String },
SaveDiagram { default_name: String },
OpenNativeOrAsc,
LocateAsc,
}
fn asc_filter() -> crate::gui::pickers::PickerFilter {
crate::gui::pickers::PickerFilter {
label: ".asc design".to_string(),
extensions: vec!["asc".to_string()],
}
}
fn pick_file(
ui: &MainWindow,
kind: PickKind,
on_done: impl FnOnce(&MainWindow, Option<PathBuf>) + 'static,
) {
use crate::gui::pickers::{PickerFilter, PickerKind, PickerRequest, pick};
let request = match kind {
PickKind::SaveAsc { default_name } => PickerRequest {
kind: PickerKind::SaveFile,
title: None,
filters: vec![asc_filter()],
default_file_name: Some(default_name),
starting_dir: default_export_dir(),
},
PickKind::SaveCuttingSheet { default_name } => PickerRequest {
kind: PickerKind::SaveFile,
title: None,
filters: vec![PickerFilter {
label: "Cutting sheet (HTML)".to_string(),
extensions: vec!["html".to_string()],
}],
default_file_name: Some(default_name),
starting_dir: None,
},
PickKind::SaveDiagram { default_name } => PickerRequest {
kind: PickerKind::SaveFile,
title: None,
filters: vec![PickerFilter {
label: "Diagram (PNG)".to_string(),
extensions: vec!["png".to_string()],
}],
default_file_name: Some(default_name),
starting_dir: None,
},
PickKind::OpenNativeOrAsc => PickerRequest {
kind: PickerKind::OpenFile,
title: Some("Open Native Design".to_string()),
filters: vec![
PickerFilter {
label: "Indicatrix native design".to_string(),
extensions: vec![
indicatrix_cut_core::native::NATIVE_EXTENSION_SUFFIX.to_string(),
indicatrix_cut_core::native::LEGACY_NATIVE_EXTENSION_SUFFIX.to_string(),
],
},
PickerFilter {
label: "All TOML files".to_string(),
extensions: vec!["toml".to_string()],
},
asc_filter(),
],
default_file_name: None,
starting_dir: None,
},
PickKind::LocateAsc => PickerRequest {
kind: PickerKind::OpenFile,
title: Some("Locate the paired .asc".to_string()),
filters: vec![asc_filter()],
default_file_name: None,
starting_dir: None,
},
};
pick(ui, request, on_done);
}
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 default_export_dir() -> Option<PathBuf> {
let default_dir = Path::new("exports");
default_dir.is_dir().then(|| default_dir.to_path_buf())
}
fn snapshot_custom_materials(render_ctx: &Arc<Mutex<RenderContext>>) -> Arc<Vec<GemMaterial>> {
Arc::clone(
&render_ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.custom_materials,
)
}
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 save_paired_reusing_solve(
design: &Design,
solved: Option<&[SolvedTier]>,
asc_filename: impl Into<String>,
original_asc_text: Option<&str>,
placeholder_note: Option<&str>,
printed_proportions: Option<&ExternalProportions>,
extras: &SaveExtras<'_>,
) -> Result<PairedSave, SaveError> {
match solved {
Some(solved) if !design.tiers.is_empty() => save_paired_extended_from_solved(
design,
solved,
asc_filename,
original_asc_text,
placeholder_note,
printed_proportions,
extras,
),
_ => save_paired_extended(
design,
asc_filename,
original_asc_text,
placeholder_note,
printed_proportions,
extras,
),
}
}
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));
}
}
fn custom_material_snapshot_for_save(
design: &Design,
db: &Arc<Mutex<Database>>,
) -> Option<CustomMaterialSnapshot> {
let name = design.material.name.as_deref()?;
if built_in_refractive_index(name).is_some() {
return None;
}
let row = db
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get_custom_materials()
.ok()?
.into_iter()
.find(|r| r.name.eq_ignore_ascii_case(name))?;
Some(CustomMaterialSnapshot::new(
f64::from(row.refractive_index),
f64::from(row.dispersion),
f64::from(row.birefringence),
row.specific_gravity.map(f64::from),
row.crystal_system.unwrap_or_default(),
row.optical_character.unwrap_or_default(),
))
}
enum CatalogueWriteBack {
UpdatedExisting {
stale_cache_invalidation_failed: bool,
},
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 invalidate_stale_caches(db: &Database, existing_id: i64) -> bool {
let preview_failed = db
.delete_preview_images(existing_id)
.inspect_err(|e| {
warn!(
"Save Native: failed to invalidate stale preview cache for entry \
#{existing_id}: {e}"
);
})
.is_err();
let tilt_curves_failed = db
.delete_tilt_curves(existing_id)
.inspect_err(|e| {
warn!(
"Save Native: failed to invalidate stale tilt-curve cache for entry \
#{existing_id}: {e}"
);
})
.is_err();
preview_failed || tilt_curves_failed
}
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())?;
let stale_cache_invalidation_failed = invalidate_stale_caches(db, existing_id);
return Ok((
existing_id,
CatalogueWriteBack::UpdatedExisting {
stale_cache_invalidation_failed,
},
));
}
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>>,
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::<EditorModel>().on_export_cutting_sheet(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
super::stall_guard::stall_guard("export_cutting_sheet", || {
let (design, base_name) = {
let st = state.borrow();
(st.design.clone(), suggested_file_name(&st))
};
let render_ctx = Arc::clone(&render_ctx);
resolve_solved_then(&ui, Arc::new(design), move |ui, design, solved_result| {
super::stall_guard::stall_guard("export_cutting_sheet_solved", || {
let solved = match solved_result {
Ok(solved) => solved,
Err(missing) => {
show_toast(
ui,
&format!("Cannot build a cutting sheet: {missing}."),
"error",
);
return;
}
};
let custom_materials = snapshot_custom_materials(&render_ctx);
let base = base_name.trim_end_matches(".asc").to_string();
pick_file(
ui,
PickKind::SaveCuttingSheet {
default_name: format!("{base}_cutting_sheet.html"),
},
move |ui, dest_path| {
let Some(dest_path) = dest_path else {
return;
};
let ui_weak = ui.as_weak();
std::thread::spawn(move || {
let result = super::cut_sheet::write_cutting_sheet_html(
&design,
&solved,
&dest_path,
&custom_materials,
);
let _ = ui_weak.upgrade_in_event_loop(move |ui| match result {
Ok(()) => show_toast(
&ui,
&format!("Wrote cutting sheet to {}", dest_path.display()),
"success",
),
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;
};
super::stall_guard::stall_guard("export_diagram", || {
let (design, base_name) = {
let st = state.borrow();
(st.design.clone(), suggested_file_name(&st))
};
resolve_solved_then(&ui, Arc::new(design), move |ui, design, solved_result| {
super::stall_guard::stall_guard("export_diagram_solved", || {
let solved = match solved_result {
Ok(solved) => solved,
Err(missing) => {
show_toast(
ui,
&format!("Cannot draw this design: {missing}."),
"error",
);
return;
}
};
let base = base_name.trim_end_matches(".asc").to_string();
pick_file(
ui,
PickKind::SaveDiagram {
default_name: format!("{base}_diagram.png"),
},
move |ui, dest_path| {
let Some(dest_path) = dest_path else {
return;
};
let ui_weak = ui.as_weak();
std::thread::spawn(move || {
let result = super::cut_sheet::write_diagram_png(
&design, &solved, &dest_path,
);
let _ = ui_weak.upgrade_in_event_loop(move |ui| match result {
Ok(()) => show_toast(
&ui,
&format!("Wrote diagram to {}", dest_path.display()),
"success",
),
Err(message) => show_toast(&ui, &message, "error"),
});
});
},
);
});
});
});
});
}
pub(super) fn setup_export_asc_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::<EditorModel>().on_export_asc(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
super::stall_guard::stall_guard("export_asc", || {
let custom_materials = snapshot_custom_materials(&render_ctx);
let (mut schedule, default_file_name, design, used_placeholder) = {
let st = state.borrow();
let mut schedule = match st.design.to_asc_schedule_with(&custom_materials) {
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",
);
}
resolve_solved_then(&ui, Arc::new(design), move |ui, design, solved_result| {
super::stall_guard::stall_guard("export_asc_status_resolved", || {
match decide_write_status(
&design,
solved_result.as_deref().map_err(String::as_str),
) {
StatusDecision::Fine => finish_export_asc(ui, &schedule, default_file_name),
StatusDecision::NeedsConfirm(message) => {
let heading_message = format!(
"{message}\n\nExport anyway? The written file will note this in \
its own header."
);
ask_write_confirm(
ui,
"This design is not a closed solid",
heading_message,
"Export Anyway",
Some(confirm_keys::NOT_CLOSED_SOLID),
move |ui| {
let mut schedule = schedule;
if let Some(header) =
degenerate_marker_header(&schedule.headers, &message)
{
schedule.headers.insert(0, header);
}
finish_export_asc(ui, &schedule, default_file_name);
},
);
}
}
});
});
});
});
}
fn finish_export_asc(
ui: &MainWindow,
schedule: &indicatrix_formats::asc::AscSchedule,
default_file_name: String,
) {
let text = indicatrix_formats::asc::to_asc_string(schedule);
pick_file(
ui,
PickKind::SaveAsc {
default_name: default_file_name,
},
move |ui, dest_path| {
let Some(dest_path) = dest_path else {
return;
};
let ui_weak = ui.as_weak();
std::thread::spawn(move || {
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let result = std::fs::write(&dest_path, &text)
.map_err(|e| format!("Failed to write {}: {e}", dest_path.display()));
let _ = ui_weak.upgrade_in_event_loop(move |ui| match result {
Ok(()) => {
record_last_saved_path(&ui, &dest_path);
show_toast(
&ui,
&format!("Exported edited schedule to {}", dest_path.display()),
"success",
);
}
Err(message) => show_toast(&ui, &message, "error"),
});
});
},
);
}
fn confirm_overwrite_unrelated_native_file_then(
ui: &MainWindow,
native_path: &Path,
on_confirmed: impl FnOnce(&MainWindow) + 'static,
) {
let is_own_file =
CURRENT_NATIVE_PATH.with(|cell| cell.borrow().as_deref() == Some(native_path));
if !native_path.is_file() || is_own_file {
on_confirmed(ui);
return;
}
ask_write_confirm(
ui,
"Overwrite existing file?",
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()
),
"Overwrite",
Some(confirm_keys::OVERWRITE_UNRELATED_NATIVE),
on_confirmed,
);
}
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))
}
#[derive(Clone)]
struct NativeSaveContext {
state: Rc<RefCell<EditorState>>,
dest_path: PathBuf,
native_path: PathBuf,
db: Arc<Mutex<Database>>,
source: Arc<Mutex<LibrarySource>>,
render_ctx: Arc<Mutex<RenderContext>>,
asc_filename: String,
original_asc_text: Option<String>,
printed_proportions: Option<ExternalProportions>,
used_placeholder: bool,
history_entries: Vec<String>,
}
thread_local! {
static PENDING_NATIVE_SAVE_STATE: RefCell<std::collections::HashMap<u64, Rc<RefCell<EditorState>>>> =
RefCell::new(std::collections::HashMap::new());
static NEXT_NATIVE_SAVE_KEY: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
fn finish_native_save(ui: &MainWindow, design: Design, ctx: NativeSaveContext) {
resolve_solved_then(ui, Arc::new(design), move |ui, design, solved_result| {
super::stall_guard::stall_guard("native_save_status_resolved", || {
let solved = solved_result.as_ref().ok().cloned();
match decide_write_status(&design, solved_result.as_deref().map_err(String::as_str)) {
StatusDecision::Fine => {
write_native_save(ui, &design, solved.as_deref(), None, &ctx);
}
StatusDecision::NeedsConfirm(message) => {
let heading_message = format!(
"{message}\n\nSave anyway? The written file will note this in its own \
header."
);
ask_write_confirm(
ui,
"This design is not a closed solid",
heading_message,
"Save Anyway",
Some(confirm_keys::NOT_CLOSED_SOLID),
move |ui| {
write_native_save(ui, &design, solved.as_deref(), Some(&message), &ctx);
},
);
}
}
});
});
}
fn write_native_save(
ui: &MainWindow,
design: &Design,
solved: Option<&[SolvedTier]>,
header_message: Option<&str>,
ctx: &NativeSaveContext,
) {
let mut design = design.clone();
if let Some(message) = header_message
&& let Some(header) = degenerate_marker_header(&design.meta.headers, message)
{
design.meta.headers.insert(0, header);
}
let placeholder_note = ctx
.used_placeholder
.then_some("angle-table reconstruction, no attached .asc");
let custom_material = custom_material_snapshot_for_save(&design, &ctx.db);
let custom_materials = snapshot_custom_materials(&ctx.render_ctx);
let paired = match save_paired_reusing_solve(
&design,
solved,
ctx.asc_filename.clone(),
ctx.original_asc_text.as_deref(),
placeholder_note,
ctx.printed_proportions.as_ref(),
&SaveExtras {
custom_material: custom_material.as_ref(),
history_entries: &ctx.history_entries,
custom_catalogue: &custom_materials,
},
) {
Ok(paired) => paired,
Err(e) => {
show_toast(ui, &format!("Cannot save: {e}"), "error");
return;
}
};
let dest_path = ctx.dest_path.clone();
let native_path = ctx.native_path.clone();
let asc_filename = ctx.asc_filename.clone();
let db = Arc::clone(&ctx.db);
let db_for_finish = Arc::clone(&ctx.db);
let source_for_finish = Arc::clone(&ctx.source);
let source_entry_id = ctx.state.borrow().source_entry_id;
let state_key = NEXT_NATIVE_SAVE_KEY.with(|c| {
let key = c.get();
c.set(key + 1);
key
});
PENDING_NATIVE_SAVE_STATE.with(|cell| {
cell.borrow_mut().insert(state_key, Rc::clone(&ctx.state));
});
let ui_weak = ui.as_weak();
std::thread::spawn(move || {
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let write_result = write_pair_atomically(
&dest_path,
&native_path,
&paired.asc_text,
&paired.native_toml,
);
let outcome = write_result.map(|()| {
let native_filename = native_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let catalogue = write_back_to_catalogue(
&db,
source_entry_id,
&asc_filename,
&paired.asc_text,
&native_filename,
&paired.native_toml,
);
WriteNativeOutcome {
dest_path: dest_path.clone(),
native_path: native_path.clone(),
asc_filename: asc_filename.clone(),
paired,
catalogue,
}
});
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
let Some(state) =
PENDING_NATIVE_SAVE_STATE.with(|cell| cell.borrow_mut().remove(&state_key))
else {
return;
};
match outcome {
Ok(outcome) => {
super::stall_guard::stall_guard("native_save_write_complete", || {
finish_save_native_success(
&ui,
&state,
outcome,
&db_for_finish,
&source_for_finish,
);
});
}
Err(message) => show_toast(&ui, &message, "error"),
}
});
});
}
fn quick_save_native(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
dest_path: &Path,
native_path: &Path,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
let (
mut design,
asc_filename,
original_asc_text,
printed_proportions,
source_entry_id,
used_placeholder,
history_entries,
) = {
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,
st.history.description_log().to_vec(),
)
};
stamp_source_entry_footnote(&mut design.meta.footnotes, source_entry_id);
finish_native_save(
ui,
design,
NativeSaveContext {
state: Rc::clone(state),
dest_path: dest_path.to_path_buf(),
native_path: native_path.to_path_buf(),
db: Arc::clone(db),
source: Arc::clone(source),
render_ctx: Arc::clone(render_ctx),
asc_filename,
original_asc_text,
printed_proportions,
used_placeholder,
history_entries,
},
);
}
fn save_native_via_dialog(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
let (
mut design,
default_asc_name,
original_asc_text,
printed_proportions,
source_entry_id,
used_placeholder,
history_entries,
) = {
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,
st.history.description_log().to_vec(),
)
};
stamp_source_entry_footnote(&mut design.meta.footnotes, source_entry_id);
let state_for_pick = Rc::clone(state);
let db_for_pick = Arc::clone(db);
let source_for_pick = Arc::clone(source);
let render_ctx_for_pick = Arc::clone(render_ctx);
pick_file(
ui,
PickKind::SaveAsc {
default_name: default_asc_name.clone(),
},
move |ui, dest_path| {
let Some(dest_path) = dest_path else {
return;
};
let asc_filename = dest_path.file_name().map_or_else(
|| default_asc_name.clone(),
|n| n.to_string_lossy().into_owned(),
);
let native_path = native_path_for_asc(&dest_path);
let native_path_for_confirm = native_path.clone();
confirm_overwrite_unrelated_native_file_then(ui, &native_path_for_confirm, move |ui| {
finish_native_save(
ui,
design,
NativeSaveContext {
state: state_for_pick,
dest_path,
native_path,
db: db_for_pick,
source: source_for_pick,
render_ctx: render_ctx_for_pick,
asc_filename,
original_asc_text,
printed_proportions,
used_placeholder,
history_entries,
},
);
});
},
);
}
pub(super) fn setup_save_native_callback(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
setup_dirty_tracking(ui, state);
setup_autosave_timer(ui, state, db, render_ctx);
setup_write_confirm_dialog_callbacks(ui);
let state_save = Rc::clone(state);
let db_save = Arc::clone(db);
let source_save = Arc::clone(source);
let render_ctx_save = Arc::clone(render_ctx);
let ui_weak = ui.as_weak();
ui.global::<EditorModel>().on_save_native(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
super::stall_guard::stall_guard("save_native", || {
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,
&render_ctx_save,
);
}
None => {
save_native_via_dialog(
&ui,
&state_save,
&db_save,
&source_save,
&render_ctx_save,
);
}
}
});
});
let state_save_as = Rc::clone(state);
let db_save_as = Arc::clone(db);
let source_save_as = Arc::clone(source);
let render_ctx_save_as = Arc::clone(render_ctx);
let ui_weak_as = ui.as_weak();
ui.global::<EditorModel>().on_save_native_as(move || {
let Some(ui) = ui_weak_as.upgrade() else {
return;
};
super::stall_guard::stall_guard("save_native_as", || {
save_native_via_dialog(
&ui,
&state_save_as,
&db_save_as,
&source_save_as,
&render_ctx_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 WriteNativeOutcome {
dest_path: PathBuf,
native_path: PathBuf,
asc_filename: String,
paired: indicatrix_cut_core::native::PairedSave,
catalogue: Result<(i64, CatalogueWriteBack), String>,
}
fn finish_save_native_success(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
outcome: WriteNativeOutcome,
db: &Arc<Mutex<Database>>,
source: &Arc<Mutex<LibrarySource>>,
) {
let WriteNativeOutcome {
dest_path,
native_path,
asc_filename,
paired,
catalogue,
} = outcome;
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.clone()));
ui.set_loaded_design_name(asc_filename.clone().into());
record_last_saved_path(ui, &dest_path);
{
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",
);
}
match catalogue {
Ok((id, catalogue_outcome)) => {
state.borrow_mut().source_entry_id = Some(id);
crate::gui::library::local::refresh_after_library_change(ui, db, source);
match catalogue_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",
);
}
CatalogueWriteBack::UpdatedExisting {
stale_cache_invalidation_failed: true,
} => {
show_toast(
ui,
"Saved, but this design's cached preview image and tilt curves in \
the library could not be cleared automatically -- they may still \
show the geometry from before this save. Re-export or recompute \
them if they look wrong.",
"warning",
);
}
CatalogueWriteBack::UpdatedExisting {
stale_cache_invalidation_failed: false,
}
| CatalogueWriteBack::NewRow => {}
}
}
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>>,
db: &Arc<Mutex<Database>>,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
let state = Rc::clone(state);
let db = Arc::clone(db);
let render_ctx = Arc::clone(render_ctx);
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;
};
super::stall_guard::stall_guard("autosave_tick", || {
run_autosave_tick(&ui, &state, &db, &render_ctx);
});
});
AUTOSAVE_TIMER.with(|cell| *cell.borrow_mut() = Some(timer));
}
fn run_autosave_tick(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
db: &Arc<Mutex<Database>>,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
if ui.global::<EditorModel>().get_solve_running() {
return;
}
let (design, asc_filename, printed_proportions, history_entries) = {
let st = state.borrow();
if !st.is_dirty() {
return;
}
(
st.design.clone(),
st.asc_filename.clone(),
st.printed_proportions,
st.history.description_log().to_vec(),
)
};
let custom_material = custom_material_snapshot_for_save(&design, db);
let custom_materials = snapshot_custom_materials(render_ctx);
let Ok(native_toml) = save_native_only_toml(
&design,
format!("{}.asc", autosave_base_name(asc_filename.as_deref())),
printed_proportions.as_ref(),
&SaveExtras {
custom_material: custom_material.as_ref(),
history_entries: &history_entries,
custom_catalogue: &custom_materials,
},
) else {
return;
};
let path = autosave_path(asc_filename.as_deref());
let ui_weak = ui.as_weak();
std::thread::spawn(move || {
let result = write_autosave(&path, &native_toml);
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
if let Err(e) = result {
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;
};
super::stall_guard::stall_guard("open_native", || {
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;
};
super::stall_guard::stall_guard("open_recent_native_file", || {
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 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 native_path_for_read = native_path.clone();
read_native_pair_then(ui, &native_path_for_read, move |ui, result| {
match result {
Some(NativePairOrSelfContained::Pair {
parsed_native,
asc_text,
native_text,
}) => open_native_pair(
ui,
&state,
&render_ctx,
&preview_state,
&solid_last_solved,
PickedPair {
native_path,
parsed_native,
asc_text,
native_text,
},
),
Some(NativePairOrSelfContained::SelfContained {
loaded,
asc_filename,
}) => open_native_self_contained(
ui,
&state,
&render_ctx,
&preview_state,
&solid_last_solved,
SelfContainedLoad {
native_path: &native_path,
loaded: *loaded,
asc_filename: &asc_filename,
},
),
None => {}
}
});
}
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 },
SelfContained {
native_path: PathBuf,
loaded: Box<LoadNativeOnlyResult>,
asc_filename: String,
},
}
struct PickedPair {
native_path: PathBuf,
parsed_native: Box<NativeDesignFile>,
asc_text: String,
native_text: String,
}
enum NativePairOrSelfContained {
Pair {
parsed_native: Box<NativeDesignFile>,
asc_text: String,
native_text: String,
},
SelfContained {
loaded: Box<LoadNativeOnlyResult>,
asc_filename: String,
},
}
fn read_native_pair_then(
ui: &MainWindow,
native_path: &Path,
on_done: impl FnOnce(&MainWindow, Option<NativePairOrSelfContained>) + 'static,
) {
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",
);
on_done(ui, None);
return;
}
};
let parsed_native = match indicatrix_cut_core::native::parse_toml_string(&native_text) {
Ok(n) => Box::new(n),
Err(e) => {
show_toast(
ui,
&format!(
"'{}' is not a valid native design file: {e}",
native_path.display()
),
"error",
);
on_done(ui, None);
return;
}
};
let asc_path = native_path.with_file_name(&parsed_native.asc_filename);
let asc_filename = parsed_native.asc_filename.clone();
let asc_findable = asc_path.is_file()
|| indicatrix_cut_core::asc_path_for_native(native_path)
.is_some_and(|guessed| guessed != asc_path && guessed.is_file());
if !asc_findable && let Ok(loaded) = load_native_only(&native_text) {
on_done(
ui,
Some(NativePairOrSelfContained::SelfContained {
loaded: Box::new(loaded),
asc_filename,
}),
);
return;
}
resolve_paired_asc_text_then(
ui,
native_path,
&asc_path,
&asc_filename,
move |ui, asc_text| {
on_done(
ui,
asc_text.map(|asc_text| NativePairOrSelfContained::Pair {
parsed_native,
asc_text,
native_text,
}),
);
},
);
}
fn resolve_paired_asc_text_then(
ui: &MainWindow,
native_path: &Path,
recorded_asc_path: &Path,
recorded_asc_filename: &str,
on_done: impl FnOnce(&MainWindow, Option<String>) + 'static,
) {
if let Ok(text) = std::fs::read_to_string(recorded_asc_path) {
on_done(ui, Some(text));
return;
}
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)
{
on_done(ui, Some(text));
return;
}
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",
);
pick_file(ui, PickKind::LocateAsc, move |ui, located| {
let Some(located) = located else {
on_done(ui, None);
return;
};
match std::fs::read_to_string(&located) {
Ok(text) => on_done(ui, Some(text)),
Err(e) => {
show_toast(
ui,
&format!("Failed to read {}: {e}", located.display()),
"error",
);
on_done(ui, None);
}
}
});
}
fn picked_native_from_pair_result(
native_path: PathBuf,
outcome: NativePairOrSelfContained,
) -> PickedNative {
match outcome {
NativePairOrSelfContained::Pair {
parsed_native,
asc_text,
native_text,
} => PickedNative::Pair(Box::new(PickedPair {
native_path,
parsed_native,
asc_text,
native_text,
})),
NativePairOrSelfContained::SelfContained {
loaded,
asc_filename,
} => PickedNative::SelfContained {
native_path,
loaded,
asc_filename,
},
}
}
fn read_picked_asc_then(
ui: &MainWindow,
asc_path: PathBuf,
on_done: impl FnOnce(&MainWindow, Option<PickedNative>) + 'static,
) {
let native_path = native_path_for_asc(&asc_path);
if native_path.is_file() {
let native_path_for_read = native_path.clone();
read_native_pair_then(ui, &native_path_for_read, move |ui, result| {
on_done(
ui,
result.map(|outcome| picked_native_from_pair_result(native_path, outcome)),
);
});
return;
}
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",
);
on_done(ui, None);
return;
}
};
on_done(ui, Some(PickedNative::AscOnly { asc_path, asc_text }));
}
fn pick_native_or_asc_then(
ui: &MainWindow,
on_done: impl FnOnce(&MainWindow, Option<PickedNative>) + 'static,
) {
pick_file(ui, PickKind::OpenNativeOrAsc, move |ui, picked_path| {
let Some(picked_path) = picked_path else {
on_done(ui, None);
return;
};
if picked_path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("asc"))
{
read_picked_asc_then(ui, picked_path, on_done);
return;
}
let picked_path_for_read = picked_path.clone();
read_native_pair_then(ui, &picked_path_for_read, move |ui, result| {
on_done(
ui,
result.map(|outcome| picked_native_from_pair_result(picked_path, outcome)),
);
});
});
}
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,
) {
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);
pick_native_or_asc_then(ui, move |ui, picked| match picked {
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,
),
Some(PickedNative::SelfContained {
native_path,
loaded,
asc_filename,
}) => open_native_self_contained(
ui,
&state,
&render_ctx,
&preview_state,
&solid_last_solved,
SelfContainedLoad {
native_path: &native_path,
loaded: *loaded,
asc_filename: &asc_filename,
},
),
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"),
}
}
struct SelfContainedLoad<'a> {
native_path: &'a Path,
loaded: LoadNativeOnlyResult,
asc_filename: &'a str,
}
fn open_native_self_contained(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &super::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);
CURRENT_NATIVE_PATH.with(|cell| *cell.borrow_mut() = Some(native_path.to_path_buf()));
let (material_note, material_still_unresolved) = if let (Some(snapshot), Some(name)) = (
loaded.restorable_custom_material.as_ref(),
loaded.design.material.name.as_deref(),
) {
let gem = 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);
(
Some(format!(
" '{name}' was restored from this file's own saved material data."
)),
false,
)
} else {
let unresolved = matches!(
loaded.material_resolution,
indicatrix_cut_core::native::MaterialResolution::Unresolved
);
(
unresolved.then(|| format!(" {}", loaded.material_resolution)),
unresolved,
)
};
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,
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.to_string()),
original_asc_text: None,
pending_gear_remap: None,
pending_retarget: None,
multi_selected: std::collections::BTreeSet::new(),
last_pushed_scratch: RefCell::new(PushedScratch::default()),
material_combo_cache: RefCell::new(MaterialComboCache::default()),
source_entry_id: None,
used_placeholder: false,
});
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 native file's own saved data.{}{}",
material_note.unwrap_or_default(),
newer_version_note.unwrap_or_default()
),
if is_mismatch { "warning" } else { "success" },
);
}
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)),
deep_solve_result_generation: 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()),
material_combo_cache: RefCell::new(MaterialComboCache::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, true);
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 outcome_note = plain_load_outcome_text(&loaded.fingerprint, &loaded.tier_overlay);
let (material_note, material_still_unresolved) = if let (Some(snapshot), Some(name)) = (
loaded.restorable_custom_material.as_ref(),
loaded.design.material.name.as_deref(),
) {
let gem = 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);
(
Some(format!(
" '{name}' was restored from this file's own saved material data."
)),
false,
)
} else {
let unresolved = matches!(
loaded.material_resolution,
indicatrix_cut_core::native::MaterialResolution::Unresolved
);
(
unresolved.then(|| format!(" {}", loaded.material_resolution)),
unresolved,
)
};
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,
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()),
material_combo_cache: RefCell::new(MaterialComboCache::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}'. {outcome_note}{}{}",
material_note.unwrap_or_default(),
newer_version_note.unwrap_or_default()
),
if is_mismatch { "warning" } else { "success" },
);
}
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(),
}
}
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, plain_load_outcome_text};
use indicatrix::geometry::meet_solver::MeetConstraint;
use indicatrix_cut_core::{
ConstraintTier, Design, FingerprintCheck, FreshDesignSpec, MaterialSelection, PreformSpec,
TierOverlay, 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());
}
#[test]
fn a_clean_match_and_applied_overlay_reads_as_restored() {
let text = plain_load_outcome_text(&FingerprintCheck::Match, &TierOverlay::Applied);
assert_eq!(text, "Your saved meet constraints were restored.");
}
#[test]
fn a_tier_count_mismatch_names_both_counts_even_on_a_clean_fingerprint() {
let text = plain_load_outcome_text(
&FingerprintCheck::Match,
&TierOverlay::SkippedTierCountMismatch {
native_tiers: 5,
asc_tiers: 6,
},
);
assert!(text.contains('5') && text.contains('6'), "{text}");
assert!(
!text.contains("fingerprint"),
"must read in plain English, not the crate's own diagnostic vocabulary: {text}"
);
}
#[test]
fn a_fingerprint_mismatch_says_the_asc_changed_and_constraints_were_not_restored() {
let text = plain_load_outcome_text(
&FingerprintCheck::Mismatch {
expected_sha256: "aaaa".to_string(),
found_sha256: "bbbb".to_string(),
},
&TierOverlay::SkippedFingerprintMismatch,
);
assert!(
text.contains("changed since this sidecar was saved"),
"{text}"
);
assert!(text.contains("not restored"), "{text}");
assert!(
!text.contains("sha256"),
"must not leak the technical hash text: {text}"
);
}
#[test]
fn applying_despite_a_mismatch_says_it_was_at_the_cutters_own_request() {
let text = plain_load_outcome_text(
&FingerprintCheck::Mismatch {
expected_sha256: "aaaa".to_string(),
found_sha256: "bbbb".to_string(),
},
&TierOverlay::AppliedDespiteMismatch,
);
assert!(text.contains("at your request"), "{text}");
}
#[test]
fn a_draft_overlay_on_a_clean_match_names_the_placeholder_masts() {
let text =
plain_load_outcome_text(&FingerprintCheck::Match, &TierOverlay::AppliedFromDraft);
assert!(text.contains("placeholders"), "{text}");
}
use super::{
StatusDecision, decide_write_status, solve_matches_design, temp_sibling, write_autosave,
};
fn round_brilliant_design() -> Design {
Design::new(
PreformSpec::block(2.0, 1.0, 2.0),
indicatrix_cut_core::ScheduleMeta::standard_round_brilliant(),
ConstraintTier::standard_round_brilliant(),
)
}
#[test]
fn solve_matches_design_accepts_a_cache_whose_tier_count_still_matches() {
let design = round_brilliant_design();
let solved = design.solve().expect("standard round brilliant solves");
assert_eq!(solved.len(), design.tiers.len());
assert!(solve_matches_design(Some(&solved), &design).is_some());
}
#[test]
fn solve_matches_design_rejects_a_cache_whose_tier_count_no_longer_matches() {
let design = round_brilliant_design();
let solved = design.solve().expect("standard round brilliant solves");
assert!(solve_matches_design(Some(&solved[..solved.len() - 1]), &design).is_none());
}
#[test]
fn solve_matches_design_rejects_no_cache_at_all() {
let design = round_brilliant_design();
assert!(solve_matches_design(None, &design).is_none());
}
#[test]
fn decide_write_status_is_fine_for_a_closed_solid() {
let design = round_brilliant_design();
let solved = design.solve().expect("standard round brilliant solves");
assert!(matches!(
decide_write_status(&design, Ok(solved.as_slice())),
StatusDecision::Fine
));
}
#[test]
fn decide_write_status_needs_confirm_when_the_design_does_not_solve() {
let design = round_brilliant_design();
match decide_write_status(&design, Err("no scale-reference tier")) {
StatusDecision::NeedsConfirm(message) => {
assert_eq!(message, "no scale-reference tier");
}
StatusDecision::Fine => panic!("a solve error must always need confirmation"),
}
}
#[test]
fn write_autosave_round_trips_its_own_toml_text() {
let path = std::env::temp_dir().join(format!(
"indicatrix_cut_write_autosave_test_{}.toml",
std::process::id()
));
let _ = std::fs::remove_file(&path);
write_autosave(&path, "design = \"round trip\"\n").expect("write must succeed");
let read_back = std::fs::read_to_string(&path).expect("must read back what was written");
assert_eq!(read_back, "design = \"round trip\"\n");
assert!(!temp_sibling(&path).is_file());
let _ = std::fs::remove_file(&path);
}
}