Feature graph panel: features per body in words, click selects their faces, hover highlights them

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-17 00:50:52 -04:00
1 parent e955b67bb7
commit 2ffbb9b978
4 files changed
+213

No files matched your search

+4
View File
@@ -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;
}
+77
View File
@@ -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<egui::Pos2> = (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::*;
+20
View File
@@ -240,6 +240,8 @@ pub struct PickState {
pub selection: selection::Selection,
/// What a click would pick under the pointer.
pub hover: Option<selection::Item>,
/// The faces of the feature whose row in the Feature Graph is under the pointer.
feature_hover: Vec<selection::Item>,
/// 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<XyPlot>,
/// The last command reply with more to it than its status line, shown in the context panel.
pub reply: Option<reply::Reply>,
/// 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<selection::Item>) {
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<Scene>) {
@@ -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())
}
+112
View File
@@ -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<crate::selection::Item>),
/// Select elements in the viewport and frame them (Mesh Quality's worst and invalid ones).
Elements(Vec<u32>),
/// 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<Act>)
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<crate::selection::Item> {
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<Act>,
) -> Vec<crate::selection::Item> {
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(" `")?;