ui: mesh in the background from the mesher forms

OK on a mesher's form ran the command on the window thread: octree hex
on the bolted assembly froze the window for over a minute, with no
progress and no way to stop it. The tet and hex meshers' forms now run
their command as a background job on a copy of the session, as opening
a file and solving do: the window keeps drawing with the job's name and
Cancel, the form closes when the mesh is in and shows the error when it
is not. ui.dialog {run} stays synchronous for programmatic callers.

The new test walks a first-time user's path: Open picks a SolidCraft
design, Tet Mesh opens its form, OK with the defaults meshes the part,
and a mapped hex mesh of a holed plate says why it does not fit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 08:46:50 -04:00
1 parent 8a4209a20c
commit e4e3c9a699
4 files changed
+91 -6

No files matched your search

+1 -1
View File
@@ -118,7 +118,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
if cancel { if cancel {
app.dialog = None; app.dialog = None;
} else if run { } else if run {
let _ = app.run_dialog(); app.submit_dialog();
} else if use_selection } else if use_selection
&& let Err(e) = app.use_selection_in_dialog() && let Err(e) = app.use_selection_in_dialog()
&& let Some(d) = app.dialog.as_mut() && let Some(d) = app.dialog.as_mut()
+71 -2
View File
@@ -106,6 +106,10 @@ fn kind_of_command(command: &str) -> Option<Kind> {
[Kind::Model, Kind::Design, Kind::Mesh].into_iter().find(|k| k.command() == command) [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.
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. /// Most entries the recent-files list keeps, and the longest path it keeps.
pub const MAX_RECENT: usize = 10; pub const MAX_RECENT: usize = 10;
const MAX_RECENT_PATH: usize = 4096; const MAX_RECENT_PATH: usize = 4096;
@@ -160,7 +164,8 @@ impl Recent {
} }
} }
/// A command running in the background on a copy of the session: a file opening, a solve. /// 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 { pub struct Loading {
/// What the window shows while it runs ("Opening beam.fieldcraft"). /// What the window shows while it runs ("Opening beam.fieldcraft").
pub title: String, pub title: String,
@@ -170,6 +175,8 @@ pub struct Loading {
recent: Option<String>, recent: Option<String>,
/// Whether it brings a different model or new geometry, which the view then frames. /// Whether it brings a different model or new geometry, which the view then frames.
fit: bool, fit: bool,
/// 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. /// The finished session copy and the command's reply.
rx: Receiver<(Box<Session>, Result<Value, String>)>, rx: Receiver<(Box<Session>, Result<Value, String>)>,
} }
@@ -277,10 +284,28 @@ impl ModelApp {
let rx = spawn(base, command, params); let rx = spawn(base, command, params);
self.set_status(format!("{title}…"), false); self.set_status(format!("{title}…"), false);
let fit = fresh || recent.is_some(); let fit = fresh || recent.is_some();
self.loading = Some(Loading { title, done, recent, fit, rx }); self.loading = Some(Loading { title, done, recent, fit, form: None, rx });
true 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.
pub fn submit_dialog(&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. /// 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. /// Without a picker service the command's form opens instead.
pub fn open_dialog(&mut self, kind: Option<Kind>) { pub fn open_dialog(&mut self, kind: Option<Kind>) {
@@ -400,6 +425,9 @@ impl ModelApp {
if let Some(p) = &l.recent { if let Some(p) = &l.recent {
self.recent.add(p); self.recent.add(p);
} }
if self.dialog.as_ref().is_some_and(|d| l.form == Some(d.command)) {
self.dialog = None;
}
let warnings: Vec<&str> = reply.get("warnings").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect(); let warnings: Vec<&str> = reply.get("warnings").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
if warnings.is_empty() { if warnings.is_empty() {
self.set_status(l.done, false); self.set_status(l.done, false);
@@ -410,6 +438,9 @@ impl ModelApp {
Some((_, Err(e))) => { Some((_, Err(e))) => {
let what = l.recent.as_deref().map_or_else(|| l.title.clone(), file_name); let what = l.recent.as_deref().map_or_else(|| l.title.clone(), file_name);
self.set_status(format!("{what}: {e}"), true); 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), None => self.set_status(format!("{}: stopped unexpectedly", l.title), true),
} }
@@ -950,6 +981,44 @@ mod tests {
assert!(empty.busy().is_none() && empty.status.as_ref().is_some_and(|s| s.error)); assert!(empty.busy().is_none() && empty.status.as_ref().is_some_and(|s| s.error));
} }
/// 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", "{}")));
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);
assert_eq!(app.status.as_ref().map(|s| (s.text.as_str(), s.error)), Some(("Generate Tet Mesh done", false)));
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");
// 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.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. /// The control channel's `ui.start` never opens a picker: it opens the form.
#[test] #[test]
fn programmatic_starts_never_open_a_picker() { fn programmatic_starts_never_open_a_picker() {
+18 -2
View File
@@ -523,8 +523,8 @@ impl ModelApp {
/// Run the open parameter form; it closes when the command succeeds. /// Run the open parameter form; it closes when the command succeeds.
pub fn run_dialog(&mut self) -> Result<Value, String> { pub fn run_dialog(&mut self) -> Result<Value, String> {
let Some(d) = self.dialog.clone() else { return Err("no command form is open".into()) }; let (command, params) = self.dialog_params()?;
let r = serde_json::from_str::<Value>(&d.text).map_err(|e| format!("the parameters are not JSON: {e}")).and_then(|p| self.run(d.command, p)); let r = self.run(command, params);
match &r { match &r {
Ok(_) => self.dialog = None, Ok(_) => self.dialog = None,
Err(e) => { Err(e) => {
@@ -536,6 +536,22 @@ impl ModelApp {
r r
} }
/// The open form's command and parameters; parameters that are not JSON are the form's error.
pub(crate) fn dialog_params(&mut self) -> Result<(&'static str, Value), String> {
let Some(d) = self.dialog.as_mut() else { return Err("no command form is open".into()) };
match serde_json::from_str::<Value>(&d.text) {
Ok(params) => {
d.error = None;
Ok((d.command, params))
}
Err(e) => {
let e = format!("the parameters are not JSON: {e}");
d.error = Some(e.clone());
Err(e)
}
}
}
/// Probe a node of the results shown (`solve.probe`) and keep its values for the viewport. /// Probe a node of the results shown (`solve.probe`) and keep its values for the viewport.
pub fn probe_node(&mut self, node: u32) -> Result<(), String> { pub fn probe_node(&mut self, node: u32) -> Result<(), String> {
let Some(view) = self.session.results_view.clone() else { return Err("no results are shown".into()) }; let Some(view) = self.session.results_view.clone() else { return Err("no results are shown".into()) };
+1 -1
View File
@@ -41,7 +41,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
| Method | Params | Notes | | Method | Params | Notes |
|---|---|---| |---|---|---|
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:<action>`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | | `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:<action>`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version |
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `<tab>/<panel>` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings | | `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `<tab>/<panel>` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings |
| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera | | `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera |
| `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) | | `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) |