From de8cdd0b037a40d71c3ebc7d66b42f8c3c042dfb Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:31:57 -0400 Subject: [PATCH] ui: a selected set is highlighted in the viewport; seeds are selected, described, edited and deleted from the browser (#336, #352) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/browser.rs | 62 +++++++++++++------------- crates/ui-egui/src/context.rs | 57 ++++++++++++++++++++---- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/describe.rs | 12 +++++ crates/ui-egui/src/lib.rs | 59 ++++++++++++++++++++++++- crates/ui-egui/src/menu.rs | 77 +++++++++++++++++++++++++++++---- crates/ui-egui/src/selection.rs | 7 +++ 7 files changed, 226 insertions(+), 50 deletions(-) diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index bffc1d8..2846a63 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -549,17 +549,16 @@ fn item_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, mut row: Row, t if let Some(item) = target.item() { row.eye = Some(app.session.shows(item.kind, &item.name)); } - if let Target::Def(kind, name) = &target { - row.selected = app.selected.as_ref().is_some_and(|(k, n)| k == kind && n == name); + match &target { + Target::Def(kind, name) => row.selected = app.selected.as_ref().is_some_and(|(k, n)| k == kind && n == name), + Target::Seed(edge) => row.selected = app.selected_seed.as_ref() == Some(edge), + Target::Body(_) | Target::Surface(_) => {} } let r = draw_row(app, ui, id, &row); // A right click selects the row its menu acts on (another item's highlight would say the // menu is about that one). if r.response.as_ref().is_some_and(egui::Response::secondary_clicked) { - acts.push(Act::Focus(match &target { - Target::Def(kind, name) => Some((*kind, name.clone())), - _ => None, - })); + acts.push(Act::Focus(target.clone())); } if let Some(resp) = &r.response { resp.context_menu(|ui| { @@ -570,11 +569,13 @@ fn item_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, mut row: Row, t } if r.eye { acts.push(Act::Menu(target, if row.eye == Some(true) { Action::Hide } else { Action::Show })); - } else if let Target::Def(kind, name) = target { - if r.double { - acts.push(Act::Edit(kind, name)); - } else if r.clicked { - acts.push(Act::Select(kind, name)); + } else if r.double && matches!(target, Target::Def(..) | Target::Seed(_)) { + acts.push(Act::Menu(target, Action::Edit)); + } else if r.clicked { + match target { + Target::Def(kind, name) => acts.push(Act::Select(kind, name)), + Target::Seed(edge) => acts.push(Act::SelectSeed(edge)), + Target::Body(_) | Target::Surface(_) => {} } } } @@ -639,9 +640,10 @@ fn draw_ghost(app: &mut ModelApp, ui: &mut egui::Ui, s: &StageState) -> bool { enum Act { Fold(String), Select(Def, String), - /// Select this definition (nothing for an item that is none), as a right click does. - Focus(Option<(Def, String)>), - Edit(Def, String), + /// Select (or deselect) the seed on this edge. + SelectSeed(fieldcraft_model::GeometryRef), + /// Select the item a right click opened its menu on (nothing for an item that cannot be). + Focus(Target), ShowResult(String), /// A ghost row's command. Start(&'static str), @@ -688,15 +690,21 @@ pub fn browser(app: &mut ModelApp, ui: &mut egui::Ui) { } } Act::Select(kind, name) => { - app.selected = if app.selected.as_ref() == Some(&(kind, name.clone())) { None } else { Some((kind, name)) }; + let again = app.selected.as_ref() == Some(&(kind, name.clone())); + app.select_definition((!again).then_some((kind, name))); } - Act::Focus(selected) => app.selected = selected, - Act::Edit(kind, name) => { - if let Some((command, params)) = kind.edit(&app.session.model, &name) { - app.selected = Some((kind, name)); - app.open_form(&command, params); + Act::SelectSeed(edge) => { + if app.selected_seed.as_ref() == Some(&edge) { + app.selected_seed = None; + } else { + app.select_seed(edge); } } + Act::Focus(target) => match target { + Target::Def(kind, name) => app.select_definition(Some((kind, name))), + Target::Seed(edge) => app.select_seed(edge), + Target::Body(_) | Target::Surface(_) => app.select_definition(None), + }, Act::Menu(target, action) => menu::apply(app, target, action), Act::ShowResult(step) => { // Errors show in the status bar. @@ -856,7 +864,8 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec) { &Row { depth: 2, swatch: Some(t.element(kind)), label: kind.name(), note: Some(format!("{n}")), ..Default::default() }, ); } - // Edge seeds: a click selects the seeded edge in the viewport. + // Edge seeds: a click selects the seed (its properties on the right) and its edge in the + // viewport; a double click or the menu edits or deletes it. let seeds: Vec<(fieldcraft_model::GeometryRef, String)> = app.session.model.seeds().iter().map(|s| (s.edge.clone(), seed_note(s))).collect(); if !seeds.is_empty() { let r = draw_row( @@ -878,15 +887,8 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec) { if open(app, "seeds") { for (i, (edge, note)) in seeds.into_iter().enumerate() { let label = format!("{} · {}", edge.body, edge.entity.name().unwrap_or_default()); - let r = draw_row( - app, - ui, - egui::Id::new(("fc_row_seed", i)), - &Row { depth: 3, label: &label, note: Some(note), ..Default::default() }, - ); - if r.clicked { - app.select([crate::selection::Item::Geometry(edge)], crate::selection::Mode::Replace); - } + let row = Row { depth: 3, icon: Some("mesh.seed.edge"), label: &label, note: Some(note), ..Default::default() }; + item_row(app, ui, egui::Id::new(("fc_row_seed", i)), row, Target::Seed(edge), acts); } } } diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index fcbd03d..031f0a9 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -17,7 +17,7 @@ pub const WIDTH: f32 = 310.0; /// Whether the panel has anything to show. pub fn has_context(app: &mut ModelApp) -> bool { - app.dialog.is_some() || app.selected.is_some() || app.reply.is_some() || app.plot().is_some() + app.dialog.is_some() || app.selected.is_some() || app.selected_seed.is_some() || app.reply.is_some() || app.plot().is_some() } pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) { @@ -41,6 +41,10 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) { properties(app, ui, &t, kind, &name); ui.add_space(10.0); } + if let Some(edge) = app.selected_seed.clone() { + seed_card(app, ui, &t, edge); + ui.add_space(10.0); + } if let Some(plot) = app.plot() { let mut close = false; heading(ui, &t, Level::Title, |ui| { @@ -252,13 +256,7 @@ fn properties(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, kind: Def, name }); }); ui.add_space(4.0); - egui::Grid::new("fc_properties").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| { - for (k, text) in rows.iter() { - ui.label(RichText::new(k).color(t.text_dim)); - ui.add(egui::Label::new(text).wrap()); - ui.end_row(); - } - }); + property_grid(ui, t, "fc_properties", &rows); ui.add_space(6.0); let (mut edit, mut delete) = (false, false); let editing = kind.edit_command(&app.session.model, name); @@ -269,7 +267,7 @@ fn properties(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, kind: Def, name delete = ui.button("Delete…").on_hover_text(format!("{} (Delete key)", kind.command("delete"))).clicked(); }); if deselect { - app.selected = None; + app.select_definition(None); } else if edit && let Some((command, params)) = kind.edit(&app.session.model, name) { app.open_form(&command, params); } else if delete { @@ -277,6 +275,47 @@ fn properties(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, kind: Def, name } } +/// The selected seed: its edge, divisions, bias and constraint, with Edit (the Seed Edges form, +/// filled in) and Delete (`mesh.seed.clear`). +fn seed_card(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, edge: fieldcraft_model::GeometryRef) { + let Some(seed) = app.seed(&edge) else { return }; + let rows = crate::describe::seed_rows(&app.session.model, seed); + let mut deselect = false; + heading(ui, t, Level::Title, |ui| { + ui.label(RichText::new("SEED").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); + property_grid(ui, t, "fc_seed_properties", &rows); + ui.add_space(6.0); + let (mut edit, mut delete) = (false, false); + ui.horizontal(|ui| { + edit = ui.button("Edit…").on_hover_text("mesh.seed.edge").clicked(); + delete = ui.button("Delete").on_hover_text("mesh.seed.clear (undo brings it back)").clicked(); + }); + let target = crate::menu::Target::Seed(edge); + if deselect { + app.selected_seed = None; + } else if edit { + crate::menu::apply(app, target, crate::menu::Action::Edit); + } else if delete { + crate::menu::apply(app, target, crate::menu::Action::Delete); + } +} + +/// 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| { + for (k, text) in rows { + ui.label(RichText::new(k).color(t.text_dim)); + ui.add(egui::Label::new(text).wrap()); + ui.end_row(); + } + }); +} + /// A field's value as short text: numbers and strings as they are, long lists as their length /// and first items, other lists and objects as compact JSON (long ones shortened). pub(crate) fn value_text(v: &Value) -> String { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6990958..5e3d926 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -202,7 +202,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> "probe": app.probe, "busy": app.busy(), "solve": solve_json(app), - "selected": app.selected.as_ref().map(|(k, n)| json!({"kind": k, "name": n})), + "selected": app.selected.as_ref().map(|(k, n)| json!({"kind": k, "name": n})).or_else(|| app.selected_seed.as_ref().map(|e| json!({"kind": "seed", "edge": e}))), "browser": crate::browser::describe(app), "nav": app.nav, "layout": app.layout.iter().map(|(k, r)| (k.to_string(), json!([r.left(), r.top(), r.width(), r.height()]))).collect::>(), diff --git a/crates/ui-egui/src/describe.rs b/crates/ui-egui/src/describe.rs index d477bcd..3951bb9 100644 --- a/crates/ui-egui/src/describe.rs +++ b/crates/ui-egui/src/describe.rs @@ -68,6 +68,18 @@ pub fn rows(model: &Model, kind: Def, name: &str) -> Option Vec<(String, String)> { + let mut out = vec![("Edge".to_string(), format!("{} · {}", seed.edge.body, seed.edge.entity.name().unwrap_or_default()))]; + let params = crate::menu::seed_params(seed).unwrap_or_default(); + if let (Some(fields), Some(obj)) = (fieldcraft_engine::find_command("mesh.seed.edge").and_then(|s| s.form), params.as_object()) { + let mut obj = obj.clone(); + obj.remove("edges"); + object(fields, &obj, model.units(), &mut out, &mut Vec::new(), 0); + } + out +} + /// "contact_pressure" → "Contact pressure". fn sentence(key: &str) -> String { let s = key.replace('_', " "); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 149092f..64d5c3d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -421,6 +421,9 @@ pub struct ModelApp { pub tree_open: std::collections::BTreeSet, /// The definition selected in the browser: (kind, name), its properties on the right. pub selected: Option<(browser::Def, String)>, + /// The edge seed selected in the browser (by its edge), its properties on the right; never + /// together with [`ModelApp::selected`]. + pub selected_seed: Option, /// What is picked in the viewport. pub pick: PickState, /// Set while [`ModelApp::capture`] draws the window offscreen: no pointer there is the @@ -501,6 +504,7 @@ impl ModelApp { command_line: String::new(), tree_open: ["mesh", "results"].into_iter().map(str::to_string).collect(), selected: None, + selected_seed: None, pick: PickState::default(), capturing: false, nav: NavMode::default(), @@ -1206,7 +1210,8 @@ impl ModelApp { h.finish() }; let scene = self.scene_key(); - let selected = hash(&|h| (scene, self.pick.generation).hash(h)); + let set = self.selected_set(); + let selected = hash(&|h| (scene, self.pick.generation, &set).hash(h)); let key = hash(&|h| (selected, self.pick.hover_generation).hash(h)); if self.pick.overlay.0 == Some(key) { return (key, self.pick.overlay.1.clone()); @@ -1215,8 +1220,16 @@ impl ModelApp { // The selection's part is kept while only the hover changes. if self.pick.selected_overlay.0 != Some(selected) { let sel = std::mem::take(&mut self.pick.selection); - let s = self.with_picker(|p| p.overlay(&sel, None, theme::rgb(t.pick_selected), theme::rgb(t.pick_hover))); + let mut s = self.with_picker(|p| p.overlay(&sel, None, theme::rgb(t.pick_selected), theme::rgb(t.pick_hover))); self.pick.selection = sel; + // The set selected in the browser: its members in the highlight of selected symbols. + if let Some(items) = set.as_ref().and_then(|name| self.set_items(name)) { + let highlight = t.glyph_colors().highlight; + let m = self.with_picker(|p| p.items_overlay(&items, highlight)); + s.tris.extend(m.tris); + s.lines.extend(m.lines); + s.points.extend(m.points); + } self.pick.selected_overlay = (Some(selected), Arc::new(s)); } let mut s = (*self.pick.selected_overlay.1).clone(); @@ -1232,6 +1245,44 @@ impl ModelApp { (key, self.pick.overlay.1.clone()) } + /// The seed on `edge`, if it has one. + pub fn seed(&self, edge: &fieldcraft_model::GeometryRef) -> Option<&fieldcraft_model::Seed> { + self.session.model.seeds().iter().find(|s| s.edge == *edge) + } + + /// Select (or with `None` deselect) a definition in the browser, as a click on its row does. + pub fn select_definition(&mut self, selected: Option<(browser::Def, String)>) { + if selected.is_some() { + self.selected_seed = None; + } + self.selected = selected; + } + + /// 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.select([selection::Item::Geometry(edge.clone())], selection::Mode::Replace); + self.selected_seed = Some(edge); + } + + /// The set selected in the browser, if a set is. + fn selected_set(&self) -> Option { + match &self.selected { + Some((browser::Def::Set, name)) => Some(name.clone()), + _ => None, + } + } + + /// Set `name`'s members as viewport items (design entities by name, mesh items by label). + fn set_items(&self, name: &str) -> Option> { + let set = self.session.model.sets().get(name)?; + let items = set.geometry.iter().cloned().map(selection::Item::Geometry); + let items = items.chain(set.nodes.iter().map(|n| selection::Item::Node(*n))); + let items = items.chain(set.elements.iter().map(|e| selection::Item::Element(*e))); + Some(items.chain(set.faces.iter().map(|f| selection::Item::Face(*f))).collect()) + } + /// Drop selected nodes, elements and element faces the model no longer has (after an edit, /// an undo or a remesh); geometry stays selected by name. fn check_selection(&mut self) { @@ -1351,6 +1402,7 @@ impl ModelApp { if self.framed_document != Some(self.session.opened()) { if self.framed_document.is_some() { self.selected = None; + self.selected_seed = None; self.reply = None; } self.framed_document = Some(self.session.opened()); @@ -1419,6 +1471,9 @@ impl ModelApp { if self.selected.as_ref().is_some_and(|(kind, name)| !kind.exists(&self.session.model, name)) { self.selected = None; } + if self.selected_seed.as_ref().is_some_and(|edge| self.seed(edge).is_none()) { + self.selected_seed = None; + } if self.session.results_view.as_ref().is_some_and(|v| v.animate) { ctx.request_repaint_after(std::time::Duration::from_secs_f64(results::PERIOD / results::FRAMES as f64)); } diff --git a/crates/ui-egui/src/menu.rs b/crates/ui-egui/src/menu.rs index 8b9e317..94317ed 100644 --- a/crates/ui-egui/src/menu.rs +++ b/crates/ui-egui/src/menu.rs @@ -1,7 +1,8 @@ //! The item menu: what a right click on a browser row offers, and on a datum or a partition's cut //! face in the viewport. Edit… opens the item's edit form, Hide/Show and Isolate change the view //! (`view.*`), Active in step switches a boundary condition or load on or off per step -//! (`model.step.activate`), and Delete… asks first ([`crate::files::Prompt::Delete`]): it lists +//! (`model.step.activate`), and Delete… asks first (a seed's Delete clears it at once: undo brings +//! it back) ([`crate::files::Prompt::Delete`]): it lists //! the definitions that use the item, and refuses, or for a datum deletes its partitions with it. //! Every entry runs the same command as the ribbon, the forms and scripts. @@ -20,6 +21,8 @@ pub enum Target { Surface(String), /// A model definition, datum or partition. Def(Def, String), + /// The seed on a design edge. + Seed(fieldcraft_model::GeometryRef), } impl Target { @@ -29,6 +32,7 @@ impl Target { Target::Body(n) => (ItemKind::Body, n), Target::Surface(n) => (ItemKind::Surface, n), Target::Def(def, n) => (def.item_kind()?, n), + Target::Seed(_) => return None, }; Some(ViewItem { kind, name: name.clone() }) } @@ -73,6 +77,9 @@ pub fn show(app: &ModelApp, ui: &mut egui::Ui, target: &Target) -> Option Option { - if let Target::Def(def, name) = target - && let Some((command, params)) = def.edit(&app.session.model, &name) + if let Target::Def(def, name) = &target + && let Some((command, params)) = def.edit(&app.session.model, name) { - app.selected = Some((def, name)); + app.select_definition(Some((*def, name.clone()))); app.open_form(&command, params); } + if let Target::Seed(edge) = target + && let Some(params) = app.seed(&edge).and_then(seed_params) + { + app.select_seed(edge); + app.open_form("mesh.seed.edge", params); + } Ok(Value::Null) } Action::Hide => app.run("view.hide", items(&target)), @@ -134,15 +153,26 @@ pub fn apply(app: &mut ModelApp, target: Target, action: Action) { } _ => Ok(Value::Null), }, - Action::Delete => { - if let Target::Def(def, name) = target { + Action::Delete => match target { + Target::Def(def, name) => { app.ask_delete(def, &name); + Ok(Value::Null) } - Ok(Value::Null) - } + Target::Seed(edge) => app.run("mesh.seed.clear", json!({"edges": [edge]})), + Target::Body(_) | Target::Surface(_) => Ok(Value::Null), + }, }; } +/// `mesh.seed.edge`'s parameters that make `seed` as it is: its edit form, filled in. +pub fn seed_params(seed: &fieldcraft_model::Seed) -> Option { + let mut v = serde_json::to_value(seed).ok()?; + let o = v.as_object_mut()?; + let edge = o.remove("edge")?; + o.insert("edges".into(), json!([edge])); + Some(v) +} + /// The question a deletion asks ([`crate::files::Prompt::Delete`]). #[derive(Clone, Debug, PartialEq, Eq)] pub struct DeleteAsk { @@ -281,6 +311,37 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A seed is selected, described, edited and deleted from the browser like a definition + /// (#352). + #[test] + fn seeds_are_selected_edited_and_deleted() { + let (mut app, dir) = plate(); + let bodies = app.run("geometry.bodies", json!({})).unwrap(); + let edge = json!({"body": bodies[0]["name"], "edge": bodies[0]["edges"][0]["name"]}); + app.run("mesh.seed.edge", json!({"edges": [edge], "count": 6, "bias": {"ratio": 4, "toward": "end"}})).unwrap(); + let seed = app.session.model.seeds()[0].clone(); + app.select_seed(seed.edge.clone()); + assert!(app.selected.is_none() && crate::context::has_context(&mut app)); + assert_eq!(app.pick.selection.len(), 1, "its edge is picked"); + let rows = crate::describe::seed_rows(&app.session.model, &seed); + let text: Vec = rows.iter().map(|(k, v)| format!("{k}: {v}")).collect(); + assert!(text.iter().any(|r| r.contains('6')) && text.iter().any(|r| r.contains('4')), "{text:?}"); + assert!(!text.iter().any(|r| r.contains('{')), "{text:?}"); + apply(&mut app, Target::Seed(seed.edge.clone()), Action::Edit); + let d = app.dialog.as_ref().unwrap(); + assert_eq!(d.command, "mesh.seed.edge"); + let params: Value = serde_json::from_str(&d.text).unwrap(); + assert_eq!((params["count"].clone(), params["edges"][0]["body"].clone()), (json!(6), bodies[0]["name"].clone()), "{params}"); + app.close_dialog(); + apply(&mut app, Target::Seed(seed.edge.clone()), Action::Delete); + assert!(app.session.model.seeds().is_empty()); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + assert!(app.selected_seed.is_none(), "a deleted seed is no longer selected"); + let _ = std::fs::remove_dir_all(&dir); + } + /// Delete asks first: a datum a partition uses names it and deletes both on confirmation; /// a definition in use elsewhere is refused. #[test] diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs index a7b3786..8bdd62b 100644 --- a/crates/ui-egui/src/selection.rs +++ b/crates/ui-egui/src/selection.rs @@ -596,6 +596,13 @@ impl Picker<'_> { s } + /// `items` (a set's members) drawn over the scene in `color`. + pub fn items_overlay(&self, items: &[Item], color: Rgb) -> Scene { + let mut s = Scene::default(); + self.mark(&Marks::of(self, items.iter()), color, &mut s); + s + } + /// The hovered item drawn over the scene in `color`. With `fill` off (while result contours /// are shown) its faces are left out and only their outline, edges and nodes are drawn, so /// the hover never hides the colours under the pointer.