diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 37620c6..e7bd9e5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -197,6 +197,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> }}), crate::files::Prompt::Recover => json!("recover"), crate::files::Prompt::Samples => json!("samples"), + crate::files::Prompt::Design(p) => json!({"design": p}), }), "recent": app.recent.paths, "version": crate::VERSION, @@ -305,6 +306,15 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> app.answer_unsaved(answer); ok(json!({"busy": app.busy()})) } + (crate::files::Prompt::Design(_), Some(a @ ("new" | "keep" | "cancel"))) => { + let answer = match a { + "new" => crate::files::DesignAnswer::New, + "keep" => crate::files::DesignAnswer::Keep, + _ => crate::files::DesignAnswer::Cancel, + }; + app.answer_design(answer); + ok(json!({"busy": app.busy()})) + } (crate::files::Prompt::Samples, Some("open")) => match s("name") { Some(name) => { app.prompt = None; @@ -325,7 +335,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> None => err("missing `id` (from file.recovery.list)"), }, (_, a) => err(format!( - "`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; recover: recover with id, close; samples: open with name, close)" + "`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; recover: recover with id, close; samples: open with name, close)" )), } } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 42ae3ea..fda7b4a 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -186,6 +186,16 @@ pub struct Loading { shown: bool, } +/// A finished background job whose session has not replaced the window's yet. +pub struct Opened { + session: Box, + reply: Value, + done: String, + recent: Option, + fit: bool, + form: Option<&'static str>, +} + /// What waits for an answer about unsaved changes. #[derive(Clone, Debug, PartialEq)] pub enum Replace { @@ -213,6 +223,20 @@ pub enum Prompt { Recover, /// The samples to open (`file.sample`). Samples, + /// 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), +} + +/// 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`]. @@ -444,24 +468,19 @@ impl ModelApp { let Some(l) = self.loading.take() else { return false }; match outcome { Some((session, Ok(reply))) => { - // The session it replaces may be large: it is freed off the window thread. - let old = std::mem::replace(&mut self.session, *session); - off_thread("fieldcraft drop".into(), move || drop(old)); - self.model_changed(); - if l.fit { - self.fit_view(); - } - 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); - } else { - self.set_status(format!("{} — {}", l.done, warnings.join("; ")), true); + let opened = Opened { session, reply, done: l.done, recent: l.recent, fit: l.fit, 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))) => { @@ -473,10 +492,68 @@ impl ModelApp { } 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); } - self.loading.is_some() + } + + /// 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 old = std::mem::replace(&mut self.session, *o.session); + off_thread("fieldcraft drop".into(), move || drop(old)); + self.model_changed(); + if o.fit { + self.fit_view(); + } + 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). @@ -614,6 +691,18 @@ impl ModelApp { } } +/// What a kept model has that the design does not (`geometry.open`'s `stale`), for the status: +/// empty when everything resolves. +fn stale_note(stale: &Value) -> String { + let names = |k: &str| stale.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect::>().join(", "); + let parts: Vec = [("mesh of", names("mesh_bodies")), ("sets", names("sets"))] + .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) @@ -681,6 +770,33 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { 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::Recover) => { let entries = app.recover_list(ctx); let (mut recover, mut discard, mut close) = (None, None, false); @@ -948,6 +1064,59 @@ mod tests { 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"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c20f56..c09441c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -313,6 +313,8 @@ pub struct ModelApp { pub queued: std::collections::VecDeque, /// The question the window is asking (unsaved changes, recover). pub prompt: Option, + /// A design opened over a model of another part, waiting for [`files::Prompt::Design`]'s answer. + pub held: Option, /// The window draws its own title bar and caption buttons (Windows, Linux). pub custom_titlebar: bool, /// The title bar runs under the macOS traffic lights. @@ -382,6 +384,7 @@ impl ModelApp { loading: None, queued: std::collections::VecDeque::new(), prompt: None, + held: None, custom_titlebar: false, integrated_titlebar: false, palette_text: String::new(), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 35184fd..0427298 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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, 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:`, `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}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"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:`, `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 `/` 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) | @@ -50,7 +50,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel | | `ui.select` (viewport) | any of `filter`, `box_mode`, `mode`, `clear`, `at`, `box`, `geometry`, `nodes`, `elements`, `faces` (and no `kind`) | changes the viewport's selection (see [Selection](#selection)); returns `ui.selection`'s reply | | `ui.selection` | — | the viewport's selection: `filter`, `box_mode` (box selection on), `count`, `capped`, `summary`, `items`, `params` (the selection as a set's members), `hover` (what a click would pick under the pointer) | -| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) | +| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) | | `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `cancel?` | drives the open command form, in that order; `use_selection` is the form's Target row (see [Selection](#selection)); `run` returns the command's result and closes the form on success | | `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; in the viewport a click selects what is under it (Shift adds, Ctrl/Cmd adds or removes, a click on nothing clears the selection), and a click on a results plot also probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) | | `ui.move` | `x`, `y` | |