Files
OneCAD-Simulation/crates/ui-egui/src/describe.rs
T
jyvikoandClaude Opus 5.5 1f85a8caa3 ui: typed form fields for label lists, point lists, multi-choices and number-or-auto (#169)
Four field kinds in the schema, each checked and described in JSON Schema: labels (nodes or
elements, typed as 1-5, 8 with Use selection taking the viewport's picks), points (rows of
x, y, z, added and removed), multi (checkboxes: coupling DOFs, step output) and number or
auto (the deformed scale). Node and element lists, probe and Delaunay points and block
corners use them; the remaining JSON fields hold geometry-specific shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00

342 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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));
}
Some(command_rows(model, &kind.command("create"), obj))
}
/// Parameters of `command` as (label, text) rows in its form's words and units; fields its form
/// does not show stay readable by their key.
pub fn command_rows(model: &Model, command: &str, obj: &Map<String, Value>) -> Vec<(String, String)> {
let form = fieldcraft_engine::find_command(command).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);
}
for (k, field) in obj {
if k != "name" && !used.iter().any(|u| u == k) {
out.push((sentence(k), crate::context::value_text(field)));
}
}
out
}
/// The seed on `edge` as (label, text) rows: the edge, then `mesh.seed.edge`'s fields.
pub fn seed_rows(model: &Model, seed: &fieldcraft_model::Seed) -> Vec<(String, String)> {
let mut out = vec![("Edge".to_string(), format!("{} · {}", seed.edge.body, seed.edge.entity.name().unwrap_or_default()))];
let params = crate::menu::seed_params(seed).unwrap_or_default();
if let (Some(fields), Some(obj)) = (fieldcraft_engine::find_command("mesh.seed.edge").and_then(|s| s.form), params.as_object()) {
let mut obj = obj.clone();
obj.remove("edges");
object(fields, &obj, model.units(), &mut out, &mut Vec::new(), 0);
}
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)),
// `Bracket.3: Tet, Bracket.7: Swept`.
Kind::RefChoices(_, options) => v.as_object().map_or_else(
|| crate::context::value_text(v),
|o| {
o.iter()
.map(|(k, x)| {
let s = x.as_str().unwrap_or_default();
format!("{k}: {}", options.iter().find(|(o, _)| *o == s).map_or(s, |(_, l)| *l))
})
.collect::<Vec<_>>()
.join(", ")
},
),
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::Labels(_) => match v.as_array().and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>()) {
Some(l) => crate::fields::format_labels(&l),
None => crate::context::value_text(v),
},
Kind::Points { .. } => v.as_array().map_or_else(|| crate::context::value_text(v), |a| plural(a.len(), "point")),
Kind::Multi(options) => {
list(v, |x| options.iter().find(|(o, _)| fieldcraft_engine::schema::multi_value(o) == *x).map(|(_, l)| (*l).to_string()))
}
Kind::NumberOrAuto { .. } => match v {
Value::String(s) => s.clone(),
_ => v.as_f64().map_or_else(|| crate::context::value_text(v), |x| with_unit(x, unit.as_deref())),
},
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}");
}
}