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

+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}");
}
}