forked from 1b-agent/fieldcraft-upstream
- A solve or a mesher runs as a job with one progress mechanism: the overlay shows the step, stage, count and elapsed time with a bar when bounded, OK waits while it runs, and the status sums it up (elements, smallest angle, time) (#322, #357). - Over the control channel such a command is a job too: answered when it ends however long it runs, or at once with wait: false, then job.status, job.wait, job.cancel; ui.inspect gives job; MCP waits for it (#367). - A command that cannot run yet lists what it misses in the context panel, each with a button to the command that provides it (#323). - A cancelled or refused solve stays in its workspace (#324, test). - A mesher form starts from the parameters of the mesh it would replace (#343). - Seed Edges and edge/vertex/face datum points switch the selection filter while open and restore it (#341). - Datum forms draw the datum live, the Partition form shows its datum, and new datums and partitions get the next free name (#347). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
1639 lines
73 KiB
Rust
1639 lines
73 KiB
Rust
//! 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<dyn Fn(&[Filter]) -> Option<String>>;
|
||
/// The host picks where to save, starting from a file name; `None` when the user cancelled.
|
||
pub type PickSave = Box<dyn Fn(&str, &[Filter]) -> Option<String>>;
|
||
/// The host opens a web link in the user's browser.
|
||
pub type OpenUrl = Box<dyn Fn(&str) -> 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<PickOpen>,
|
||
pub pick_save: Option<PickSave>,
|
||
/// The recent-files list's file (JSON); `None` keeps the list for this run only.
|
||
pub recent_path: Option<PathBuf>,
|
||
/// 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<PrefsStore>,
|
||
/// 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<OpenUrl>,
|
||
}
|
||
|
||
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<Kind> {
|
||
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> {
|
||
[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<String>,
|
||
file: Option<PathBuf>,
|
||
}
|
||
|
||
impl Recent {
|
||
/// The list kept in `file` (an unreadable or malformed file gives an empty list).
|
||
pub fn load(file: Option<PathBuf>) -> 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::<Vec<String>>(&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<dyn Fn() -> Option<Vec<u8>>>,
|
||
pub save: Box<dyn Fn(&[u8]) -> 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<bool>,
|
||
/// 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<String>,
|
||
/// 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<Session>, Result<Value, String>)>,
|
||
/// The work, while it waits to run on the window thread (the web, which has no threads).
|
||
pending: Option<Work>,
|
||
/// 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<Progress>,
|
||
/// 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<Sender<Value>>,
|
||
}
|
||
|
||
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<Session>,
|
||
reply: Value,
|
||
done: String,
|
||
recent: Option<String>,
|
||
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<fieldcraft_engine::crash::Summary>),
|
||
/// 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<dyn FnOnce() + Send>;
|
||
|
||
/// 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<Work> {
|
||
#[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<Progress>,
|
||
) -> (Receiver<(Box<Session>, Result<Value, String>)>, Option<Work>) {
|
||
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<String>) -> 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<Kind>) {
|
||
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<u64, String> {
|
||
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<Value> {
|
||
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<String> = 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<String>, Vec<String>) = 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::<Vec<_>>().join(", ");
|
||
let parts: Vec<String> = [("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::<Vec<Value>>(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::<Vec<Value>>(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<Value> {
|
||
let key = egui::Id::new("fc_recover_entries");
|
||
if let Some(v) = ctx.data(|d| d.get_temp::<Vec<Value>>(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<Box<dyn FnOnce(&mut ModelApp)>> = 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<String>, offered: Rc<RefCell<Vec<Vec<Filter>>>>) -> 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<String> { 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<String> { panic!("a picker opened") }));
|
||
app.services.pick_save = Some(Box::new(|_: &str, _: &[Filter]| -> Option<String> { 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));
|
||
}
|
||
}
|