forked from 1b-agent/fieldcraft-upstream
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:
1 parent
e955b67bb7
commit
2ffbb9b978
4 files changed
+213
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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(" `")?;
|
||||
|
||||
Reference in new issue
Block a user