//! Files the user opens and saves: the host's native file pickers ([`Services`]), opening off the //! window thread, drag and drop, the recent-files list, and the prompts around them (unsaved //! changes, autosaved models to recover). //! //! Only user actions reach a picker: a click (toolbar, title bar, File menu), a shortcut or a drop. //! Programmatic calls (`engine.execute`, the control channel's `ui.start`) run commands with the //! paths they are given and never open a native dialog. Without a picker service (tests, the //! offscreen capture, the web), the same entries open their command's parameter form instead. //! //! A file opens on a copy of the session in a worker thread; the window keeps drawing and shows //! the file's progress with Cancel. The finished copy replaces the session only when the command //! succeeded, so a failed or cancelled open leaves the model as it was. Structure follows //! SolidCraft's `crates/ui-egui/src/file_load.rs` and `documents.rs` (MIT OR Apache-2.0), see //! ATTRIBUTION.md. use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, TryRecvError}; use fieldcraft_model::progress::Progress; use fieldcraft_engine::Session; use serde_json::{Value, json}; use crate::ModelApp; /// A file type a picker offers: a name and its extensions (without the dot). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Filter { pub name: &'static str, pub extensions: &'static [&'static str], } /// The host picks a file to open from the filters (the first is selected), or `None` when the /// user cancelled. pub type PickOpen = Box Option>; /// The host picks where to save, starting from a file name; `None` when the user cancelled. pub type PickSave = Box Option>; /// The host opens a web link in the user's browser. pub type OpenUrl = Box Result<(), String>>; /// What the host provides: native file pickers and where the recent-files list is kept. The /// desktop app installs them; tests answer the pickers with closures. #[derive(Default)] pub struct Services { pub pick_open: Option, pub pick_save: Option, /// The recent-files list's file (JSON); `None` keeps the list for this run only. pub recent_path: Option, /// Where the user's preferences ([`Prefs`]) are kept: a file on the desktop, the browser's /// storage on the web; `None` keeps them for this run only. pub prefs: Option, /// Opens a link in the browser (Help › Report a Bug); without it, egui's own /// `open_url` (the browser build opens the link itself). pub open_url: Option, } const MODEL: Filter = Filter { name: "FieldCraft model", extensions: &["fieldcraft"] }; const DESIGN: Filter = Filter { name: "SolidCraft design", extensions: &["solidcraft"] }; const MESH: Filter = Filter { name: "Face-tagged mesh (3MF)", extensions: &["3mf", "3MF"] }; const ANY: Filter = Filter { name: "Models, designs and tagged meshes", extensions: &["fieldcraft", "solidcraft", "3mf", "3MF"] }; /// The Open picker's filters: everything FieldCraft opens, then each type. pub const OPEN_FILTERS: &[Filter] = &[ANY, MODEL, DESIGN, MESH]; /// What opening a file does with it, from its extension. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { /// A `.fieldcraft` model replaces the session (`file.open`). Model, /// A `.solidcraft` design becomes the geometry (`geometry.open`). Design, /// A `.3mf` face-tagged mesh is added as a surface (`geometry.import.mesh`). Mesh, } impl Kind { /// The kind of a file by its extension (any case); `None` for a file FieldCraft does not open. pub fn of(path: &str) -> Option { let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); match ext.as_str() { "fieldcraft" => Some(Kind::Model), "solidcraft" => Some(Kind::Design), "3mf" => Some(Kind::Mesh), _ => None, } } pub fn command(self) -> &'static str { match self { Kind::Model => "file.open", Kind::Design => "geometry.open", Kind::Mesh => "geometry.import.mesh", } } /// The picker's filters when the user asked for this kind (its own type first). fn filters(self) -> &'static [Filter] { match self { Kind::Model => &[MODEL, ANY], Kind::Design => &[DESIGN, ANY], Kind::Mesh => &[MESH, ANY], } } /// What the window says while it opens and once it has. fn verbs(self) -> (&'static str, &'static str) { match self { Kind::Model | Kind::Design => ("Opening", "Opened"), Kind::Mesh => ("Importing", "Imported"), } } } /// The file a command opens, if it is one the File menu handles with a picker. fn kind_of_command(command: &str) -> Option { [Kind::Model, Kind::Design, Kind::Mesh].into_iter().find(|k| k.command() == command) } /// Commands a form runs in the background: they can take a while on a large part. /// Whether `command` may run long: a solve or a mesher, run as a job off the window thread. pub fn is_long(command: &str) -> bool { BACKGROUND.contains(&command) || matches!( command, "solve.run" | "solve.static" | "solve.nonlinear" | "solve.heat" | "solve.frequency" | "solve.buckle" | "solve.explicit" | "solve.fluid" ) } pub const BACKGROUND: &[&str] = &["mesh.tet.generate", "mesh.hex.generate", "mesh.hex.sweep", "mesh.hex.mapped", "mesh.hex.blocks", "mesh.hex.octree"]; /// Most entries the recent-files list keeps, and the longest path it keeps. pub const MAX_RECENT: usize = 10; const MAX_RECENT_PATH: usize = 4096; /// Largest recent-files file read. const MAX_RECENT_BYTES: u64 = 256 * 1024; /// Recently opened and saved files, newest first. #[derive(Clone, Debug, Default, PartialEq)] pub struct Recent { pub paths: Vec, file: Option, } impl Recent { /// The list kept in `file` (an unreadable or malformed file gives an empty list). pub fn load(file: Option) -> Recent { let paths = file .as_ref() .filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_RECENT_BYTES)) .and_then(|f| std::fs::read(f).ok()) .and_then(|b| serde_json::from_slice::>(&b).ok()) .unwrap_or_default() .into_iter() .filter(|p| !p.is_empty() && p.len() <= MAX_RECENT_PATH && Kind::of(p).is_some()) .take(MAX_RECENT) .collect(); Recent { paths, file } } /// Put `path` first and save the list. pub fn add(&mut self, path: &str) { if path.is_empty() || path.len() > MAX_RECENT_PATH { return; } self.paths.retain(|p| p != path); self.paths.insert(0, path.to_string()); self.paths.truncate(MAX_RECENT); self.save(); } fn save(&self) { let Some(file) = &self.file else { return }; let write = || -> std::io::Result<()> { if let Some(dir) = file.parent() { std::fs::create_dir_all(dir)?; } std::fs::write(file, serde_json::to_vec_pretty(&self.paths).unwrap_or_default()) }; if let Err(e) = write() { log::warn!("recent files {}: {e}", file.display()); } } } /// Largest preferences file read. const MAX_PREFS_BYTES: u64 = 64 * 1024; /// Where the preferences are kept: reads their JSON (`None` when there is none) and writes it. pub struct PrefsStore { pub load: Box Option>>, pub save: Box Result<(), String>>, } impl PrefsStore { /// The preferences in `file` (an unreadable or oversized file reads as none). pub fn file(file: PathBuf) -> PrefsStore { let read = file.clone(); PrefsStore { load: Box::new(move || { std::fs::metadata(&read).is_ok_and(|m| m.is_file() && m.len() <= MAX_PREFS_BYTES).then(|| std::fs::read(&read).ok()).flatten() }), save: Box::new(move |bytes| { if let Some(dir) = file.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; } std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display())) }), } } } /// What the window remembers for the user between runs (`prefs.json` beside `recent.json`). /// Keys it no longer knows (`show_setup`, from before the setup card went) are ignored. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(default)] pub struct Prefs { /// The mouse navigation preset ([`crate::navigation::NAV_PRESETS`] id). pub nav_preset: String, /// The wheel zooms the other way. pub reverse_zoom: bool, /// Share anonymous usage (docs/privacy.md); `None` until the user is asked. pub usage_sharing: Option, /// Count commands run by scripts and AI agents (MCP) too. pub usage_automation: bool, /// The random id usage is sent under, made when the user first agrees; empty before. pub install_id: String, /// Check for updates at start-up, at most once a day ([`crate::updates`]). pub update_check: bool, /// When the last automatic check was made (seconds since the Unix epoch; 0: never). pub update_checked_at: u64, /// The version the user skipped (the automatic check does not offer it); empty for none. pub update_skip: String, } impl Default for Prefs { fn default() -> Self { Prefs { nav_preset: crate::navigation::DEFAULT_PRESET.into(), reverse_zoom: false, usage_sharing: None, usage_automation: true, install_id: String::new(), update_check: true, update_checked_at: 0, update_skip: String::new(), } } } impl Prefs { /// The preferences kept in `store` (unreadable or malformed ones give the defaults). pub fn load(store: Option<&PrefsStore>) -> Prefs { store.and_then(|s| (s.load)()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() } /// Write them to `store`; a failure is logged (the preferences stay for this run). pub fn save(&self, store: Option<&PrefsStore>) { let Some(store) = store else { return }; if let Err(e) = (store.save)(&serde_json::to_vec_pretty(self).unwrap_or_default()) { log::warn!("preferences: {e}"); } } } /// A command running in the background on a copy of the session: a file opening, a solve, a /// mesher started from its form. pub struct Loading { /// What the window shows while it runs ("Opening beam.fieldcraft"). pub title: String, /// The status when it succeeded ("Opened beam.fieldcraft"). done: String, /// The file it opens, for the recent list. recent: Option, /// The command form that started it: closed when it succeeds, showing the error when not. form: Option<&'static str>, /// The finished session copy and the command's reply. rx: Receiver<(Box, Result)>, /// The work, while it waits to run on the window thread (the web, which has no threads). pending: Option, /// Whether the window has drawn the job's progress: on the web the work runs (and the page /// stops drawing) only once the user has seen that it runs. shown: bool, /// The command it runs. pub command: &'static str, /// Where the command is, reported from the worker ([`fieldcraft_model::progress`]). pub progress: Arc, /// When it started (app time, seconds). pub started: f64, /// Control-channel requests waiting for its reply (`engine.execute` of a long command, /// `job.wait`): each gets the command's result, or why it has none. pub waiters: Vec>, } impl Loading { /// The job for `ui.inspect` and `job.status`: what runs, where it is and for how long. pub fn to_json(&self, now: f64) -> Value { let p = self.progress.snapshot(); json!({ "title": self.title, "command": self.command, "elapsed": (now - self.started).max(0.0), "progress": p, "fraction": p.fraction(), "line": p.line(), }) } /// The overlay's line: `Solving · Step 1/2 Load · Load factor 0.3 of 1 · 12 s`. pub fn line(&self, now: f64) -> String { let p = self.progress.snapshot().line(); let elapsed = elapsed((now - self.started).max(0.0)); if p.is_empty() { format!("{}… · {elapsed}", self.title) } else { format!("{}… · {p} · {elapsed}", self.title) } } } /// What the status bar says when job `command` finished: its `done` text with the time it took, /// and for a mesher the elements made and their worst quality. fn finished(command: &str, done: &str, took: &str, reply: &Value) -> String { let r = &reply["result"]; match (r["elements"].as_u64(), r["kind"].as_str()) { (Some(n), Some(kind)) if BACKGROUND.contains(&command) => { let bodies = r["bodies"].as_array().into_iter().flatten(); let dihedral = bodies.filter_map(|b| b["quality"]["mesh"]["min_dihedral"].as_f64()).reduce(f64::min); let q = dihedral.map(|d| format!(", smallest dihedral angle {d:.1}°")).unwrap_or_default(); let n = fieldcraft_engine::thousands(usize::try_from(n).unwrap_or(usize::MAX)); format!("{done}: {n} {kind}{q} in {took} — Mesh › Quality checks them") } _ if is_long(command) => format!("{done} in {took}"), _ => done.to_string(), } } /// A duration as `0.4 s`, `12 s` or `3 min 05 s`. pub fn elapsed(seconds: f64) -> String { match seconds { s if s < 10.0 => format!("{s:.1} s"), s if s < 60.0 => format!("{s:.0} s"), s => format!("{} min {:02} s", (s / 60.0) as u64, (s % 60.0) as u64), } } /// A finished background job whose session has not replaced the window's yet. pub struct Opened { session: Box, reply: Value, done: String, recent: Option, form: Option<&'static str>, } /// What waits for an answer about unsaved changes. #[derive(Clone, Debug, PartialEq)] pub enum Replace { /// A `.fieldcraft` file. File(String), /// A sample (`file.sample`). Sample(&'static str), /// Restarting to finish installing an update ([`crate::updates`]). Update, } impl Replace { fn name(&self) -> String { match self { Replace::File(p) => file_name(p), Replace::Sample(n) => fieldcraft_engine::sample::find(n).map_or_else(|| (*n).to_string(), |s| format!("the {} sample", s.label)), Replace::Update => "the update".into(), } } } /// A question the window asks before going on. #[derive(Clone, Debug, PartialEq)] pub enum Prompt { /// Opening a model or a sample discards the current one's unsaved changes. Unsaved(Replace), /// Autosaved models to recover (`file.recovery.list`). Recover, /// The samples to open (`file.sample`). Samples, /// A mesher's form would replace the mesh of bodies that have one: Replace runs it, Cancel /// leaves the form open. ReplaceMesh(fieldcraft_engine::Replacement), /// A design (its path) has none of the bodies the model's mesh and sets are on: start a new /// model with it, or keep the model. The opened session waits in [`ModelApp::held`]. Design(String), /// Delete a definition (the item menu's Delete…, the Delete key): what uses it is listed, and /// the deletion refused or (a datum's partitions) done with it. Delete(crate::menu::DeleteAsk), /// The first run asks once whether to share anonymous usage ([`crate::privacy`]). Consent, /// Help › Settings: the privacy and update choices ([`crate::privacy`]). Settings, /// An update is downloaded and verified: restart to finish it, or later ([`crate::updates`]). Update, /// The last run crashed (its new crash reports, newest first): report it, or not now. Crashed(Vec), /// Help › Report a Bug ([`crate::report`]; the form is [`ModelApp::report`]). ReportBug, } /// The answer to [`Prompt::Design`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DesignAnswer { /// Open the design into a new model (`geometry.open` with `"model": "new"`). New, /// Open the design and keep the model's mesh and definitions. Keep, /// Do not open the design. Cancel, } /// The answer to [`Prompt::Unsaved`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Answer { Save, Discard, Cancel, } /// The file name a model is saved under by default. /// The document's name: its file's, else the sample it was opened as ("Cantilever (sample)"). fn model_name(session: &Session) -> String { match session.path.as_deref().and_then(|p| Path::new(p).file_stem()).and_then(|s| s.to_str()) { Some(stem) => stem.to_string(), None => session.sample.map_or_else(|| "Untitled".to_string(), |label| format!("{label} (sample)")), } } /// `path` with `ext` added when it has no extension of that name (some pickers leave it off). fn with_extension(path: String, ext: &str) -> String { let has = Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case(ext)); if has { path } else { format!("{path}.{ext}") } } /// Run `work` off the window thread (inline on the web, which has no threads). fn off_thread(name: String, work: impl FnOnce() + Send + 'static) { if let Some(work) = start(name, Box::new(work)) { work(); } } /// A background job's work. type Work = Box; /// Start `work` on a worker thread. The web has no threads: the work comes back, to run on the /// window thread once the window has shown that it runs ([`Loading::shown`]). fn start(name: String, work: Work) -> Option { #[cfg(not(target_arch = "wasm32"))] { if let Err(e) = std::thread::Builder::new().name(name.clone()).spawn(work) { // The work went with the closure: a job then reports that it could not start. log::warn!("{name}: no worker thread: {e}"); } None } #[cfg(target_arch = "wasm32")] { let _ = name; Some(work) } } /// Run `command` on `session` off the window thread: the reply's receiver, and the work when it /// waits to run on the window thread (the web). fn spawn( session: Session, command: &'static str, params: Value, progress: Arc, ) -> (Receiver<(Box, Result)>, Option) { let (tx, rx) = std::sync::mpsc::channel(); let work = start( format!("fieldcraft {command}"), Box::new(move || { let mut s = session; let r = fieldcraft_model::progress::scoped(Some(progress), || s.execute(command, ¶ms)).map_err(|e| e.to_string()); let _ = tx.send((Box::new(s), r)); }), ); (rx, work) } impl ModelApp { /// The model's name: its file's stem, the sample it was opened as, or "Untitled". pub fn model_name(&self) -> String { model_name(&self.session) } /// What is running in the background ("Opening beam.fieldcraft", "Solving"), if anything. pub fn busy(&self) -> Option<&str> { self.loading.as_ref().map(|l| l.title.as_str()) } /// `Err` (and the status says so) while a background job runs: commands wait for it. pub fn ensure_idle(&mut self) -> Result<(), String> { let Some(busy) = self.busy() else { return Ok(()) }; let e = format!("{busy}: wait for it or cancel it"); self.set_status(e.clone(), true); Err(e) } /// Run `command` in the background; the session it ran on replaces this one when it /// succeeds. A command that brings a whole model (`fresh`: opening a model, recovering one) /// starts from an empty session, the others from a copy of this one. One at a time. fn begin_job(&mut self, command: &'static str, params: Value, fresh: bool, title: String, done: String, recent: Option) -> bool { if self.ensure_idle().is_err() { return false; } // A fresh session is the app's own model too (its commands go into the crash journal). let base = if fresh { let mut s = Session::default(); s.crash_journal = self.session.crash_journal; s.origin = self.session.origin; s } else { self.session.clone() }; let progress = Progress::new(); let (rx, pending) = spawn(base, command, params, progress.clone()); self.set_status(format!("{title}…"), false); let started = self.now; self.loading = Some(Loading { title, done, recent, form: None, rx, pending, shown: false, command, progress, started, waiters: Vec::new() }); true } /// The form's OK: run its command, in the background for the meshers ([`BACKGROUND`]) so the /// window keeps drawing (with Cancel) while a large part meshes; the form closes when the /// command succeeds and shows the error when it fails. /// A mesher that would replace a body's mesh asks first ([`Prompt::ReplaceMesh`]). pub fn submit_dialog(&mut self) { let Ok((command, params)) = self.dialog_params() else { return }; if let Some(r) = fieldcraft_engine::replacement(&self.session, command, ¶ms) { self.prompt = Some(Prompt::ReplaceMesh(r)); return; } self.submit_now(); } /// Answer [`Prompt::ReplaceMesh`]: run the form (replacing the mesh), or leave it open. pub fn answer_replace_mesh(&mut self, replace: bool) { if !matches!(self.prompt, Some(Prompt::ReplaceMesh(_))) { return; } self.prompt = None; if replace { self.submit_now(); } } /// The form's OK once confirmed. fn submit_now(&mut self) { let Some(command) = self.dialog.as_ref().map(|d| d.command) else { return }; if !BACKGROUND.contains(&command) { let _ = self.run_dialog(); return; } let Ok((command, params)) = self.dialog_params() else { return }; let label = crate::command_label(command).to_string(); if self.begin_job(command, params, false, label.clone(), format!("{label} done"), None) && let Some(l) = self.loading.as_mut() { l.form = Some(command); } } /// File > Open: pick a model, design or tagged mesh and open it. `None` asks for any of them. /// Without a picker service the command's form opens instead. pub fn open_dialog(&mut self, kind: Option) { let Some(pick) = self.services.pick_open.as_ref() else { self.open_form(kind.unwrap_or(Kind::Model).command(), json!({})); return; }; if let Some(path) = pick(kind.map_or(OPEN_FILTERS, Kind::filters)) { self.open_path(&path); } } /// Whether replacing the model would lose work: unsaved changes made by commands. pub(crate) fn has_unsaved_work(&self) -> bool { self.session.is_dirty() && !self.session.journal.is_empty() } /// Open a file the user chose (picked, dropped or from the recent list) by its type. A model /// replacing unsaved changes asks first. pub fn open_path(&mut self, path: &str) { let Some(kind) = Kind::of(path) else { self.set_status(format!("{path}: FieldCraft opens .fieldcraft models, .solidcraft designs and .3mf tagged meshes"), true); return; }; if kind == Kind::Model && self.has_unsaved_work() { self.prompt = Some(Prompt::Unsaved(Replace::File(path.to_string()))); return; } self.load(kind, path.to_string()); } /// Open `path` in the background; files dropped while another opens wait their turn. fn load(&mut self, kind: Kind, path: String) { if self.loading.is_some() { self.queued.push_back(path); return; } let name = file_name(&path); let (doing, done) = kind.verbs(); self.begin_job(kind.command(), json!({"path": path}), kind == Kind::Model, format!("{doing} {name}"), format!("{done} {name}"), Some(path)); } /// Help › Samples (and the empty viewport): open a sample model, asking first when that /// would discard unsaved changes. pub fn open_sample(&mut self, name: &str) { let Some(sample) = fieldcraft_engine::sample::find(name) else { self.set_status(format!("no sample `{name}`"), true); return; }; if self.has_unsaved_work() { self.prompt = Some(Prompt::Unsaved(Replace::Sample(sample.name))); return; } self.load_sample(sample.name); } fn load_sample(&mut self, name: &str) { let Some(sample) = fieldcraft_engine::sample::find(name) else { return }; if self.run("file.sample", json!({"name": sample.name})).is_ok() { self.model_changed(); let next = if self.session.model.steps().is_empty() { "" } else { " — Solve shows its results" }; self.set_status(format!("Opened the {} sample{next}", sample.label), false); } } /// Solve every step (`solve.run`) in the background; the results come up when it is done. pub fn solve_all(&mut self) { if let Some(u) = self.unavailable("solve.run") { self.refuse(u); return; } let n = self.session.model.steps().len(); let done = format!("Solved {n} step{}", if n == 1 { "" } else { "s" }); self.begin_job("solve.run", json!({}), false, "Solving".into(), done, None); } /// Run `command` as a background job for the control channel: the job's number (its /// opening), or why it cannot start (another job runs, the command cannot run yet). pub fn start_job(&mut self, command: &str, params: Value) -> Result { let spec = fieldcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?; if let Err(u) = self.session.availability(spec) { return Err(u.to_string()); } let label = crate::command_label(spec.id).to_string(); let title = if spec.id.starts_with("solve.") { "Solving".to_string() } else { label.clone() }; let steps = self.session.model.steps().len(); let done = match spec.id { "solve.run" => format!("Solved {steps} step{}", if steps == 1 { "" } else { "s" }), _ => format!("{label} done"), }; if !self.begin_job(spec.id, params, false, title, done, None) { return Err(self.busy().map_or_else(|| "busy".to_string(), |b| format!("{b}: wait for it or cancel it"))); } self.jobs = self.jobs.wrapping_add(1); Ok(self.jobs) } /// Recover an autosaved model (`file.recover`) in the background. pub fn recover(&mut self, id: &str) { self.prompt = None; self.begin_job( "file.recover", json!({"id": id}), true, "Recovering the autosaved model".into(), "Recovered the autosaved model".into(), None, ); } /// Stop waiting for the background job; the model stays as it was. pub fn cancel_loading(&mut self) { if let Some(l) = self.loading.take() { self.queued.clear(); for w in &l.waiters { let _ = w.send(json!({"ok": false, "error": format!("cancelled: {}", l.title), "cancelled": true})); } let line = if l.title == "Solving" { "Solve cancelled".to_string() } else { format!("{} cancelled", l.title) }; self.set_status(line, false); } } /// Take in a finished background job (called every frame); `true` while one is running. pub fn poll_loading(&mut self) -> bool { let Some(l) = &mut self.loading else { return false }; if l.pending.is_some() && !l.shown { return true; } if let Some(work) = l.pending.take() { work(); } let outcome = match l.rx.try_recv() { Ok(done) => Some(done), Err(TryRecvError::Empty) => return true, Err(TryRecvError::Disconnected) => None, }; let Some(l) = self.loading.take() else { return false }; let reply = match &outcome { Some((_, Ok(v))) => json!({"ok": true, "result": v}), Some((_, Err(e))) => json!({"ok": false, "error": e}), None => json!({"ok": false, "error": format!("{}: stopped unexpectedly", l.title)}), }; for w in &l.waiters { let _ = w.send(reply.clone()); } let took = elapsed((self.now - l.started).max(0.0)); let l = Loading { done: finished(l.command, &l.done, &took, &reply), ..l }; match outcome { Some((session, Ok(reply))) => { let opened = Opened { session, reply, done: l.done, recent: l.recent, form: l.form }; // A design of another part over a meshed or defined model: ask what to do with // the model before it changes (the job ran with the default, keep). let unrelated = opened.reply.get("related").and_then(Value::as_bool) == Some(false) && opened.reply.get("model").and_then(Value::as_str) == Some("kept"); match opened.recent.clone() { Some(path) if unrelated && Kind::of(&path) == Some(Kind::Design) => { self.held = Some(opened); self.set_status(format!("{} has none of the bodies the model is on", file_name(&path)), false); self.prompt = Some(Prompt::Design(path)); return false; } _ => self.take_opened(opened), } } Some((_, Err(e))) => { let what = l.recent.as_deref().map_or_else(|| l.title.clone(), file_name); self.set_status(format!("{what}: {e}"), true); if let Some(d) = self.dialog.as_mut().filter(|d| l.form == Some(d.command)) { d.error = Some(e); } } None => self.set_status(format!("{}: stopped unexpectedly", l.title), true), } self.open_queued(); self.loading.is_some() } /// Open the next dropped file waiting its turn. fn open_queued(&mut self) { if let Some(next) = self.queued.pop_front() { self.open_path(&next); } } /// A finished job's session replaces the window's. fn take_opened(&mut self, o: Opened) { // The session it replaces may be large: it is freed off the window thread. let mut session = *o.session; // Its commands come from where the window's do now (the job may have started from the // command search or the control channel). session.origin = self.session.origin; let old = std::mem::replace(&mut self.session, session); off_thread("fieldcraft drop".into(), move || drop(old)); self.model_changed(); if let Some(p) = &o.recent { self.recent.add(p); } if self.dialog.as_ref().is_some_and(|d| o.form == Some(d.command)) { self.dialog = None; } let warnings: Vec<&str> = o.reply.get("warnings").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect(); if warnings.is_empty() { self.set_status(o.done, false); } else { self.set_status(format!("{} — {}", o.done, warnings.join("; ")), true); } } /// Answer [`Prompt::Design`]: a new model with the design (opened again, in the background), /// the design over the kept model, or neither. pub fn answer_design(&mut self, answer: DesignAnswer) { let Some(Prompt::Design(path)) = self.prompt.clone() else { return }; self.prompt = None; let Some(opened) = self.held.take() else { return }; match answer { DesignAnswer::Keep => { let stale = opened.reply.get("stale").map(stale_note).unwrap_or_default(); let done = format!("{}{stale}", opened.done); self.take_opened(Opened { done, ..opened }); } DesignAnswer::New => { let name = file_name(&path); self.begin_job( "geometry.open", json!({"path": path, "model": "new"}), false, format!("Opening {name}"), format!("Opened {name} as a new model"), Some(path), ); } DesignAnswer::Cancel => self.set_status(format!("Did not open {}", file_name(&path)), false), } if self.loading.is_none() { self.open_queued(); } } /// Block until the background open finishes (tests and the offscreen harness). pub fn wait_loaded(&mut self) { for _ in 0..6000 { if !self.poll_loading() { return; } std::thread::sleep(std::time::Duration::from_millis(10)); } } /// File > Save: to the model's file, or Save As when it has none. Returns whether it saved. pub fn save(&mut self) -> bool { if self.session.path.is_none() { return self.save_as(); } let saved = self.run("file.save", json!({})).is_ok(); self.saved_to_recent(saved) } /// After a save: the model's file goes first in the recent list. fn saved_to_recent(&mut self, saved: bool) -> bool { if saved && let Some(p) = self.session.path.clone() { self.recent.add(&p); } saved } /// File > Save As: pick a new file for the model. Without a picker the command's form opens. pub fn save_as(&mut self) -> bool { let Some(pick) = self.services.pick_save.as_ref() else { self.open_form("file.save_as", json!({})); return false; }; let Some(path) = pick(&format!("{}.fieldcraft", self.model_name()), &[MODEL]) else { return false }; let path = with_extension(path, "fieldcraft"); let saved = self.run("file.save_as", json!({"path": path})).is_ok(); self.saved_to_recent(saved) } /// Write Deck: pick where to write the model as a `format` deck. Without a picker the /// command's form opens with the format filled in. pub fn write_deck(&mut self, format: &str) { let Some(f) = fieldcraft_decks::format(format) else { self.set_status(format!("no {format} deck writer yet"), true); return; }; let Some(pick) = self.services.pick_save.as_ref() else { self.open_form("deck.write", json!({"format": format})); return; }; let exts: &'static [&'static str] = match f.extension { "inp" => &["inp"], "bdf" => &["bdf", "dat", "nas"], "k" => &["k", "key", "dyn"], _ => &[], }; let filter = Filter { name: "Solver deck", extensions: exts }; let Some(path) = pick(&format!("{}.{}", self.model_name(), f.extension), &[filter]) else { return }; let path = if exts.iter().any(|e| Path::new(&path).extension().and_then(|x| x.to_str()).is_some_and(|x| x.eq_ignore_ascii_case(e))) { path } else { with_extension(path, f.extension) }; let _ = self.run("deck.write", json!({"format": format, "path": path})); } /// The autosaved models that can be recovered (`file.recovery.list`), newest first. pub fn recoverable(&mut self) -> Vec { self.session.execute("file.recovery.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default() } /// Answer the unsaved-changes prompt. pub fn answer_unsaved(&mut self, answer: Answer) { let Some(Prompt::Unsaved(next)) = self.prompt.clone() else { return }; self.prompt = None; let go = match answer { Answer::Save => self.save(), Answer::Discard => true, Answer::Cancel => false, }; if !go { return; } match next { Replace::File(path) => { if let Some(kind) = Kind::of(&path) { self.load(kind, path); } } Replace::Sample(name) => self.load_sample(name), Replace::Update => self.close_to_update(), } } /// What a click on a file entry does, when it is one: open, save, write a deck or recover /// (through the host's pickers when `pickers`), choose a sample, or solve in the background. /// `false` for every other command, and for picker entries without pickers (their form /// opens instead). pub(crate) fn file_action(&mut self, command: &str, preset: &Value, pickers: bool) -> bool { let can_save = pickers && self.services.pick_save.is_some(); match command { "file.save" if self.session.path.is_some() || can_save => { self.save(); } "file.save_as" if can_save => { self.save_as(); } "file.recover" | "file.recovery.list" => self.prompt = Some(Prompt::Recover), "file.sample" => self.prompt = Some(Prompt::Samples), "diagnostics.bug_report" | "diagnostics.crash_reports" | "diagnostics.crash_report" => self.report_bug(None), "solve.run" => self.solve_all(), "deck.write" if can_save => match preset.get("format").and_then(Value::as_str) { Some(f) => self.write_deck(f), None => return false, }, other => match kind_of_command(other) { Some(kind) if pickers && self.services.pick_open.is_some() => self.open_dialog(Some(kind)), _ => return false, }, } true } /// Files dropped on the window (and by the control channel's `ui.drop`) open in turn; a /// hovered drop shows where it goes. pub(crate) fn dropped_files(&mut self, ctx: &egui::Context) { let mut dropped: Vec = ctx .input(|i| i.raw.dropped_files.iter().filter_map(|f| Some(f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty())).collect()); dropped.append(&mut self.synthetic_drops); // A model first: it replaces the session, then designs and meshes go into it. let (models, rest): (Vec, Vec) = dropped.into_iter().partition(|p| Kind::of(p) == Some(Kind::Model)); for p in models.into_iter().take(1).chain(rest) { self.open_path(&p); } } } /// What a kept model has that the design does not (`geometry.open`'s `stale`), for the status: /// empty when everything resolves. fn stale_note(stale: &Value) -> String { let names = |k: &str| stale.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect::>().join(", "); let parts: Vec = [("mesh of", names("mesh_bodies")), ("sets", names("sets")), ("seeds on", names("seeds"))] .into_iter() .filter(|(_, n)| !n.is_empty()) .map(|(what, n)| format!("{what} {n}")) .collect(); if parts.is_empty() { String::new() } else { format!(" — not on this design: {}", parts.join("; ")) } } /// The last part of a path, for messages. pub fn file_name(path: &str) -> String { Path::new(path).file_name().and_then(|n| n.to_str()).map_or_else(|| path.to_string(), str::to_string) } /// How long ago, from an age in seconds: "just now", "5 min ago", "3 h ago", "2 days ago". pub fn ago(seconds: u64) -> String { match seconds { 0..60 => "just now".into(), 60..3600 => format!("{} min ago", seconds / 60), 3600..86_400 => format!("{} h ago", seconds / 3600), 86_400..172_800 => "1 day ago".into(), _ => format!("{} days ago", seconds / 86_400), } } /// The prompts (unsaved changes, recover), the loading overlay and the drop hint. pub fn show(app: &mut ModelApp, ctx: &egui::Context) { let t = crate::theme::Tokens::get(app.ui.dark); let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty()); if hovering || app.loading.is_some() { let fraction = app.loading.as_ref().and_then(|l| l.progress.snapshot().fraction()); let text = match app.loading.as_ref() { Some(l) => l.line(app.now), None => "Drop to open: .fieldcraft models, .solidcraft designs, .3mf tagged meshes".into(), }; let mut cancel = false; egui::Area::new(egui::Id::new("fc_loading")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)).show( ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.overlay).inner_margin(egui::Margin::symmetric(16, 12)).show(ui, |ui| { ui.horizontal(|ui| { if app.loading.is_some() { ui.spinner(); } ui.label(egui::RichText::new(text).color(t.overlay_text)); if app.loading.is_some() { cancel = ui.button("Cancel").clicked(); } }); if let Some(f) = fraction { ui.add(egui::ProgressBar::new(f as f32).desired_height(4.0).fill(t.accent)); } }); }, ); if let Some(l) = app.loading.as_mut() { l.shown = true; } if cancel { app.cancel_loading(); } if app.loading.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(50)); } } match app.prompt.clone() { Some(Prompt::Unsaved(next)) => { let mut answer = None; crate::frame::window(ctx, "Unsaved changes", crate::frame::Width::Normal).id(egui::Id::new("fc_unsaved")).show(ctx, |ui| { ui.label(match next { Replace::Update => format!("Save the changes to {} before restarting to update?", app.model_name()), _ => format!("Save the changes to {} before opening {}?", app.model_name(), next.name()), }); ui.label(egui::RichText::new("Without saving, they are lost.").color(t.text_dim)); ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Save").clicked() { answer = Some(Answer::Save); } if ui.button("Don't Save").clicked() { answer = Some(Answer::Discard); } if ui.button("Cancel").clicked() { answer = Some(Answer::Cancel); } }); }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(Answer::Cancel); } if let Some(a) = answer { app.answer_unsaved(a); } } Some(Prompt::Design(path)) => { let mut answer = None; crate::frame::window(ctx, "Open design", crate::frame::Width::Normal).id(egui::Id::new("fc_design")).show(ctx, |ui| { ui.label(format!("{} has none of the bodies {}'s mesh and sets are on.", file_name(&path), app.model_name())); ui.label(egui::RichText::new("A new model starts empty with this design; keeping the model keeps its mesh and definitions, which then refer to bodies the design does not have.").color(t.text_dim)); ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Start a new model with this design").clicked() { answer = Some(DesignAnswer::New); } if ui.button("Keep the current model").clicked() { answer = Some(DesignAnswer::Keep); } if ui.button("Cancel").clicked() { answer = Some(DesignAnswer::Cancel); } }); }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(DesignAnswer::Cancel); } else if ctx.input(|i| i.key_pressed(egui::Key::Enter)) { answer = Some(DesignAnswer::New); } if let Some(a) = answer { app.answer_design(a); } } Some(Prompt::Delete(ask)) => { let mut answer = None; crate::frame::window(ctx, "Delete", crate::frame::Width::Normal).id(egui::Id::new("fc_delete")).show(ctx, |ui| { ui.label(ask.question()); if ask.allowed() && !ask.users.is_empty() { ui.label(egui::RichText::new("They are deleted too; Undo brings everything back.").color(t.text_dim)); } ui.add_space(6.0); ui.horizontal(|ui| { if ask.allowed() && ui.button("Delete").clicked() { answer = Some(true); } if ui.button(if ask.allowed() { "Cancel" } else { "Close" }).clicked() { answer = Some(false); } }); }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(false); } else if ctx.input(|i| i.key_pressed(egui::Key::Enter)) { answer = Some(ask.allowed()); } if let Some(delete) = answer { app.answer_delete(delete); } } Some(Prompt::ReplaceMesh(r)) => { let mut answer = None; crate::frame::window(ctx, "Replace mesh", crate::frame::Width::Normal).id(egui::Id::new("fc_replace_mesh")).show(ctx, |ui| { ui.label(&r.question); for w in &r.warnings { ui.label(egui::RichText::new(w).color(t.text_dim)); } ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Replace").clicked() { answer = Some(true); } if ui.button("Cancel").clicked() { answer = Some(false); } }); }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(false); } else if ctx.input(|i| i.key_pressed(egui::Key::Enter)) { answer = Some(true); } if let Some(replace) = answer { app.answer_replace_mesh(replace); } } Some(Prompt::Recover) => { let entries = app.recover_list(ctx); let (mut recover, mut discard, mut discard_all, mut close) = (None, None, false, false); crate::frame::window(ctx, "Recover", crate::frame::Width::Wide).id(egui::Id::new("fc_recover")).show(ctx, |ui| { if entries.is_empty() { ui.label(egui::RichText::new("No autosaved models to recover.").color(t.text_dim)); } egui::Grid::new("fc_recover_grid").num_columns(4).spacing(egui::vec2(10.0, 4.0)).show(ui, |ui| { for e in &entries { let id = e.get("id").and_then(Value::as_str).unwrap_or_default().to_string(); let path = e.get("path").and_then(Value::as_str); let n = |k: &str| e.get(k).and_then(Value::as_u64).unwrap_or(0); let name = ui.label(path.map_or_else(|| "Untitled".to_string(), file_name)); if let Some(path) = path { name.on_hover_text(path); } ui.label(egui::RichText::new(ago(n("age"))).color(t.text_dim)); ui.label(egui::RichText::new(format!("{} nodes, {} elements", n("nodes"), n("elements"))).color(t.text_dim)); ui.horizontal(|ui| { if ui.button("Recover").clicked() { recover = Some(id.clone()); } if ui.button("Discard").clicked() { discard = Some(id.clone()); } }); ui.end_row(); } }); ui.add_space(6.0); ui.horizontal(|ui| { close = ui.button("Close").clicked(); if entries.len() > 1 { discard_all = ui.button("Discard all").clicked(); } }); }); if let Some(id) = recover { app.recover(&id); } else if discard.is_some() || discard_all { let params = discard.map_or_else(|| json!({"all": true}), |id| json!({"id": id})); let _ = app.run("file.recovery.discard", params); ctx.data_mut(|d| d.remove::>(egui::Id::new("fc_recover_entries"))); } else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.prompt = None; ctx.data_mut(|d| d.remove::>(egui::Id::new("fc_recover_entries"))); } } Some(Prompt::Samples) => { let (mut open, mut close) = (None, false); crate::frame::window(ctx, "Samples", crate::frame::Width::Wide).id(egui::Id::new("fc_samples")).show(ctx, |ui| { ui.set_width(crate::frame::WINDOW_WIDE_MAX_W.min(ctx.content_rect().width() - 48.0)); crate::samples::grid(ui, &t, (ctx.content_rect().height() * 0.6).max(160.0), &mut open); ui.add_space(6.0); close = ui.button("Close").clicked(); }); if let Some(name) = open { app.prompt = None; app.open_sample(name); } else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.prompt = None; } } // Drawn by `crate::report::show` and `crate::privacy::show`. Some(Prompt::Crashed(_) | Prompt::ReportBug | Prompt::Consent | Prompt::Settings | Prompt::Update) | None => {} } } impl ModelApp { /// The recover window's entries, read from disk once while it is open. fn recover_list(&mut self, ctx: &egui::Context) -> Vec { let key = egui::Id::new("fc_recover_entries"); if let Some(v) = ctx.data(|d| d.get_temp::>(key)) { return v; } let v = self.recoverable(); ctx.data_mut(|d| d.insert_temp(key, v.clone())); v } } /// The File menu's rows: open, recent files, save, decks, samples, recover. The title bar's /// File menu. pub fn file_menu(app: &mut ModelApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(app.ui.dark); let key = |k: &str| crate::appbar::shortcut(k); let row = |ui: &mut egui::Ui, label: &str, right: Option<&str>| crate::frame::menu_row(ui, &t, label, right, true).clicked(); let mut act: Option> = None; if row(ui, "Open…", Some(&key("O"))) { act = Some(Box::new(|app| app.open_dialog(None))); } if row(ui, "Samples…", None) { act = Some(Box::new(|app| app.prompt = Some(Prompt::Samples))); } if !app.recent.paths.is_empty() { crate::frame::menu_heading(ui, &t, "RECENT"); for p in app.recent.paths.iter().take(6) { let name = file_name(p); if crate::frame::menu_row(ui, &t, &name, None, true).on_hover_text(p).clicked() { let p = p.clone(); act = Some(Box::new(move |app| app.open_path(&p))); } } } ui.separator(); if row(ui, "Save", Some(&key("S"))) { act = Some(Box::new(|app| { app.save(); })); } if row(ui, "Save As…", Some(&key("Shift+S"))) { act = Some(Box::new(|app| { app.save_as(); })); } ui.separator(); crate::frame::menu_heading(ui, &t, "WRITE DECK"); for c in fieldcraft_engine::catalogue::CATALOGUE.iter().filter(|c| c.panel == "Decks") { let format = c.preset().get("format").and_then(Value::as_str).map(str::to_string); let soon = c.milestone().map(|m| format!("soon · {m}")); let enabled = format.is_some() && soon.is_none(); if crate::frame::menu_row(ui, &t, &format!("{}…", c.label), soon.as_deref(), enabled).on_hover_text(c.tip).clicked() && let Some(f) = format { act = Some(Box::new(move |app| app.write_deck(&f))); } } ui.separator(); if crate::frame::menu_row(ui, &t, "Recover…", None, true) .on_hover_text("Models autosaved by an app that crashed or closed with unsaved changes") .clicked() { act = Some(Box::new(|app| app.prompt = Some(Prompt::Recover))); } if let Some(f) = act { app.ui.menu = None; ui.close(); f(app); } } #[cfg(test)] mod tests { use std::cell::RefCell; use std::rc::Rc; use super::*; fn temp(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("fieldcraft-files-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } /// The navigation preset and the wheel's direction are remembered in the preferences file; /// an older file with keys no longer used still loads, a malformed one gives the defaults. #[test] fn navigation_preferences_are_remembered() { let file = temp("prefs").join("prefs.json"); std::fs::write(&file, br#"{"show_setup": false}"#).unwrap(); let store = PrefsStore::file(file.clone()); assert_eq!(Prefs::load(Some(&store)), Prefs::default()); let services = || Services { prefs: Some(PrefsStore::file(file.clone())), ..Default::default() }; let mut app = ModelApp::new(Session::default()).with_services(services()); app.ui.nav_preset = "solidworks".into(); app.ui.reverse_zoom = true; app.save_prefs(); let again = ModelApp::new(Session::default()).with_services(services()); assert_eq!((again.ui.nav_preset.as_str(), again.ui.reverse_zoom), ("solidworks", true)); std::fs::write(&file, b"{nope").unwrap(); assert_eq!(Prefs::load(Some(&store)), Prefs::default()); } /// A picker that answers `path` and records the filters it was offered. fn answering(path: Option, offered: Rc>>>) -> PickOpen { Box::new(move |f: &[Filter]| { offered.borrow_mut().push(f.to_vec()); path.clone() }) } #[test] fn file_types_dispatch_to_their_command() { assert_eq!(Kind::of("a/b.fieldcraft"), Some(Kind::Model)); assert_eq!(Kind::of("B.SOLIDCRAFT"), Some(Kind::Design)); assert_eq!(Kind::of("part.3MF").map(Kind::command), Some("geometry.import.mesh")); assert_eq!(Kind::of("part.step"), None); assert_eq!(Kind::of("noext"), None); assert_eq!(with_extension("a/b".into(), "fieldcraft"), "a/b.fieldcraft"); assert_eq!(with_extension("a/b.FIELDCRAFT".into(), "fieldcraft"), "a/b.FIELDCRAFT"); } /// Open (picker) → background open → the model, the recent list; Save As (picker) and Save. #[test] fn the_pickers_open_and_save_models() { let dir = temp("models"); let saved = dir.join("beam.fieldcraft").to_string_lossy().to_string(); let mut first = ModelApp::new(Session::default()); first.run_script(&fieldcraft_engine::sample::script()).unwrap(); // Save As through the save picker: the extension is added, the file becomes the model's. let bare = dir.join("beam").to_string_lossy().to_string(); first.services.pick_save = Some(Box::new(move |name: &str, f: &[Filter]| { assert_eq!((name, f.first().map(|f| f.extensions)), ("Untitled.fieldcraft", Some(&["fieldcraft"][..]))); Some(bare.clone()) })); first.start("file.save_as").unwrap(); assert_eq!(first.session.path.as_deref(), Some(saved.as_str())); assert!(!first.session.is_dirty()); assert_eq!(first.recent.paths, vec![saved.clone()]); let offered = Rc::new(RefCell::new(Vec::new())); let mut app = ModelApp::new(Session::default()); app.services.recent_path = None; app.services.pick_open = Some(answering(Some(saved.clone()), offered.clone())); app.start("file.open").unwrap(); assert!(app.dialog.is_none(), "a picker, not the form"); assert_eq!(offered.borrow()[0][0], MODEL, "Open Model offers models first"); assert_eq!(app.busy(), Some("Opening beam.fieldcraft")); app.wait_loaded(); assert_eq!(app.session.model.elements().len(), first.session.model.elements().len()); assert_eq!(app.session.path.as_deref(), Some(saved.as_str())); assert_eq!(app.recent.paths, vec![saved.clone()]); assert_eq!(app.status.as_ref().map(|s| (s.text.as_str(), s.error)), Some(("Opened beam.fieldcraft", false))); // Save goes to the model's own file without asking. app.services.pick_save = Some(Box::new(|_: &str, _: &[Filter]| -> Option { panic!("Save asked for a file") })); app.run("mesh.node.add", serde_json::json!({"label": 99999, "xyz": [0, 0, 0]})).unwrap(); assert!(app.save()); assert!(!app.session.is_dirty()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn cancelled_pickers_and_failed_opens_leave_the_model() { let mut app = ModelApp::new(Session::default()); app.run("mesh.node.add", serde_json::json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); let offered = Rc::new(RefCell::new(Vec::new())); app.services.pick_open = Some(answering(None, offered.clone())); app.open_dialog(None); assert_eq!(offered.borrow()[0], OPEN_FILTERS.to_vec(), "File > Open offers every type"); assert!(app.loading.is_none()); // A file that does not open: the model stays, the error is shown. let dir = temp("broken"); let broken = dir.join("broken.solidcraft"); std::fs::write(&broken, b"not a design").unwrap(); app.open_path(&broken.to_string_lossy()); app.wait_loaded(); assert!(app.session.model.node(1).is_some()); assert!(app.status.as_ref().is_some_and(|s| s.error && s.text.starts_with("broken.solidcraft:")), "{:?}", app.status); assert!(app.recent.paths.is_empty()); app.open_path("drawing.dxf"); assert!(app.status.as_ref().is_some_and(|s| s.error)); let _ = std::fs::remove_dir_all(&dir); } #[test] fn opening_a_model_over_unsaved_changes_asks_first() { let dir = temp("unsaved"); let path = dir.join("other.fieldcraft").to_string_lossy().to_string(); let mut other = ModelApp::new(Session::default()); other.run("mesh.node.add", serde_json::json!({"label": 5, "xyz": [0, 0, 0]})).unwrap(); other.run("file.save_as", serde_json::json!({"path": path})).unwrap(); let mut app = ModelApp::new(Session::default()); app.run("mesh.node.add", serde_json::json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); app.open_path(&path); assert_eq!(app.prompt, Some(Prompt::Unsaved(Replace::File(path.clone())))); assert!(app.loading.is_none()); app.answer_unsaved(Answer::Cancel); assert!(app.prompt.is_none() && app.loading.is_none() && app.session.model.node(1).is_some()); app.open_path(&path); app.answer_unsaved(Answer::Discard); app.wait_loaded(); assert!(app.session.model.node(5).is_some() && app.session.model.node(1).is_none()); let _ = std::fs::remove_dir_all(&dir); } /// A design of another part over a meshed model asks; each answer does what it says, and the /// same design opens again without asking. #[test] fn opening_a_design_of_another_part_asks_what_to_do_with_the_model() { let dir = temp("design"); let path = dir.join("plate.solidcraft").to_string_lossy().to_string(); std::fs::write(&path, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap(); let cube = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]]; let meshed = || { let mut app = ModelApp::new(Session::default()); app.run("mesh.block", serde_json::json!({"body": "Seal", "kind": "hex8", "corners": cube, "divisions": [1, 1, 1]})).unwrap(); app.run("model.set.create", serde_json::json!({"name": "seal", "geometry": [{"body": "Seal"}]})).unwrap(); app }; let opened = |app: &mut ModelApp| { app.open_path(&path); app.wait_loaded(); }; // Cancel: nothing changes. let mut app = meshed(); opened(&mut app); assert_eq!(app.prompt, Some(Prompt::Design(path.clone()))); assert!(app.session.design.is_none(), "the model waits for the answer"); app.answer_design(DesignAnswer::Cancel); assert!(app.prompt.is_none() && app.held.is_none() && app.session.design.is_none() && app.session.model.elements().len() == 1); // Keep: the design over the model, and the status says what does not resolve. let mut app = meshed(); opened(&mut app); app.answer_design(DesignAnswer::Keep); assert!(app.session.design.is_some() && app.session.model.elements().len() == 1); assert!(app.status.as_ref().is_some_and(|s| s.text.contains("not on this design: mesh of Seal; sets seal")), "{:?}", app.status); assert_eq!(app.recent.paths, vec![path.clone()]); // New: an empty model with the design, one undo step from the old one. let mut app = meshed(); opened(&mut app); app.answer_design(DesignAnswer::New); app.wait_loaded(); assert!(app.prompt.is_none() && app.session.design.is_some()); assert!(app.session.model.elements().is_empty() && app.session.model.sets().is_empty()); app.run("edit.undo", serde_json::json!({})).unwrap(); assert!(app.session.design.is_none() && app.session.model.elements().len() == 1); // The same design again (its bodies are the model's): no question. app.run("edit.redo", serde_json::json!({})).unwrap(); app.run("model.set.create", serde_json::json!({"name": "plate", "geometry": [{"body": "Body1"}]})).unwrap(); opened(&mut app); assert!(app.prompt.is_none() && app.session.model.set("plate").is_some()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn decks_are_written_where_the_picker_says() { let dir = temp("decks"); let target = dir.join("cantilever").to_string_lossy().to_string(); let mut app = ModelApp::new(Session::default()); app.run_script(&fieldcraft_engine::sample::cantilever()).unwrap(); let t = target.clone(); app.services.pick_save = Some(Box::new(move |name: &str, f: &[Filter]| { assert_eq!((name, f.first().map(|f| f.extensions)), ("Untitled.inp", Some(&["inp"][..]))); Some(t.clone()) })); app.start("deck.write.abaqus").unwrap(); assert!(app.dialog.is_none()); let text = std::fs::read_to_string(format!("{target}.inp")).unwrap(); assert!(text.contains("*NODE") || text.contains("*Node"), "{}", &text[..text.len().min(200)]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn recovered_entries_say_how_long_ago() { let cases = [ (0, "just now"), (59, "just now"), (60, "1 min ago"), (3599, "59 min ago"), (7200, "2 h ago"), (90_000, "1 day ago"), (13 * 86_400, "13 days ago"), ]; for (s, want) in cases { assert_eq!(ago(s), want, "{s}"); } } #[test] fn the_recent_list_is_kept_newest_first_and_survives_bad_files() { let dir = temp("recent"); let file = dir.join("recent.json"); let mut r = Recent::load(Some(file.clone())); assert!(r.paths.is_empty()); for i in 0..12 { r.add(&format!("/m/{i}.fieldcraft")); } r.add("/m/3.fieldcraft"); let again = Recent::load(Some(file.clone())); assert_eq!(again.paths.len(), MAX_RECENT); assert_eq!(again.paths.first().map(String::as_str), Some("/m/3.fieldcraft")); assert_eq!(again.paths.iter().filter(|p| *p == "/m/3.fieldcraft").count(), 1); std::fs::write(&file, b"{not json").unwrap(); assert!(Recent::load(Some(file.clone())).paths.is_empty()); std::fs::write(&file, br#"["/x/a.fieldcraft", "/x/b.exe", ""]"#).unwrap(); assert_eq!(Recent::load(Some(file)).paths, vec!["/x/a.fieldcraft".to_string()]); let _ = std::fs::remove_dir_all(&dir); } /// A sample opens with nothing to save; Solve runs its steps in the background and the /// Results tab comes up with the first step's result. #[test] fn a_sample_solves_into_the_results_view() { let mut app = ModelApp::new(Session::default()); app.start("file.sample").unwrap(); assert_eq!(app.prompt, Some(Prompt::Samples)); app.prompt = None; app.open_sample("cantilever"); assert!(!app.session.is_dirty() && !app.session.model.steps().is_empty()); app.start("solve.run").unwrap(); assert_eq!(app.busy(), Some("Solving")); assert!(app.run("model.info", serde_json::json!({})).is_err(), "commands wait for the solve"); app.wait_loaded(); assert!(app.status.as_ref().is_some_and(|s| s.text.starts_with("Solved 2 steps in ")), "{:?}", app.status); app.logic(&egui::Context::default()); assert_eq!(app.ui.tab, fieldcraft_engine::catalogue::Tab::Results); assert!(app.plot().is_some()); // Over unsaved changes, opening a sample asks first. app.run("mesh.node.add", serde_json::json!({"label": 900000, "xyz": [0, 0, 0]})).unwrap(); app.open_sample("impact"); assert_eq!(app.prompt, Some(Prompt::Unsaved(Replace::Sample("impact")))); app.answer_unsaved(Answer::Discard); assert!(app.session.model.step("Impact").is_some()); // A model without steps says so instead of solving nothing. let mut empty = ModelApp::new(Session::default()); empty.solve_all(); assert!(empty.busy().is_none() && empty.status.as_ref().is_some_and(|s| s.error)); } /// A cancelled or refused solve leaves the window in the workspace it was in, and a job /// waiting over the control channel hears that it was cancelled (#324, #367). #[test] fn a_cancelled_or_refused_solve_stays_in_its_workspace() { use fieldcraft_engine::catalogue::Workspace; let mut app = ModelApp::new(Session::default()); app.open_sample("cantilever"); app.ui.select_workspace(Workspace::Model); let job = app.start_job("solve.run", serde_json::json!({})).unwrap(); assert_eq!(job, 1); let (tx, rx) = std::sync::mpsc::channel(); app.loading.as_mut().unwrap().waiters.push(tx); assert_eq!(app.loading.as_ref().unwrap().to_json(app.now)["command"], "solve.run"); app.cancel_loading(); app.logic(&egui::Context::default()); assert_eq!(app.ui.workspace, Workspace::Model); assert_eq!(rx.recv().unwrap()["cancelled"], true); let mut empty = ModelApp::new(Session::default()); empty.ui.select_workspace(Workspace::Model); assert!(empty.start_job("solve.run", serde_json::json!({})).is_err()); empty.solve_all(); empty.logic(&egui::Context::default()); assert_eq!(empty.ui.workspace, Workspace::Model); } /// A first-time user's path: Open picks a SolidCraft design, the Tet Mesh button opens its /// form, OK with the defaults meshes it in the background, and the mesh is there. A mesher /// that does not fit says why in the form, which stays open. #[test] fn a_picked_design_meshes_from_the_ribbon_with_the_defaults() { let dir = temp("path"); let design = dir.join("plate.solidcraft").to_string_lossy().to_string(); std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap(); let offered = Rc::new(RefCell::new(Vec::new())); let mut app = ModelApp::new(Session::default()); app.services.pick_open = Some(answering(Some(design.clone()), offered)); app.open_dialog(None); app.wait_loaded(); assert_eq!(app.session.design.as_ref().map(|d| d.bodies.len()), Some(1), "{:?}", app.status); assert!(!app.display().bounds.is_empty()); app.start("mesh.tet.generate").unwrap(); assert_eq!(app.dialog.as_ref().map(|d| (d.command, d.text.as_str())), Some(("mesh.tet.generate", r#"{"order":"linear","quality":true}"#))); app.submit_dialog(); assert_eq!(app.busy(), Some("Generate Tet Mesh"), "meshing runs off the window thread"); app.wait_loaded(); assert!(app.dialog.is_none(), "the form closes when it succeeded: {:?}", app.status); // The status sums up the mesh made: elements, worst angle, time, where to check it. let status = app.status.clone().unwrap(); assert!( !status.error && status.text.starts_with("Generate Tet Mesh done: ") && status.text.contains(" tet4, smallest dihedral angle "), "{}", status.text ); let counts = app.session.model.element_counts(); assert!(matches!(counts.as_slice(), [(fieldcraft_model::ElementKind::Tet4, n)] if *n > 0), "{counts:?}"); assert_eq!(app.session.journal.len(), 2, "open and mesh, each an undo step"); // Meshing the meshed body again asks first (#267); Cancel keeps the form and the mesh. app.start("mesh.tet.generate").unwrap(); app.submit_dialog(); let Some(Prompt::ReplaceMesh(r)) = app.prompt.clone() else { panic!("no question: {:?}", app.prompt) }; let tets = counts.first().map_or(0, |c| c.1); assert!(r.question.starts_with("Body1 already has a mesh (") && r.question.ends_with(" tet4). Replace it?"), "{r:?}"); assert!(tets > 999 && r.question.contains(','), "thousands are separated: {r:?}"); assert!(app.busy().is_none()); app.answer_replace_mesh(false); assert!(app.prompt.is_none() && app.dialog.is_some(), "Cancel leaves the form open"); assert_eq!(app.session.journal.len(), 2); // Replace meshes it again, in place of the old mesh: as many tets, one undo step. app.submit_dialog(); app.answer_replace_mesh(true); app.wait_loaded(); assert!(app.dialog.is_none(), "{:?}", app.status); assert_eq!(app.session.model.element_counts(), counts, "replaced, not stacked"); // A plate with a hole is not one mapped block: the form stays with the reason. app.start("mesh.hex.mapped").unwrap(); app.submit_dialog(); app.answer_replace_mesh(true); app.wait_loaded(); let error = app.dialog.as_ref().and_then(|d| d.error.clone()).unwrap_or_default(); assert!(error.contains("not a logical cube"), "{error}"); assert!(app.status.as_ref().is_some_and(|s| s.error)); assert_eq!(app.session.model.element_counts(), counts, "a failed mesher changes nothing"); let _ = std::fs::remove_dir_all(&dir); } /// The control channel's `ui.start` never opens a picker: it opens the form. #[test] fn programmatic_starts_never_open_a_picker() { let mut app = ModelApp::new(Session::default()); app.services.pick_open = Some(Box::new(|_: &[Filter]| -> Option { panic!("a picker opened") })); app.services.pick_save = Some(Box::new(|_: &str, _: &[Filter]| -> Option { panic!("a picker opened") })); app.start_programmatic("file.open").unwrap(); assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("file.open")); // A deck needs a model to write. app.run("file.sample", json!({"name": "cantilever"})).unwrap(); app.start_programmatic("deck.write.abaqus").unwrap(); assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("deck.write")); // In-window choices are not pickers: they behave as a click. app.start_programmatic("file.sample").unwrap(); assert_eq!(app.prompt, Some(Prompt::Samples)); } }