From a53c79cb556c5dcd21f61b3e1a6f900493e561f1 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:28:30 -0400 Subject: [PATCH] ui: properties and status lines in words, current card on top, Use selection makes its set on OK, marks on the left (#335, #333, #360, #337, #315, #361) - describe.rs: a definition's fields in its form's labels and units (sets by members and what they resolve to), for the context panel. - reply::done: status lines that say what changed ("Showing Load deformed", "Mode 2: 1637 Hz", "Added material Al"). - The context panel puts the form and the selection above reports; opening another document clears both; what a form makes is selected. - Use selection reuses a set with the same members, otherwise names one made on OK; Cancel leaves the model unchanged. The Target's set row is labelled. - The browser's state marks sit in one column at its left edge; a right click selects the row its menu acts on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/browser.rs | 35 ++-- crates/ui-egui/src/context.rs | 44 +++-- crates/ui-egui/src/control.rs | 23 ++- crates/ui-egui/src/describe.rs | 299 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/form.rs | 2 +- crates/ui-egui/src/lib.rs | 81 ++++++++- crates/ui-egui/src/reply.rs | 59 +++++++ crates/ui-egui/src/selection.rs | 9 + 8 files changed, 513 insertions(+), 39 deletions(-) create mode 100644 crates/ui-egui/src/describe.rs diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 6bc9e79..bffc1d8 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -9,9 +9,9 @@ //! opens its menu ([`crate::menu`]: edit, hide, isolate, active in step, delete). //! //! The tree also shows where the model is in the analysis workflow -//! ([`fieldcraft_engine::progress`]): each top-level node that is a setup stage carries a mark at -//! the right of its row, beside its count (✓ done, → the next stage, ⚠ when its command cannot -//! run yet), with the reason and the place in its hover, and a node that still lacks what its +//! ([`fieldcraft_engine::progress`]): each top-level node that is a setup stage carries a mark in +//! one column at the tree's left edge, so the state of every stage reads down one line (✓ done, +//! → the next stage, ⚠ when its command cannot run yet), with the reason and the place in its hover, and a node that still lacks what its //! stage needs shows a ghost row ("+ Add material…") that starts the providing command. Optional //! stages (seeds and partitions) carry no mark. //! @@ -38,7 +38,7 @@ const INDENT: f32 = 14.0; const NOTE_GAP: f32 = 6.0; /// Free space kept at a row's right end. const ROW_PAD: f32 = 4.0; -/// The state mark's slot at a top-level row's right end. +/// The state marks' column at the tree's left edge (every row keeps it, so the tree lines up). const MARK_W: f32 = 14.0; /// The browser's top-level nodes below the model, in order, with the setup stages each shows @@ -430,7 +430,7 @@ struct Row<'a> { note: Option, selected: bool, dim: bool, - /// The setup state mark at the row's right end (top-level nodes). + /// The setup state mark in the left column (top-level nodes). mark: Option, /// The hover text (a setup state's reason and place). tip: Option, @@ -459,7 +459,10 @@ fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> R } else if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - let mut x = r.left() + 2.0 + row.depth as f32 * INDENT; + if let Some(status) = row.mark { + paint_mark(ui.painter(), Rect::from_center_size(pos2(r.left() + 1.0 + MARK_W / 2.0, r.center().y), Vec2::splat(9.0)), status, &t); + } + let mut x = r.left() + 2.0 + MARK_W + row.depth as f32 * INDENT; let cy = r.center().y; let fold_r = Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom())); let eye_r = Rect::from_center_size(pos2(x + 14.0 + 8.0, cy), vec2(19.0, r.height())); @@ -505,12 +508,7 @@ fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> R let col = if row.dim { t.text_dim } else { t.text }; // The note (a count, a seed's size and bias) keeps its room; the label gets the rest, elided in // the middle when long, whole on hover. - // The trailing area, right to left: the state mark's slot (top-level rows keep it so their - // counts line up), then the note. - let right = r.right() - ROW_PAD - if row.depth == 1 { MARK_W } else { 0.0 }; - if let Some(status) = row.mark { - paint_mark(ui.painter(), Rect::from_center_size(pos2(right + MARK_W / 2.0, cy), Vec2::splat(9.0)), status, &t); - } + let right = r.right() - ROW_PAD; let note = row.note.as_ref().map(|n| ui.painter().layout_no_wrap(n.clone(), FontId::proportional(11.5), t.text_dim)); let note_w = note.as_ref().map_or(0.0, |g| g.size().x + NOTE_GAP); let font = FontId::proportional(12.5); @@ -555,6 +553,14 @@ fn item_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, mut row: Row, t row.selected = app.selected.as_ref().is_some_and(|(k, n)| k == kind && n == name); } 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, + })); + } if let Some(resp) = &r.response { resp.context_menu(|ui| { if let Some(a) = menu::show(app, ui, &target) { @@ -617,7 +623,7 @@ fn draw_ghost(app: &mut ModelApp, ui: &mut egui::Ui, s: &StageState) -> bool { } let col = if s.status == Status::Next { t.accent } else { t.text_dim }; // Aligned with the node's children: past the fold arrow, the plus in the icon's place. - let x = r.left() + 2.0 + 2.0 * INDENT + 14.0; + let x = r.left() + 2.0 + MARK_W + 2.0 * INDENT + 14.0; let c = pos2(x + 8.0, r.center().y); let plus = Stroke::new(1.3, col); ui.painter().line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], plus); @@ -633,6 +639,8 @@ 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), ShowResult(String), /// A ghost row's command. @@ -682,6 +690,7 @@ 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)) }; } + 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)); diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index 884b9bd..fcbd03d 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -1,12 +1,13 @@ //! The context panel (right): shown only while there is context, as in the other Craft apps. -//! From the top: the open command's form, the results controls while results are shown, and the -//! properties of the definition selected in the browser (with Edit and Delete). Each section has +//! From the top: the open command's form, the properties of the definition selected in the +//! browser (with Edit and Delete), the results controls while results are shown, and last the +//! reply of a report (Check Model, Model Info), so what the person is working on stays on top. Each section has //! a heading band; everything acts through commands. use egui::{RichText, vec2}; use serde_json::{Value, json}; -use crate::browser::{self, Def}; +use crate::browser::Def; use crate::frame::{Level, heading}; use crate::theme::{Tokens, label_font}; use crate::{ModelApp, selection}; @@ -36,6 +37,10 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) { form(app, ui, &t); ui.add_space(10.0); } + if let Some((kind, name)) = app.selected.clone() { + properties(app, ui, &t, kind, &name); + ui.add_space(10.0); + } if let Some(plot) = app.plot() { let mut close = false; heading(ui, &t, Level::Title, |ui| { @@ -53,10 +58,6 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) { } if app.reply.is_some() { crate::reply::show(app, ui, &t); - ui.add_space(10.0); - } - if let Some((kind, name)) = app.selected.clone() { - properties(app, ui, &t, kind, &name); } }); }); @@ -73,6 +74,8 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { let selected = (!app.pick.selection.is_empty()).then(|| app.pick.selection.summary()); let units = app.session.model.units(); let session = &app.session; + // The set Use selection will make on OK is offered with the others. + let pending = app.dialog.as_ref().and_then(|d| d.pending_set.as_ref()).and_then(|s| s.get("name")).and_then(Value::as_str).map(str::to_string); let mut use_selection = false; let Some(d) = app.dialog.as_mut() else { return }; let Some(spec) = fieldcraft_engine::find_command(d.command) else { @@ -99,7 +102,9 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { ui.horizontal(|ui| { let tip = match target { selection::Target::Members => "The selection becomes the set's members (geometry by name, the mesh by label)", - selection::Target::Set | selection::Target::TargetSet => "Makes a set of the selection (model.set.create) and names it here", + selection::Target::Set | selection::Target::TargetSet => { + "Names a set of the selection here: an existing set with the same members, or a new one made on OK (model.set.create)" + } selection::Target::Edges => "The selected design edges (Select: Edges) become the edges seeded", }; use_selection = ui.add_enabled(selected.is_some(), egui::Button::new("Use selection")).on_hover_text(tip).clicked(); @@ -143,7 +148,16 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { let refused = d.refused.as_ref().map(|(k, p)| (k.as_str(), p.as_str())); let cx = crate::form::Context { units, - names: &|c| session.names(c), + names: &|c| { + let mut names = session.names(c); + if c == fieldcraft_engine::schema::Collection::Set + && let Some(p) = &pending + && !names.contains(p) + { + names.push(p.clone()); + } + names + }, salt: form_id, touched: &d.touched, attempted: d.attempted, @@ -211,7 +225,8 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { } } -/// The selected definition's fields as (name, short text), read again only when the model changes. +/// The selected definition's fields as (label, text) in its form's words and units, read again only +/// when the model changes. fn property_rows(app: &mut ModelApp, kind: Def, name: &str) -> Option>> { let key = (app.session.revision(), kind, name.to_string()); if let Some((k, rows)) = &app.properties @@ -219,14 +234,7 @@ fn property_rows(app: &mut ModelApp, kind: Def, name: &str) -> Option = v - .as_object() - .into_iter() - .flatten() - .filter(|(k, _)| k.as_str() != "name") - .map(|(k, field)| (k.replace('_', " "), value_text(field))) - .collect(); + let rows = crate::describe::rows(&app.session.model, kind, name)?; let rows = std::sync::Arc::new(rows); app.properties = Some((key, rows.clone())); Some(rows) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bc727ba..6990958 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -756,16 +756,33 @@ mod tests { assert_eq!(r["ok"], true, "{r}"); let r = call_in(&mut app, &ctx, "engine.execute", json!({"command": "model.set.resolve", "params": {"name": "end"}})); assert_eq!((r["result"]["faces"]["count"].as_u64(), r["result"]["nodes"]["count"].as_u64()), (Some(8), Some(15)), "{r}"); - // A boundary condition's Target row makes a set of its own and names it. + // A boundary condition's Target row names the set that has exactly the selection. call_in(&mut app, &ctx, "ui.start", json!({"command": "model.boundary_condition.create"})); let r = call_in(&mut app, &ctx, "ui.dialog", json!({"params": {"name": "Hold", "type": "fixed"}, "use_selection": true, "run": true})); assert_eq!(r["ok"], true, "{r}"); let bc = serde_json::to_value(app.session.model.boundary_conditions().get("Hold").unwrap()).unwrap(); - assert_eq!(bc["target"], json!({"set": "Hold-set"}), "{bc}"); - assert_eq!(app.session.model.resolve_set("Hold-set").unwrap().faces.len(), 8); + assert_eq!(bc["target"], json!({"set": "end"}), "{bc}"); // Nodes in the box: the end's 3 × 5, none of those behind it. let r = call_in(&mut app, &ctx, "ui.select", json!({"filter": "nodes", "box": corners})); assert_eq!(r["result"]["count"], 15, "{r}"); + // A selection no set has: Use selection names a set made only on OK, so Cancel leaves + // the model as it was (#337). + let r = call_in(&mut app, &ctx, "ui.select", json!({"nodes": [1, 2]})); + assert_eq!(r["result"]["count"], 2, "{r}"); + let sets = app.session.model.sets().len(); + call_in(&mut app, &ctx, "ui.start", json!({"command": "model.boundary_condition.create"})); + let r = call_in(&mut app, &ctx, "ui.dialog", json!({"params": {"name": "Pin", "type": "fixed"}, "use_selection": true})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(app.session.model.sets().len(), sets); + app.close_dialog(); + assert_eq!(app.session.model.sets().len(), sets, "Cancel leaves no set behind"); + call_in(&mut app, &ctx, "ui.start", json!({"command": "model.boundary_condition.create"})); + let r = call_in(&mut app, &ctx, "ui.dialog", json!({"params": {"name": "Pin", "type": "fixed"}, "use_selection": true, "run": true})); + assert_eq!(r["ok"], true, "{r}"); + let bc = serde_json::to_value(app.session.model.boundary_conditions().get("Pin").unwrap()).unwrap(); + assert_eq!(bc["target"], json!({"set": "Pin-set"}), "{bc}"); + assert_eq!(app.session.model.resolve_set("Pin-set").unwrap().nodes, [1, 2]); + assert_eq!(app.selected, Some((crate::browser::Def::BoundaryCondition, "Pin".into())), "what OK made is selected"); // Escape clears the selection. call_in(&mut app, &ctx, "ui.key", json!({"key": "Escape"})); frame(&mut app, &ctx); diff --git a/crates/ui-egui/src/describe.rs b/crates/ui-egui/src/describe.rs new file mode 100644 index 0000000..d477bcd --- /dev/null +++ b/crates/ui-egui/src/describe.rs @@ -0,0 +1,299 @@ +//! Values as people read them: a definition's fields with the labels and units of its form +//! ([`rows`], the context panel's properties), and numbers rounded and grouped ([`quantity`]). +//! The command's form schema ([`fieldcraft_engine::schema`]) knows each field's label, unit and +//! choices, so a definition is described by the same words as the form that edits it, never as +//! its JSON. + +use fieldcraft_engine::schema::{Field, Kind}; +use fieldcraft_model::{Model, UnitSystem}; +use serde_json::{Map, Value}; + +use crate::browser::Def; + +/// Lists longer than this are given by their length. +const LIST: usize = 6; +/// Nesting followed at most. +const MAX_DEPTH: usize = 6; + +/// A number in a few significant figures: whole numbers below a billion in full, with thin spaces +/// between groups of three digits ("210 000"); others as [`crate::results::number`]. +pub fn quantity(v: f64) -> String { + if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e9 { + let digits = format!("{}", v.abs() as u64); + let mut out = String::new(); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push('\u{2009}'); + } + out.push(c); + } + return if v < 0.0 { format!("−{out}") } else { out }; + } + // Trailing zeros say nothing here: "0.3", "7.85e−9". + let s = crate::results::number(v); + let (mantissa, exponent) = s.split_once('e').map_or((s.as_str(), None), |(m, e)| (m, Some(e))); + let mantissa = if mantissa.contains('.') { mantissa.trim_end_matches('0').trim_end_matches('.') } else { mantissa }; + let out = exponent.map_or_else(|| mantissa.to_string(), |e| format!("{mantissa}e{e}")); + out.replace('-', "−") +} + +/// `v` with its unit, when it has one. +fn with_unit(v: f64, unit: Option<&str>) -> String { + match unit { + Some(u) => format!("{} {u}", quantity(v)), + None => quantity(v), + } +} + +/// The definition `name` of `kind` as (label, text) rows, in its form's words and units; `None` +/// when it is gone. +pub fn rows(model: &Model, kind: Def, name: &str) -> Option> { + let v = crate::browser::definition(model, kind, name)?; + let obj = v.as_object()?; + if kind == Def::Set { + return Some(set_rows(model, name, obj)); + } + let form = fieldcraft_engine::find_command(&kind.command("create")).and_then(|spec| spec.form); + let mut out = Vec::new(); + let mut used = Vec::new(); + if let Some(fields) = form { + object(fields, obj, model.units(), &mut out, &mut used, 0); + } + // What the form does not show stays readable. + for (k, field) in obj { + if k != "name" && !used.iter().any(|u| u == k) { + out.push((sentence(k), crate::context::value_text(field))); + } + } + Some(out) +} + +/// "contact_pressure" → "Contact pressure". +fn sentence(key: &str) -> String { + let s = key.replace('_', " "); + let mut c = s.chars(); + c.next().map_or_else(String::new, |f| f.to_uppercase().chain(c).collect()) +} + +/// The rows of one object's fields; `used` collects the top-level keys described. +fn object(fields: &[Field], obj: &Map, units: UnitSystem, out: &mut Vec<(String, String)>, used: &mut Vec, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for f in fields { + if f.key == "name" { + continue; + } + if let Kind::Variants { tag, variants } = f.kind + && f.key.is_empty() + { + let Some(t) = obj.get(tag).and_then(Value::as_str) else { continue }; + used.push(tag.to_string()); + match variants.iter().find(|v| v.tag == t) { + Some(v) => { + out.push((f.label.to_string(), v.label.to_string())); + object(v.fields, obj, units, out, used, depth + 1); + } + None => out.push((f.label.to_string(), t.to_string())), + } + continue; + } + let Some(value) = obj.get(f.key).filter(|v| !v.is_null()) else { continue }; + used.push(f.key.to_string()); + match f.kind { + Kind::Group(inner) => { + if let Some(o) = value.as_object() { + object(inner, o, units, out, &mut Vec::new(), depth + 1); + } + } + Kind::Variants { tag, variants } => { + let Some(o) = value.as_object() else { continue }; + let t = o.get(tag).and_then(Value::as_str).unwrap_or_default(); + match variants.iter().find(|v| v.tag == t) { + Some(v) => { + out.push((f.label.to_string(), v.label.to_string())); + object(v.fields, o, units, out, &mut Vec::new(), depth + 1); + } + None => out.push((f.label.to_string(), crate::context::value_text(value))), + } + } + _ => out.push((f.label.to_string(), scalar(f, value, units))), + } + } +} + +/// One field's value as text, with its unit. +fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String { + let unit = f.unit_label(units); + match f.kind { + Kind::Number { .. } => v.as_f64().map_or_else(|| crate::context::value_text(v), |x| with_unit(x, unit.as_deref())), + Kind::Integer { .. } => v.as_i64().map_or_else(|| crate::context::value_text(v), |x| quantity(x as f64)), + Kind::Bool => (if v.as_bool() == Some(true) { "yes" } else { "no" }).into(), + Kind::Choice(options) => { + let s = v.as_str().unwrap_or_default(); + options.iter().find(|(o, _)| *o == s).map_or_else(|| s.to_string(), |(_, l)| (*l).to_string()) + } + Kind::Vector { labels, .. } => vector(labels, v, unit.as_deref()), + Kind::Ref(_) | Kind::Text | Kind::Path { .. } => v.as_str().map_or_else(|| crate::context::value_text(v), str::to_string), + Kind::RefList(_) => list(v, |x| x.as_str().map(str::to_string)), + Kind::Either(options) => match v { + Value::String(s) => options.iter().find(|o| o.key == s).map_or_else(|| s.clone(), |o| o.label.to_string()), + Value::Object(o) => o + .iter() + .next() + .map(|(k, x)| match options.iter().find(|op| op.key == k) { + Some(op) => format!("{} {}", op.label, scalar(op, x, units)), + None => crate::context::value_text(v), + }) + .unwrap_or_default(), + _ => crate::context::value_text(v), + }, + Kind::Rows { .. } => v.as_array().map_or_else(|| crate::context::value_text(v), |a| plural(a.len(), "row")), + Kind::Json | Kind::Unit | Kind::Group(_) | Kind::Variants { .. } => crate::context::value_text(v), + } +} + +/// A vector by its components: "x 0, y −1 000, z 0 N". Components left free (null) are listed +/// last as free; rotations (`ur…`) are in radians. +fn vector(labels: &[&str], v: &Value, unit: Option<&str>) -> String { + let Some(a) = v.as_array() else { return crate::context::value_text(v) }; + let (mut set, mut free) = (Vec::new(), Vec::new()); + for (i, x) in a.iter().enumerate() { + let label = labels.get(i).copied().unwrap_or("?"); + match x.as_f64() { + Some(x) => { + let u = if label.starts_with("ur") { Some("rad") } else { unit }; + set.push(format!("{} {}", label.to_uppercase(), with_unit(x, u))); + } + None => free.push(label.to_uppercase()), + } + } + // A force or moment vector shares one unit: given once, at the end. + if free.is_empty() && labels.iter().all(|l| !l.starts_with("ur")) { + let parts: Vec = a + .iter() + .enumerate() + .map(|(i, x)| format!("{} {}", labels.get(i).copied().unwrap_or("?"), x.as_f64().map_or("—".into(), quantity))) + .collect(); + return match unit { + Some(u) => format!("{} {u}", parts.join(", ")), + None => parts.join(", "), + }; + } + let mut out = set.join(", "); + if !free.is_empty() { + out.push_str(&format!("; {} free", free.join(", "))); + } + out +} + +fn list(v: &Value, item: impl Fn(&Value) -> Option) -> String { + let Some(a) = v.as_array() else { return crate::context::value_text(v) }; + let names: Vec = a.iter().filter_map(item).collect(); + if names.len() > LIST { format!("{}, … ({} in all)", names[..LIST].join(", "), names.len()) } else { names.join(", ") } +} + +fn plural(n: usize, word: &str) -> String { + if n == 1 { format!("1 {word}") } else { format!("{n} {word}s") } +} + +/// A set: what it is defined by (design entities by kind and body, mesh labels) and what that +/// resolves to in the mesh. +fn set_rows(model: &Model, name: &str, obj: &Map) -> Vec<(String, String)> { + let mut out = Vec::new(); + if let Some(geometry) = obj.get("geometry").and_then(Value::as_array).filter(|g| !g.is_empty()) { + let mut kinds: std::collections::BTreeMap<&str, usize> = Default::default(); + let mut bodies = std::collections::BTreeSet::new(); + for g in geometry { + let kind = ["face", "edge", "vertex"].into_iter().find(|k| g.get(*k).is_some()).unwrap_or("body"); + *kinds.entry(kind).or_default() += 1; + if let Some(b) = g.get("body").and_then(Value::as_str) { + bodies.insert(b); + } + } + let what: Vec = kinds + .iter() + .map(|(k, n)| { + if *k == "vertex" && *n != 1 { + format!("{n} vertices") + } else if *k == "body" && *n != 1 { + format!("{n} bodies") + } else { + plural(*n, k) + } + }) + .collect(); + let on = if bodies.len() == 1 && !kinds.contains_key("body") { + bodies.iter().next().map(|b| format!(" of {b}")).unwrap_or_default() + } else if !kinds.contains_key("body") { + format!(" on {} bodies", bodies.len()) + } else { + String::new() + }; + out.push(("Geometry".into(), format!("{}{on}", what.join(", ")))); + } + for (key, label, word) in + [("nodes", "Nodes", "node"), ("elements", "Elements", "element"), ("faces", "Element faces", "face"), ("edges", "Element edges", "edge")] + { + if let Some(a) = obj.get(key).and_then(Value::as_array).filter(|a| !a.is_empty()) { + out.push((label.into(), plural(a.len(), word))); + } + } + if let Some(r) = model.resolve_set_partial(name) { + let mut parts = vec![plural(r.nodes.len(), "node")]; + if !r.elements.is_empty() { + parts.push(plural(r.elements.len(), "element")); + } + if !r.faces.is_empty() { + parts.push(plural(r.faces.len(), "element face")); + } + if !r.edges.is_empty() { + parts.push(plural(r.edges.len(), "element edge")); + } + out.push(("In the mesh".into(), parts.join(", "))); + } + out +} + +#[cfg(test)] +mod tests { + use fieldcraft_engine::Session; + + use super::*; + + fn text(rows: &[(String, String)]) -> String { + rows.iter().map(|(k, v)| format!("{k}: {v}")).collect::>().join("\n") + } + + #[test] + fn numbers_are_grouped_and_rounded() { + assert_eq!(quantity(210000.0), "210\u{2009}000"); + assert_eq!(quantity(-1000.0), "−1\u{2009}000"); + assert_eq!(quantity(0.3), "0.3"); + assert_eq!(quantity(-2.5), "−2.5"); + assert_eq!(quantity(7.85e-9), "7.85e−9"); + } + + /// Definitions read in their forms' words and units, never as JSON (#335). + #[test] + fn definitions_read_with_labels_and_units() { + let mut s = Session::default(); + s.run_script(&fieldcraft_engine::sample::cantilever()).unwrap(); + let m = &s.model; + let steel = text(&rows(m, Def::Material, "Steel").unwrap()); + assert!(steel.contains("Elasticity: Isotropic") && steel.contains("MPa") && steel.contains("t/mm³"), "{steel}"); + assert!(!steel.contains('{'), "{steel}"); + let load = Def::Load.names(m).into_iter().next().unwrap(); + let load = text(&rows(m, Def::Load, &load).unwrap()); + assert!(load.contains("Type: Force") && load.contains("Target: Set ") && load.contains(" N"), "{load}"); + assert!(!load.contains('{') && !load.contains('['), "{load}"); + let set = Def::Set.names(m).into_iter().next().unwrap(); + let set = text(&rows(m, Def::Set, &set).unwrap()); + assert!(set.contains("In the mesh: "), "{set}"); + + let mut s = Session::default(); + s.run_script(&fieldcraft_engine::sample::rubber_seal()).unwrap(); + let press = text(&rows(&s.model, Def::BoundaryCondition, "Press").unwrap()); + assert!(press.contains("Type: Displacement") && press.contains("U3 −2.5 mm") && press.contains("UR1, UR2, UR3 free"), "{press}"); + } +} diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs index 201cc55..8b68735 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -310,7 +310,7 @@ impl Form<'_> { let sub = join(path, &key); let style = self.style; let help = option.help_text(self.cx.units); - let new = fields::row(ui, &style, "", false, &help, |ui| self.scalar(ui, option, Some(&value), &sub)); + let new = fields::row(ui, &style, option.label, false, &help, |ui| self.scalar(ui, option, Some(&value), &sub)); if let Some(new) = new { obj.insert(f.key.into(), json!({key: new})); self.touch(path); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index abe6056..149092f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ pub mod browser; pub mod capture; pub mod context; pub mod control; +pub mod describe; pub mod elide; pub mod fields; pub mod files; @@ -193,6 +194,9 @@ pub struct ParamsDialog { pub opening: u64, /// Opened from another form's "Create …" button: the form to go back to. pub back: Option>, + /// The set Use selection made of the selection (`model.set.create`'s parameters): created on + /// OK when the form still names it, so Cancel leaves the model as it was. + pub pending_set: Option, } /// A command that failed: its message, and the parameter it refused (path, form message). @@ -565,7 +569,7 @@ impl ModelApp { self.follow_stage(id); } match &r { - Ok(v) if v.is_null() || changed || display => self.set_status(format!("{label} done"), false), + Ok(v) if v.is_null() || changed || display => self.set_status(reply::done(id, label, ¶ms, v), false), // A reply reads as one line; the whole of a rich one shows in the context panel (the // reaction sums have their own place in the results panel). Ok(v) => { @@ -687,6 +691,7 @@ impl ModelApp { advanced: spec.form.is_none(), opening: self.forms_opened, back: None, + pending_set: None, }); } } @@ -734,12 +739,38 @@ impl ModelApp { /// Run the open parameter form; it closes when the command succeeds. pub fn run_dialog(&mut self) -> Result { let (command, params) = self.dialog_params()?; + // The set Use selection made, when the form still names it: created first, and taken back + // if the command then fails. + let pending = self.dialog.as_ref().and_then(|d| d.pending_set.clone()).filter(|set| { + let name = set.get("name").and_then(Value::as_str); + name.is_some() && [params.pointer("/target/set"), params.get("set")].into_iter().flatten().any(|v| v.as_str() == name) + }); + if let Some(set) = &pending + && let Err(e) = self.session.execute("model.set.create", set) + { + let message = format!("the selection's set: {e}"); + if let Some(open) = self.dialog.as_mut() { + open.error = Some(message.clone()); + } + return Err(message); + } + let made = params.get("name").and_then(Value::as_str).map(str::to_string); match self.run_form(command, params, true) { Ok(v) => { + // A definition made in its form is the selection, its properties in the panel. + if let (Some(kind), Some(name)) = (browser::Def::ALL.into_iter().find(|k| k.command("create") == command), made) + && kind.exists(&self.session.model, &name) + { + self.selected = Some((kind, name)); + } self.dialog_done(); Ok(v) } Err(Failure { message, refused }) => { + if pending.is_some() { + let _ = self.session.execute("edit.undo", &json!({})); + self.model_changed(); + } if let Some(open) = self.dialog.as_mut() { open.error = Some(message.clone()); open.refused = refused; @@ -1260,8 +1291,15 @@ impl ModelApp { selection::Target::Set | selection::Target::TargetSet => { let base = params.get("name").and_then(Value::as_str).map(|n| format!("{n}-set")); let set = self.selection_set(base.as_deref())?; - let name = set.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); - self.run("model.set.create", set)?; + // A set with exactly these members is used as it is; otherwise the new one is made + // on OK ([`ParamsDialog::pending_set`]). + let (name, pending) = match selection::same_set(&self.session.model, &set) { + Some(existing) => (existing, None), + None => (set.get("name").and_then(Value::as_str).unwrap_or_default().to_string(), Some(set)), + }; + if let Some(open) = self.dialog.as_mut() { + open.pending_set = pending; + } selection::with_set(¶ms, target, &name) } }; @@ -1307,9 +1345,14 @@ impl ModelApp { } /// Another model, sample or design opened (or the window's first one): a fresh start's home - /// view when the viewport next draws. + /// view when the viewport next draws, and nothing of the last document left selected or + /// reported in the context panel (a definition of the same name is another one). fn frame_new_document(&mut self) { if self.framed_document != Some(self.session.opened()) { + if self.framed_document.is_some() { + self.selected = None; + self.reply = None; + } self.framed_document = Some(self.session.opened()); self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Home); } @@ -1641,6 +1684,33 @@ mod tests { assert!(app.status.as_ref().is_some_and(|s| s.text.starts_with("Model Info: 1 node, 0 elements")), "{:?}", app.status); } + /// Status lines say what changed, in words, whatever started the command (#333), and the + /// document is named after the sample opened (#334). + #[test] + fn status_lines_say_what_changed() { + let mut app = ModelApp::new(Session::default()); + assert_eq!(app.model_name(), "Untitled"); + app.run("file.sample", json!({"name": "cantilever"})).unwrap(); + assert_eq!(app.model_name(), "Cantilever (sample)"); + let status = |app: &ModelApp| app.status.as_ref().map(|s| s.text.clone()).unwrap_or_default(); + app.run("solve.run", json!({})).unwrap(); + assert_eq!(status(&app), "Solved 2 steps"); + app.run("results.deformed", json!({"step": "Load", "show": true})).unwrap(); + assert_eq!(status(&app), "Showing Load deformed"); + app.run("results.deformed", json!({"step": "Load", "scale": 2})).unwrap(); + assert_eq!(status(&app), "Showing Load deformed ×2"); + app.run("results.contour", json!({"step": "Load", "field": "von_mises"})).unwrap(); + assert_eq!(status(&app), "Showing Von Mises stress on Load"); + app.run("results.modes", json!({"step": "Modes", "mode": 2})).unwrap(); + assert!(status(&app).starts_with("Mode 2: ") && status(&app).ends_with(" Hz"), "{}", status(&app)); + app.run("results.clear", json!({})).unwrap(); + assert_eq!(status(&app), "Back to the model"); + app.run("model.material.create", json!({"name": "Al", "elastic": {"type": "isotropic", "young": 70000, "poisson": 0.33}})).unwrap(); + assert_eq!(status(&app), "Added material Al"); + app.run("model.material.delete", json!({"name": "Al"})).unwrap(); + assert_eq!(status(&app), "Deleted material Al"); + } + /// Every command run from the UI reports one readable line, never its JSON, in every state /// tried (empty, a solved sample); a reply with more to it opens in the context panel. #[test] @@ -1797,6 +1867,9 @@ mod tests { assert_eq!(params(&app)["material"], "Steel"); app.close_dialog(); assert!(app.dialog.is_none()); + // What a form made is selected (#360); the rest of the test starts with nothing selected. + assert_eq!(app.selected, Some((browser::Def::Material, "Steel".into()))); + app.selected = None; // One material: Plasticity starts with it. app.start("model.material.plasticity").unwrap(); diff --git a/crates/ui-egui/src/reply.rs b/crates/ui-egui/src/reply.rs index 5aca102..d64f692 100644 --- a/crates/ui-egui/src/reply.rs +++ b/crates/ui-egui/src/reply.rs @@ -78,6 +78,65 @@ pub fn summary(command: &str, v: &Value) -> String { if line.is_empty() { "done".into() } else { one_line(&line) } } +/// The status line after `command` changed the model or the display, written for the person +/// rather than from the command's label: "Showing Load deformed ×2", "Mode 2: 1637 Hz", "Added +/// load Push". Commands without a sentence of their own say "