forked from 1b-agent/fieldcraft-upstream
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
8a4209a20c
commit
e4e3c9a699
4 files changed
+91
-6
No files matched your search
@@ -118,7 +118,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if cancel {
|
||||
app.dialog = None;
|
||||
} else if run {
|
||||
let _ = app.run_dialog();
|
||||
app.submit_dialog();
|
||||
} else if use_selection
|
||||
&& let Err(e) = app.use_selection_in_dialog()
|
||||
&& let Some(d) = app.dialog.as_mut()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub const MAX_RECENT: usize = 10;
|
||||
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 {
|
||||
/// What the window shows while it runs ("Opening beam.fieldcraft").
|
||||
pub title: String,
|
||||
@@ -170,6 +175,8 @@ pub struct Loading {
|
||||
recent: Option<String>,
|
||||
/// Whether it brings a different model or new geometry, which the view then frames.
|
||||
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.
|
||||
rx: Receiver<(Box<Session>, Result<Value, String>)>,
|
||||
}
|
||||
@@ -277,10 +284,28 @@ impl ModelApp {
|
||||
let rx = spawn(base, command, params);
|
||||
self.set_status(format!("{title}…"), false);
|
||||
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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Without a picker service the command's form opens instead.
|
||||
pub fn open_dialog(&mut self, kind: Option<Kind>) {
|
||||
@@ -400,6 +425,9 @@ impl ModelApp {
|
||||
if let Some(p) = &l.recent {
|
||||
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();
|
||||
if warnings.is_empty() {
|
||||
self.set_status(l.done, false);
|
||||
@@ -410,6 +438,9 @@ impl ModelApp {
|
||||
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),
|
||||
}
|
||||
@@ -950,6 +981,44 @@ mod tests {
|
||||
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.
|
||||
#[test]
|
||||
fn programmatic_starts_never_open_a_picker() {
|
||||
|
||||
@@ -523,8 +523,8 @@ impl ModelApp {
|
||||
|
||||
/// Run the open parameter form; it closes when the command succeeds.
|
||||
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 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 (command, params) = self.dialog_params()?;
|
||||
let r = self.run(command, params);
|
||||
match &r {
|
||||
Ok(_) => self.dialog = None,
|
||||
Err(e) => {
|
||||
@@ -536,6 +536,22 @@ impl ModelApp {
|
||||
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.
|
||||
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()) };
|
||||
|
||||
@@ -41,7 +41,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
|
||||
|
||||
| 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.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) |
|
||||
|
||||
Reference in new issue
Block a user