From 910d437819593b6aa8285ed6e962393337c449df Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:29:57 -0400 Subject: [PATCH] App: ask before a mesher replaces a body's mesh The form's OK on a mesher whose body already has a mesh opens a Replace mesh prompt ("CrankArm already has a mesh (7,274 hex20). Replace it?"), naming sets that list the old labels and results that will be cleared. Replace runs the form; Cancel leaves it open. The control channel's ui.dialog submit is the OK button, ui.prompt answers replace or cancel. Programmatic calls never ask. Fixes #267. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/control.rs | 13 ++++++- crates/ui-egui/src/files.rs | 70 +++++++++++++++++++++++++++++++++++ docs/control-protocol.md | 4 +- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 091e441..b6eee53 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -199,6 +199,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}), + crate::files::Prompt::ReplaceMesh(r) => json!({"replace_mesh": r.question, "warnings": r.warnings}), }), "recent": app.recent.paths, "version": crate::VERSION, @@ -293,6 +294,12 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> if b("run") { return wrap(app.run_dialog()); } + // The form's OK button, as a click runs it: a mesher that would replace a mesh asks + // first (`ui.inspect`'s `prompt`, answered with `ui.prompt`). + if b("submit") { + app.submit_dialog(); + return ok(json!({"prompt": app.prompt.is_some(), "busy": app.busy()})); + } ok(json!(app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error})))) } "ui.prompt" => { @@ -316,6 +323,10 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> app.answer_design(answer); ok(json!({"busy": app.busy()})) } + (crate::files::Prompt::ReplaceMesh(_), Some(a @ ("replace" | "cancel"))) => { + app.answer_replace_mesh(a == "replace"); + ok(json!({"busy": app.busy()})) + } (crate::files::Prompt::Samples, Some("open")) => match s("name") { Some(name) => { app.prompt = None; @@ -336,7 +347,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; design: new, keep, 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; replace_mesh: replace, 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 d44b2b7..e11250d 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -263,6 +263,9 @@ pub enum Prompt { 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), @@ -377,7 +380,29 @@ impl ModelApp { /// 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(); @@ -843,6 +868,32 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { app.answer_design(a); } } + 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); @@ -1301,9 +1352,28 @@ mod tests { 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}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d955c9b..a9c5c67 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,8 +50,8 @@ 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 `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; a form opened by another form's **Create …** button (a reference field with nothing to pick) goes back to that form on `run` (with the new item picked) or `cancel` | +| `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 `replace_mesh` `replace` (runs the form, replacing the mesh) or `cancel` (the form stays open); 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?`, `submit?`, `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; `submit` is the form's OK button: a mesher that would replace a body's mesh first asks (`prompt` `replace_mesh`, with the question and what else goes: sets listing the old labels, solved results), and meshers run in the background (`busy`); a form opened by another form's **Create …** button (a reference field with nothing to pick) goes back to that form on `run` (with the new item picked) or `cancel` | | `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` | | | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` (1–200) | press, move, release: in the viewport a left drag orbits (with box selection on, it selects the visible nodes, element faces or elements in the box), a right or middle drag (or Shift + left) pans |