forked from 1b-agent/fieldcraft-upstream
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:
1 parent
b65f1696b2
commit
a53c79cb55
8 files changed
+513
-39
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¶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<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(¶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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user