From 27af5e19b7ee322d025a9575c65c72068b5625cd Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:33:27 -0400 Subject: [PATCH] ui: the Bodies list by name with sizes, faces and edges; journal deletions and edits badged, tooltips in words (#345, #359) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/reply.rs | 105 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/timeline.rs | 87 +++++++++++++++++++++++---- 2 files changed, 182 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/reply.rs b/crates/ui-egui/src/reply.rs index d64f692..16c7e8c 100644 --- a/crates/ui-egui/src/reply.rs +++ b/crates/ui-egui/src/reply.rs @@ -193,6 +193,8 @@ enum Act { Start(&'static str), Tab(Tab), Rerun, + /// Pick a design entity in the viewport (a body, face or edge from the Bodies list). + Pick(fieldcraft_model::GeometryRef), } /// The reply's section of the context panel. @@ -216,6 +218,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { match reply.command.as_str() { "model.check" => diagnostics(app, ui, t, &reply.value, &mut acts), "model.activity" => activity(ui, t, &reply.value), + "geometry.bodies" => bodies(ui, t, &reply.value, &mut acts), _ => value(ui, t, egui::Id::new("fc_reply"), &reply.value, 0), } for a in acts { @@ -229,6 +232,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { Act::Rerun => { let _ = app.run(&reply.command, reply.params.clone()); } + Act::Pick(g) => app.select([crate::selection::Item::Geometry(g)], crate::selection::Mode::Replace), } } } @@ -295,6 +299,107 @@ fn diagnostics(app: &ModelApp, ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: & } } +/// Faces or edges listed under a body at most. +const MAX_ENTITIES: usize = 500; + +/// Geometry › Bodies: one entry per body, its name first, then volume, area, face and edge counts +/// and its size; a click on the name picks the body, and its faces and edges unfold, by +/// persistent name (edges with their lengths), each picked by a click: the names the seed and set +/// forms ask for. Design geometry is in millimetres. +fn bodies(ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec) { + let q = crate::describe::quantity; + let pick = |body: &str, entity: fieldcraft_model::Entity| fieldcraft_model::GeometryRef { body: body.to_string(), entity }; + for (i, b) in v.as_array().into_iter().flatten().enumerate() { + let name = b["name"].as_str().unwrap_or_default(); + let faces = b["faces"].as_array().map(Vec::as_slice).unwrap_or_default(); + let edges = b["edges"].as_array().map(Vec::as_slice).unwrap_or_default(); + let corner = |k: usize| -> Option<[f64; 3]> { serde_json::from_value(b["bbox"][k].clone()).ok() }; + let size = + corner(0).zip(corner(1)).map(|(lo, hi)| format!("{} × {} × {} mm", q(sig(hi[0] - lo[0])), q(sig(hi[1] - lo[1])), q(sig(hi[2] - lo[2])))); + let id = egui::Id::new(("fc_reply_body", i)); + if ui.link(RichText::new(name).strong()).on_hover_text("Pick this body in the viewport").clicked() { + acts.push(Act::Pick(pick(name, fieldcraft_model::Entity::Body))); + } + egui::Grid::new(id.with("grid")).num_columns(2).spacing(vec2(10.0, 2.0)).show(ui, |ui| { + let mut row = |k: &str, text: String| { + ui.label(RichText::new(k).color(t.text_dim)); + ui.label(text); + ui.end_row(); + }; + if let Some(v) = b["volume"].as_f64() { + row("Volume", format!("{} mm³", q(sig(v)))); + } + if let Some(a) = b["area"].as_f64() { + row("Area", format!("{} mm²", q(sig(a)))); + } + row("Faces, edges", format!("{}, {}", faces.len(), edges.len())); + if let Some(size) = size { + row("Size", size); + } + }); + body_list(ui, t, id.with("faces"), "Faces", faces, &|f| (f["name"].as_str().unwrap_or_default().to_string(), None), name, acts); + body_list( + ui, + t, + id.with("edges"), + "Edges", + edges, + &|e| (e["name"].as_str().unwrap_or_default().to_string(), e["length"].as_f64()), + name, + acts, + ); + ui.separator(); + } +} + +/// A body's faces or edges, folded: each name picks it in the viewport (edges with their length). +#[allow(clippy::too_many_arguments)] +fn body_list( + ui: &mut egui::Ui, + t: &Tokens, + id: egui::Id, + title: &str, + items: &[Value], + read: &dyn Fn(&Value) -> (String, Option), + body: &str, + acts: &mut Vec, +) { + if items.is_empty() { + return; + } + egui::CollapsingHeader::new(RichText::new(format!("{title} ({})", items.len())).size(12.0)).id_salt(id).show(ui, |ui| { + for item in items.iter().take(MAX_ENTITIES) { + let (name, length) = read(item); + ui.horizontal(|ui| { + if ui.link(&name).on_hover_text("Pick it in the viewport").clicked() { + let entity = + if title == "Edges" { fieldcraft_model::Entity::Edge(name.clone()) } else { fieldcraft_model::Entity::Face(name.clone()) }; + acts.push(Act::Pick(fieldcraft_model::GeometryRef { body: body.to_string(), entity })); + } + if let Some(l) = length { + ui.label(RichText::new(format!("{} mm", crate::describe::quantity(sig(l)))).color(t.text_dim)); + } + }); + } + more_than(ui, t, items.len(), MAX_ENTITIES); + }); +} + +/// `v` to 3 significant figures. +fn sig(v: f64) -> f64 { + if v == 0.0 || !v.is_finite() { + return v; + } + let scale = 10f64.powi(2 - v.abs().log10().floor() as i32); + (v * scale).round() / scale +} + +fn more_than(ui: &mut egui::Ui, t: &Tokens, n: usize, max: usize) { + if n > max { + ui.label(RichText::new(format!("… {} more", n - max)).small().color(t.text_dim)); + } +} + /// The Step Table: each boundary condition and load against the steps, ticked where active. fn activity(ui: &mut egui::Ui, t: &Tokens, v: &Value) { let steps: Vec<&str> = v["steps"].as_array().into_iter().flatten().filter_map(|s| s["name"].as_str()).collect(); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 8a35629..765204e 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -82,15 +82,63 @@ impl ModelApp { } } -/// Text for an item's tooltip: the command, how many, and its first parameters. -fn tip(item: &Item, reachable: bool) -> String { - let label = crate::command_label(&item.command); - let mut s = if item.count() > 1 { format!("{label} × {}", item.count()) } else { label.to_string() }; - let params = item.params.to_string(); - if params != "{}" { - let short: String = params.chars().take(160).collect(); - s.push_str(&format!("\n{}{short}{}", if item.count() > 1 { "first: " } else { "" }, if params.chars().count() > 160 { "…" } else { "" })); +/// What an item's commands did, in words: "Delete reference point RP1", "Add Node × 3", "Open +/// Sample cantilever". Never the parameters' JSON. +fn describe(item: &Item) -> String { + let name = item.params.get("name").and_then(Value::as_str); + let def = crate::browser::Def::ALL + .into_iter() + .find_map(|kind| ["create", "edit", "delete"].into_iter().find(|verb| kind.command(verb) == item.command).map(|verb| (kind, verb))); + let text = match (def, name) { + (Some((kind, verb)), Some(name)) => { + let did = match verb { + "create" => "Add", + "edit" => "Edit", + _ => "Delete", + }; + format!("{did} {} {name}", kind.label().to_lowercase()) + } + (_, Some(name)) => format!("{} {name}", crate::command_label(&item.command)), + _ => crate::command_label(&item.command).to_string(), + }; + if item.count() > 1 { format!("{text} × {}", item.count()) } else { text } +} + +/// What a command does to the item its icon shows, when it is not making it: deleting or changing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Badge { + Delete, + Edit, +} + +fn badge(command: &str) -> Option { + let verb = command.rsplit('.').next()?; + match verb { + "delete" | "clear" => Some(Badge::Delete), + "edit" => Some(Badge::Edit), + _ => None, } +} + +/// The badge at an item's top-left corner: a minus on the error colour for a deletion, a pencil +/// stroke on the accent for an edit. +fn paint_badge(p: &egui::Painter, item: Rect, b: Badge, t: &Tokens) { + let c = item.left_top() + vec2(5.0, 5.0); + let (fill, white) = (if b == Badge::Delete { t.error } else { t.accent }, Stroke::new(1.4, Color32::WHITE)); + p.circle(c, 5.0, fill, Stroke::new(1.0, t.timeline_item)); + match b { + Badge::Delete => { + p.line_segment([c - vec2(2.5, 0.0), c + vec2(2.5, 0.0)], white); + } + Badge::Edit => { + p.line_segment([c + vec2(-2.2, 2.2), c + vec2(2.2, -2.2)], white); + } + } +} + +/// Text for an item's tooltip: what it did, and whether a click reaches it. +fn tip(item: &Item, reachable: bool) -> String { + let mut s = describe(item); s.push_str(if reachable { "\nClick to roll the model to here" } else { "\nOlder than the undo history" }); s } @@ -191,14 +239,17 @@ pub fn timeline(app: &mut ModelApp, ui: &mut egui::Ui) { let icon = fieldcraft_engine::catalogue::entry_of(&item.command).map_or("ui.views", |c| c.id); let tint = if undone || !reachable { Color32::from_white_alpha(110) } else { Color32::WHITE }; icons::paint_tinted(app, ui, icon, br.shrink(4.0), Look::of(!undone), tint); + if let Some(b) = badge(&item.command) { + paint_badge(&p, br, b, &t); + } if item.count() > 1 { let text = p.layout_no_wrap(format!("{}", item.count()), FontId::proportional(9.0), t.text); let b = Rect::from_min_size(br.right_bottom() - text.size() - vec2(3.0, 1.0), text.size() + vec2(3.0, 1.0)); p.rect_filled(b, 3.0, t.panel_header); p.galley(b.min + vec2(1.5, 0.5), text, t.text); } - let label = crate::command_label(&item.command); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, reachable, label)); + let label = describe(item); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, reachable, &label)); if resp .on_hover_ui(|ui| { ui.label(tip(item, reachable)); @@ -252,4 +303,20 @@ mod tests { app.roll_to(100); assert_eq!(app.session.journal.len(), 4, "clamped to what can be redone"); } + + /// A deletion or an edit reads apart from the creation it undoes (#359): its own badge, and + /// a tooltip in words. + #[test] + fn deletions_and_edits_read_apart_from_creations() { + let item = |command: &str, params: Value| Item { command: command.into(), start: 0, end: 1, params }; + let create = item("model.reference_point.create", json!({"name": "RP1", "xyz": [0, 0, 0]})); + let delete = item("model.reference_point.delete", json!({"name": "RP1"})); + assert_eq!((badge(&create.command), badge(&delete.command)), (None, Some(Badge::Delete))); + assert_eq!(badge("model.load.edit"), Some(Badge::Edit)); + assert_eq!(describe(&create), "Add reference point RP1"); + assert_eq!(describe(&delete), "Delete reference point RP1"); + assert!(!tip(&delete, true).contains('{')); + let nodes = Item { end: 3, ..item("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})) }; + assert_eq!(describe(&nodes), "Add Node × 3"); + } }