diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index 108a445..cf3827d 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -32,6 +32,10 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) { app.pick.form_hover = false; app.set_hover(None); } + // A Feature Graph row hovered last frame no longer is once the graph is not shown. + if app.reply.as_ref().is_none_or(|r| r.command != "geometry.features") { + app.set_feature_hover(Vec::new()); + } if !has_context(app) { return; } diff --git a/crates/ui-egui/src/feature_graph.rs b/crates/ui-egui/src/feature_graph.rs index 6841f68..13052ab 100644 --- a/crates/ui-egui/src/feature_graph.rs +++ b/crates/ui-egui/src/feature_graph.rs @@ -279,6 +279,83 @@ pub fn describe(f: &Value) -> Feature { } } +/// The feature's glyph, drawn in `rect` with lines in `line` and the part it adds or removes in +/// `accent` (no image assets: every glyph is a few strokes). +pub fn paint_glyph(painter: &egui::Painter, rect: egui::Rect, kind: Kind, line: egui::Color32, accent: egui::Color32) { + use egui::{Shape, Stroke, pos2, vec2}; + let r = rect.shrink(rect.width() * 0.12); + let (w, h) = (r.width(), r.height()); + let at = |x: f32, y: f32| pos2(r.left() + x * w, r.top() + y * h); + let s = Stroke::new(1.3, line); + let a = Stroke::new(1.5, accent); + let arrow = |from: egui::Pos2, to: egui::Pos2| { + painter.line_segment([from, to], a); + let d = (to - from).normalized() * (w * 0.2); + let n = vec2(-d.y, d.x); + painter.add(Shape::convex_polygon(vec![to, to - d + n * 0.8, to - d - n * 0.8], accent, Stroke::NONE)); + }; + match kind { + Kind::Extrude | Kind::Cut => { + painter.rect_stroke(egui::Rect::from_min_max(at(0.0, 0.62), at(1.0, 1.0)), 1.0, s, egui::StrokeKind::Inside); + if kind == Kind::Extrude { arrow(at(0.5, 0.62), at(0.5, 0.0)) } else { arrow(at(0.5, 0.0), at(0.5, 0.6)) } + } + Kind::Revolve => { + painter.circle_stroke(r.center(), w * 0.45, s); + arrow(at(0.15, 0.25), at(0.45, 0.05)); + } + Kind::Primitive => { + painter.rect_stroke(egui::Rect::from_min_max(at(0.0, 0.25), at(0.75, 1.0)), 1.0, s, egui::StrokeKind::Inside); + painter.add(Shape::line(vec![at(0.0, 0.25), at(0.25, 0.0), at(1.0, 0.0), at(1.0, 0.75), at(0.75, 1.0)], s)); + painter.line_segment([at(0.75, 0.25), at(1.0, 0.0)], s); + } + Kind::Hole => { + painter.rect_stroke(r, 1.0, s, egui::StrokeKind::Inside); + painter.circle_stroke(r.center(), w * 0.24, a); + } + Kind::Fillet => { + painter.add(Shape::line(vec![at(0.0, 1.0), at(0.0, 0.5)], s)); + painter.add(Shape::line(vec![at(0.5, 0.0), at(1.0, 0.0)], s)); + let arc: Vec = (0..=8) + .map(|i| { + let t = std::f32::consts::FRAC_PI_2 * i as f32 / 8.0; + at(0.5 - 0.5 * t.cos(), 0.5 - 0.5 * t.sin()) + }) + .collect(); + painter.add(Shape::line(arc, a)); + } + Kind::Chamfer => { + painter.add(Shape::line(vec![at(0.0, 1.0), at(0.0, 0.45)], s)); + painter.add(Shape::line(vec![at(0.55, 0.0), at(1.0, 0.0)], s)); + painter.line_segment([at(0.0, 0.45), at(0.55, 0.0)], a); + } + Kind::Shell => { + painter.add(Shape::line(vec![at(0.0, 0.0), at(0.0, 1.0), at(1.0, 1.0), at(1.0, 0.0)], s)); + painter.add(Shape::line(vec![at(0.25, 0.0), at(0.25, 0.75), at(0.75, 0.75), at(0.75, 0.0)], a)); + } + Kind::Pattern => { + for (x, y) in [(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8)] { + painter.circle_stroke(at(x, y), w * 0.16, if (x, y) == (0.2, 0.2) { s } else { a }); + } + } + Kind::Mirror => { + let mut y = 0.0; + while y < 1.0 { + painter.line_segment([at(0.5, y), at(0.5, (y + 0.12).min(1.0))], s); + y += 0.22; + } + painter.add(Shape::convex_polygon(vec![at(0.38, 0.15), at(0.38, 0.85), at(0.0, 0.85)], egui::Color32::TRANSPARENT, s)); + painter.add(Shape::convex_polygon(vec![at(0.62, 0.15), at(0.62, 0.85), at(1.0, 0.85)], egui::Color32::TRANSPARENT, a)); + } + Kind::Boolean => { + painter.circle_stroke(at(0.36, 0.5), w * 0.34, s); + painter.circle_stroke(at(0.64, 0.5), w * 0.34, a); + } + Kind::Other => { + painter.circle_filled(r.center(), w * 0.18, accent); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c088787..0115cff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -240,6 +240,8 @@ pub struct PickState { pub selection: selection::Selection, /// What a click would pick under the pointer. pub hover: Option, + /// The faces of the feature whose row in the Feature Graph is under the pointer. + feature_hover: Vec, /// A left drag draws a selection box (nodes, element faces, elements) instead of orbiting. pub box_mode: bool, /// The box being dragged (window points). @@ -398,6 +400,8 @@ pub struct ModelApp { pub xy: Option, /// The last command reply with more to it than its status line, shown in the context panel. pub reply: Option, + /// The Feature Graph rows opened to their details: (body, feature id). + pub features_open: std::collections::BTreeSet<(String, u64)>, /// Whether results were shown last frame (the Results tab comes up when they appear). results_shown: bool, /// The framing the viewport does when it next draws ([`framing::Reframe`]). @@ -531,6 +535,7 @@ impl ModelApp { reactions: None, xy: None, reply: None, + features_open: Default::default(), results_shown: false, reframe: None, framed_document: None, @@ -1568,6 +1573,15 @@ impl ModelApp { } } + /// Highlight `items` as hovered (the faces of a Feature Graph row under the pointer; empty: + /// none). + pub fn set_feature_hover(&mut self, items: Vec) { + if self.pick.feature_hover != items { + self.pick.feature_hover = items; + self.pick.hover_generation = self.pick.hover_generation.wrapping_add(1); + } + } + /// The selection and the hover drawn over the scene in the theme's highlight colours, /// rebuilt when either or the scene changes; and its key. pub fn overlay(&mut self) -> (u64, Arc) { @@ -1632,6 +1646,12 @@ impl ModelApp { s.lines.extend(h.lines); s.points.extend(h.points); } + if !self.pick.feature_hover.is_empty() { + let items = self.pick.feature_hover.clone(); + let h = self.with_picker(|p| p.items_overlay(&items, theme::rgb(t.pick_hover))); + s.tris.extend(h.tris); + s.lines.extend(h.lines); + } self.pick.overlay = (Some(key), Arc::new(s)); (key, self.pick.overlay.1.clone()) } diff --git a/crates/ui-egui/src/reply.rs b/crates/ui-egui/src/reply.rs index 0e6b396..9d549a6 100644 --- a/crates/ui-egui/src/reply.rs +++ b/crates/ui-egui/src/reply.rs @@ -65,6 +65,15 @@ pub fn summary_in(command: &str, v: &Value, units: UnitSystem) -> String { plural(count("boundary_conditions") as u64, "boundary condition"), plural(count("loads") as u64, "load") ), + "geometry.features" => { + let bodies = v["bodies"].as_array().map(Vec::as_slice).unwrap_or_default(); + let n: usize = bodies.iter().map(|b| b["features"].as_array().map_or(0, Vec::len)).sum(); + format!( + "{} building {}", + plural(n as u64, "feature"), + if bodies.len() == 1 { "1 body".to_string() } else { format!("{} bodies", bodies.len()) } + ) + } "geometry.bodies" => match v.as_array().map(Vec::len) { Some(0) => "no bodies (open a design first)".to_string(), Some(n) => plural(n as u64, "body").replace("bodys", "bodies"), @@ -217,6 +226,10 @@ enum Act { Rerun, /// Pick a design entity in the viewport (a body, face or edge from the Bodies list). Pick(fieldcraft_model::GeometryRef), + /// Open or close a Feature Graph row's details: (body, feature id). + Open((String, u64)), + /// Select these faces in the viewport (a Feature Graph row's). + Faces(Vec), /// Select elements in the viewport and frame them (Mesh Quality's worst and invalid ones). Elements(Vec), /// Open Relax Nodes round an element (Mesh Quality's worst). @@ -260,6 +273,10 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { "geometry.bodies" => bodies(ui, t, &reply.value, &mut acts), "mesh.quality" => quality(ui, t, &reply.value, &mut acts), "geometry.defeature.propose" => defeature_list(ui, t, &reply.value, &mut acts), + "geometry.features" => { + let hovered = feature_graph(ui, t, &reply.value, &app.features_open, &mut acts); + app.set_feature_hover(hovered); + } _ => value(ui, t, egui::Id::new("fc_reply"), &reply.value, 0), } for a in acts { @@ -274,6 +291,12 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { let _ = app.run(&reply.command, reply.params.clone()); } Act::Pick(g) => app.select([crate::selection::Item::Geometry(g)], crate::selection::Mode::Replace), + Act::Open(key) => { + if !app.features_open.remove(&key) { + app.features_open.insert(key); + } + } + Act::Faces(items) => app.select(items, crate::selection::Mode::Replace), Act::Elements(labels) => app.show_elements(&labels), Act::Relax(element) => { app.show_elements(&[element]); @@ -367,6 +390,95 @@ fn defeature_list(ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec) more_than(ui, t, candidates.len(), MAX_ENTITIES); } +/// The faces of `body` named `faces`, as selection items. +fn face_items(body: &str, faces: &[String]) -> Vec { + faces + .iter() + .take(MAX_ENTITIES) + .filter_map(|f| fieldcraft_model::GeometryRef::new(body, fieldcraft_model::Entity::Face(f.clone())).ok()) + .map(crate::selection::Item::Geometry) + .collect() +} + +/// The Feature Graph: per body, its timeline's features in order, each with its glyph, name and +/// what it does in words; a click selects the faces it made and opens its details. Returns the +/// faces of the row under the pointer, to highlight. +fn feature_graph( + ui: &mut egui::Ui, + t: &Tokens, + v: &Value, + opened: &std::collections::BTreeSet<(String, u64)>, + acts: &mut Vec, +) -> Vec { + use crate::feature_graph::{describe, paint_glyph}; + let mut hovered = Vec::new(); + let bodies = v["bodies"].as_array().map(Vec::as_slice).unwrap_or_default(); + for (bi, b) in bodies.iter().enumerate() { + let body = b["body"].as_str().unwrap_or_default(); + let nodes = b["features"].as_array().map(Vec::as_slice).unwrap_or_default(); + let head = RichText::new(format!("{body} ยท {}", plural(nodes.len() as u64, "feature"))).strong(); + egui::CollapsingHeader::new(head).id_salt(("fc_features_body", bi)).default_open(true).show(ui, |ui| { + for (fi, node) in nodes.iter().take(MAX_ENTITIES).enumerate() { + let f = describe(node); + let id = ui.id().with(("fc_feature", bi, fi)); + let key = (body.to_string(), f.id); + let open = opened.contains(&key); + let bg = ui.painter().add(egui::Shape::Noop); + let inner = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 4)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal_top(|ui| { + let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), egui::Sense::hover()); + let accent = if f.error.is_some() { t.error } else { t.accent }; + paint_glyph(ui.painter(), rect, f.kind, t.icon, accent); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.add(egui::Label::new(RichText::new(&f.name).strong().color(t.text)).truncate()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(format!("F{}", f.id)).small().color(t.text_dim)); + }); + }); + ui.add(egui::Label::new(RichText::new(&f.summary).color(t.text)).wrap()); + if let Some(e) = &f.error { + ui.add(egui::Label::new(RichText::new(e).color(t.error)).wrap()); + } + if open { + ui.add_space(2.0); + ui.spacing_mut().item_spacing.y = 0.0; + ui.spacing_mut().interact_size.y = 0.0; + for (k, val) in &f.details { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + ui.label(RichText::new(k).small().color(t.text_dim)); + ui.label(RichText::new(val).small()); + }); + } + } + }); + }); + }); + let rect = inner.response.rect; + let resp = ui.interact(rect, id.with("row"), egui::Sense::click()); + let hover_text = if f.faces.is_empty() { "Show its details" } else { "Select the faces it made; show its details" }; + let resp = resp.on_hover_text(hover_text); + if resp.hovered() { + ui.painter().set(bg, egui::Shape::rect_filled(rect, 3.0, t.hover)); + hovered = face_items(body, &f.faces); + } else if open { + ui.painter().set(bg, egui::Shape::rect_filled(rect, 3.0, t.subheading_bg)); + } + if resp.clicked() { + acts.push(Act::Open(key)); + if !f.faces.is_empty() { + acts.push(Act::Faces(face_items(body, &f.faces))); + } + } + } + more_than(ui, t, nodes.len(), MAX_ENTITIES); + }); + } + hovered +} + /// The definition a diagnostic's subject names (``section `web` ``), when the model has it. fn subject_definition(app: &ModelApp, subject: &str) -> Option<(Def, String)> { let (kind, rest) = subject.split_once(" `")?;