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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 08:55:25 -04:00
1 parent b65f1696b2
commit a53c79cb55
8 files changed
+513 -39

No files matched your search

+22 -13
View File
@@ -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<String>,
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<Status>,
/// The hover text (a setup state's reason and place).
tip: Option<String>,
@@ -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));
+26 -18
View File
@@ -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<std::sync::Arc<Vec<(String, String)>>> {
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<std::sync:
{
return Some(rows.clone());
}
let v = browser::definition(&app.session.model, kind, name)?;
let rows: Vec<(String, String)> = 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)
+20 -3
View File
@@ -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);
+299
View File
@@ -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<Vec<(String, String)>> {
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<String, Value>, units: UnitSystem, out: &mut Vec<(String, String)>, used: &mut Vec<String>, 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<String> = 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>) -> String {
let Some(a) = v.as_array() else { return crate::context::value_text(v) };
let names: Vec<String> = 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<String, Value>) -> 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<String> = 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::<Vec<_>>().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}");
}
}
+1 -1
View File
@@ -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);
+77 -4
View File
@@ -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<Box<Back>>,
/// 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<Value>,
}
/// 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, &params, 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<Value, String> {
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(&params, 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();
+59
View File
@@ -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 "<label> done".
pub fn done(command: &str, label: &str, params: &Value, v: &Value) -> String {
let view = v.get("view").filter(|x| x.is_object());
let step = view.and_then(|x| x["step"].as_str()).unwrap_or("the step");
let line = match command {
"results.deformed" if view.is_some_and(|x| x["deformed"] == false) => Some(format!("Showing {step} undeformed")),
"results.deformed" => {
let scale = view.and_then(|x| x["scale"].as_f64()).map(|f| format!(" ×{}", crate::describe::quantity(f))).unwrap_or_default();
Some(format!("Showing {step} deformed{scale}"))
}
"results.contour" => Some(match v.pointer("/contour/label").and_then(Value::as_str) {
Some(field) => format!("Showing {field} on {step}"),
None => format!("Contours off on {step}"),
}),
"results.modes" => {
let mode = view.and_then(|x| x["mode"].as_u64()).unwrap_or(1);
let hz = v
.get("modes")
.and_then(Value::as_array)
.and_then(|m| m.iter().find(|m| m["mode"].as_u64() == Some(mode)))
.and_then(|m| m["frequency"].as_f64());
Some(match hz {
Some(hz) => format!("Mode {mode}: {} Hz", crate::describe::quantity(hz)),
None => format!("Showing mode {mode} of {step}"),
})
}
"results.animate" => Some(if view.is_some_and(|x| x["animate"] == true) { "Animating" } else { "Animation stopped" }.to_string()),
"results.frame" => Some(match view.and_then(|x| x["frame"].as_u64()) {
Some(f) => format!("Showing {step} frame {f}"),
None => format!("Showing {step}, last frame"),
}),
"results.clear" => Some("Back to the model".to_string()),
"solve.run" => v.get("steps").and_then(Value::as_array).map(|s| format!("Solved {}", plural(s.len() as u64, "step"))),
c if c.starts_with("solve.") => Some(format!("Solved {step}", step = params["step"].as_str().unwrap_or("the step"))),
_ => None,
};
if let Some(line) = line {
return line;
}
// A definition added, changed or deleted, by its kind and name.
let name = params.get("name").and_then(Value::as_str);
let def = Def::ALL
.into_iter()
.find_map(|kind| ["create", "edit", "delete"].into_iter().find(|verb| kind.command(verb) == command).map(|verb| (kind, verb)));
match (def, name) {
(Some((kind, verb)), Some(name)) => {
let did = match verb {
"create" => "Added",
"edit" => "Changed",
_ => "Deleted",
};
format!("{did} {} {name}", kind.label().to_lowercase())
}
_ => format!("{label} done"),
}
}
/// Whether the reply says more than its summary line, so it is worth the panel.
pub fn is_rich(v: &Value) -> bool {
match v {
+9
View File
@@ -859,6 +859,15 @@ pub fn with_set(params: &Value, target: Target, set: &str) -> Value {
Value::Object(out)
}
/// The set of `model` whose members are exactly those of `set` (`model.set.create`'s
/// parameters), if any.
pub fn same_set(model: &Model, set: &Value) -> Option<String> {
let mut members = set.as_object().cloned()?;
members.remove("name");
let wanted = serde_json::from_value::<Set>(Value::Object(members)).ok()?.normalized().ok()?;
model.sets().iter().find(|(_, s)| **s == wanted).map(|(n, _)| n.clone())
}
/// A set name not taken in `model`: `<base>`, then `<base>-2`, `<base>-3`, …; `Set` when
/// `base` is not a valid name.
pub fn fresh_set_name(model: &Model, base: &str) -> String {