From 3a023f3e25f74b2ae06d0fc8867b924e9808e7c4 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:48:27 -0400 Subject: [PATCH] ui: selecting the Mesh node shows the commands and parameters that made the mesh (#343, #371) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/browser.rs | 12 ++++++- crates/ui-egui/src/context.rs | 60 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/describe.rs | 11 +++++-- crates/ui-egui/src/files.rs | 5 +++ crates/ui-egui/src/lib.rs | 39 ++++++++++++++++++++++ 5 files changed, 123 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 41fc73e..92bb68a 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -899,8 +899,18 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec) { ); if r.eye { app.ui.show_mesh = !app.ui.show_mesh; - } else if r.fold || r.clicked { + } else if r.fold { acts.push(Act::Fold("mesh".into())); + } else if r.clicked { + // A click shows how the mesh was made (#343) and opens the node; again, deselects it. + if app.selected_mesh { + app.selected_mesh = false; + } else { + app.select_mesh(); + } + if !open(app, "mesh") { + acts.push(Act::Fold("mesh".into())); + } } if open(app, "mesh") { let r = draw_row( diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index bbe68fc..aba47da 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -21,6 +21,7 @@ pub fn has_context(app: &mut ModelApp) -> bool { || app.refused.is_some() || app.selected.is_some() || app.selected_seed.is_some() + || app.selected_mesh || app.reply.is_some() || app.plot().is_some() } @@ -59,6 +60,10 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) { seed_card(app, ui, &t, edge); ui.add_space(10.0); } + if app.selected_mesh { + mesh_card(app, ui, &t); + ui.add_space(10.0); + } if let Some(plot) = app.plot() { let mut close = false; heading(ui, &t, Level::Title, |ui| { @@ -370,6 +375,61 @@ fn seed_card(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, edge: fieldcraft } } +/// The mesh: its size, then each command that made it with its parameters (in its form's +/// words), with Edit… opening that command's form filled in (#343). +fn mesh_card(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { + let key = app.session.revision(); + let made = match &app.mesh_made { + Some((k, made)) if *k == key => made.clone(), + _ => { + let model = &app.session.model; + let made: Vec<(String, Value, Vec<(String, String)>)> = app + .mesh_history() + .into_iter() + .map(|(c, p)| { + let rows = p.as_object().map(|o| crate::describe::command_rows(model, &c, o)).unwrap_or_default(); + (c, p, rows) + }) + .collect(); + let made = std::sync::Arc::new(made); + app.mesh_made = Some((key, made.clone())); + made + } + }; + let mut deselect = false; + heading(ui, t, Level::Title, |ui| { + ui.label(RichText::new("MESH").font(label_font(ui.ctx(), 12.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + deselect = ui.small_button("×").on_hover_text("Deselect").clicked(); + }); + }); + ui.add_space(4.0); + let (nodes, elements) = (app.session.model.nodes().len(), app.session.model.elements().len()); + let size = [("Nodes".to_string(), crate::describe::quantity(nodes as f64)), ("Elements".to_string(), crate::describe::quantity(elements as f64))]; + property_grid(ui, t, "fc_mesh_size", &size); + let mut edit = None; + if made.is_empty() && elements > 0 { + ui.add_space(4.0); + ui.label(RichText::new("Not made by a mesher in this model (imported or built by hand)").size(11.5).color(t.text_dim)); + } + for (i, (command, params, rows)) in made.iter().enumerate() { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::command_label(command)).strong()); + ui.label(RichText::new(command.as_str()).size(11.0).color(t.text_dim)); + }); + property_grid(ui, t, &format!("fc_mesh_made_{i}"), rows); + if ui.button("Edit…").on_hover_text(format!("{command}: its form with these parameters")).clicked() { + edit = Some((command.clone(), params.clone())); + } + } + if deselect { + app.selected_mesh = false; + } else if let Some((command, params)) = edit { + app.open_form(&command, params); + } +} + /// Rows of (label, text) as a two-column grid. fn property_grid(ui: &mut egui::Ui, t: &Tokens, id: &str, rows: &[(String, String)]) { egui::Grid::new(id).num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| { diff --git a/crates/ui-egui/src/describe.rs b/crates/ui-egui/src/describe.rs index d02ca05..4ba0b7d 100644 --- a/crates/ui-egui/src/describe.rs +++ b/crates/ui-egui/src/describe.rs @@ -53,19 +53,24 @@ pub fn rows(model: &Model, kind: Def, name: &str) -> Option) -> Vec<(String, String)> { + let form = fieldcraft_engine::find_command(command).and_then(|spec| spec.form); let mut out = Vec::new(); let mut used = Vec::new(); if let Some(fields) = form { object(fields, obj, model.units(), &mut out, &mut used, 0); } - // What the form does not show stays readable. for (k, field) in obj { if k != "name" && !used.iter().any(|u| u == k) { out.push((sentence(k), crate::context::value_text(field))); } } - Some(out) + out } /// The seed on `edge` as (label, text) rows: the edge, then `mesh.seed.edge`'s fields. diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index d8fa066..6c22c2a 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -1606,6 +1606,11 @@ mod tests { app.wait_loaded(); assert!(app.dialog.is_none(), "{:?}", app.status); assert_eq!(app.session.model.element_counts(), counts, "replaced, not stacked"); + // The Mesh node shows the run that made it, once though it ran twice (#343). + let made = app.mesh_history(); + assert_eq!(made.len(), 1, "{made:?}"); + assert_eq!(made[0].0, "mesh.tet.generate"); + assert_eq!(made[0].1["order"], "linear"); // A plate with a hole is not one mapped block: the form stays with the reason. app.start("mesh.hex.mapped").unwrap(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6052ecb..d56e56c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -451,6 +451,9 @@ pub struct ModelApp { /// The edge seed selected in the browser (by its edge), its properties on the right; never /// together with [`ModelApp::selected`]. pub selected_seed: Option, + /// The Mesh node is selected in the browser: how the current mesh was made shows on the + /// right ([`ModelApp::mesh_history`]); never together with a definition or a seed. + pub selected_mesh: bool, /// What is picked in the viewport. pub pick: PickState, /// Set while [`ModelApp::capture`] draws the window offscreen: no pointer there is the @@ -468,6 +471,8 @@ pub struct ModelApp { element_counts: Option<(u64, Vec<(fieldcraft_model::ElementKind, usize)>)>, /// The selected definition's property rows and what they were read for. properties: Option<((u64, browser::Def, String), std::sync::Arc>)>, + /// [`ModelApp::mesh_history`] with each command's rows, by model revision. + mesh_made: Option<(u64, std::sync::Arc)>>)>, /// The search's query and its results. search: Option<(String, std::sync::Arc>)>, } @@ -536,6 +541,7 @@ impl ModelApp { tree_open: ["mesh", "results"].into_iter().map(str::to_string).collect(), selected: None, selected_seed: None, + selected_mesh: false, pick: PickState::default(), capturing: false, nav: NavMode::default(), @@ -545,6 +551,7 @@ impl ModelApp { element_counts: None, search: None, properties: None, + mesh_made: None, } } @@ -1372,14 +1379,45 @@ impl ModelApp { pub fn select_definition(&mut self, selected: Option<(browser::Def, String)>) { if selected.is_some() { self.selected_seed = None; + self.selected_mesh = false; } self.selected = selected; } + /// Select the Mesh node in the browser: how the current mesh was made, on the right. + pub fn select_mesh(&mut self) { + self.selected = None; + self.selected_seed = None; + self.selected_mesh = true; + } + + /// The meshing commands that made the current mesh with their parameters, oldest first: from + /// the journal, the latest run for each body that still has elements (a remesh replaces an + /// earlier run, a mirror adds its image). Empty for a mesh no mesher made (imported, built + /// node by node). + pub fn mesh_history(&self) -> Vec<(String, Value)> { + let mut covered: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + for (command, params) in self.session.journal.iter().rev() { + let Some((_, targets)) = fieldcraft_engine::MESHERS.iter().find(|(m, _)| m == command) else { continue }; + let Ok(bodies) = targets(&self.session, params) else { continue }; + let fresh: Vec = + bodies.into_iter().filter(|b| !covered.contains(b) && !self.session.model.body_mesh(b).elements.is_empty()).collect(); + if fresh.is_empty() { + continue; + } + covered.extend(fresh); + out.push((command.clone(), params.clone())); + } + out.reverse(); + out + } + /// Select the seed on `edge` in the browser: its properties on the right, its edge picked in /// the viewport. pub fn select_seed(&mut self, edge: fieldcraft_model::GeometryRef) { self.selected = None; + self.selected_mesh = false; self.select([selection::Item::Geometry(edge.clone())], selection::Mode::Replace); self.selected_seed = Some(edge); } @@ -1538,6 +1576,7 @@ impl ModelApp { if self.framed_document.is_some() { self.selected = None; self.selected_seed = None; + self.selected_mesh = false; self.reply = None; // Picks are by name, and the names of another document are other things. if !self.pick.selection.is_empty() {