//! Values as people read them: a definition's fields with the labels and units of its form //! ([`rows`], the context panel's properties), and numbers rounded and grouped ([`quantity`]). //! The command's form schema ([`fieldcraft_engine::schema`]) knows each field's label, unit and //! choices, so a definition is described by the same words as the form that edits it, never as //! its JSON. use fieldcraft_engine::schema::{Field, Kind}; use fieldcraft_model::{Model, UnitSystem}; use serde_json::{Map, Value}; use crate::browser::Def; /// Lists longer than this are given by their length. const LIST: usize = 6; /// Nesting followed at most. const MAX_DEPTH: usize = 6; /// A number in a few significant figures: whole numbers below a billion in full, with thin spaces /// between groups of three digits ("210 000"); others as [`crate::results::number`]. pub fn quantity(v: f64) -> String { if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e9 { let digits = format!("{}", v.abs() as u64); let mut out = String::new(); for (i, c) in digits.chars().enumerate() { if i > 0 && (digits.len() - i) % 3 == 0 { out.push('\u{2009}'); } out.push(c); } return if v < 0.0 { format!("−{out}") } else { out }; } // Trailing zeros say nothing here: "0.3", "7.85e−9". let s = crate::results::number(v); let (mantissa, exponent) = s.split_once('e').map_or((s.as_str(), None), |(m, e)| (m, Some(e))); let mantissa = if mantissa.contains('.') { mantissa.trim_end_matches('0').trim_end_matches('.') } else { mantissa }; let out = exponent.map_or_else(|| mantissa.to_string(), |e| format!("{mantissa}e{e}")); out.replace('-', "−") } /// `v` with its unit, when it has one. fn with_unit(v: f64, unit: Option<&str>) -> String { match unit { Some(u) => format!("{} {u}", quantity(v)), None => quantity(v), } } /// The definition `name` of `kind` as (label, text) rows, in its form's words and units; `None` /// when it is gone. pub fn rows(model: &Model, kind: Def, name: &str) -> Option> { let v = crate::browser::definition(model, kind, name)?; let obj = v.as_object()?; if kind == Def::Set { return Some(set_rows(model, name, obj)); } 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) -> 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, units: UnitSystem, out: &mut Vec<(String, String)>, used: &mut Vec, depth: usize) { if depth > MAX_DEPTH { return; } for f in fields { if f.key == "name" { continue; } if let Kind::Variants { tag, variants } = f.kind && f.key.is_empty() { let Some(t) = obj.get(tag).and_then(Value::as_str) else { continue }; used.push(tag.to_string()); match variants.iter().find(|v| v.tag == t) { Some(v) => { out.push((f.label.to_string(), v.label.to_string())); object(v.fields, obj, units, out, used, depth + 1); } None => out.push((f.label.to_string(), t.to_string())), } continue; } let Some(value) = obj.get(f.key).filter(|v| !v.is_null()) else { continue }; used.push(f.key.to_string()); match f.kind { Kind::Group(inner) => { if let Some(o) = value.as_object() { object(inner, o, units, out, &mut Vec::new(), depth + 1); } } Kind::Variants { tag, variants } => { let Some(o) = value.as_object() else { continue }; let t = o.get(tag).and_then(Value::as_str).unwrap_or_default(); match variants.iter().find(|v| v.tag == t) { Some(v) => { out.push((f.label.to_string(), v.label.to_string())); object(v.fields, o, units, out, &mut Vec::new(), depth + 1); } None => out.push((f.label.to_string(), crate::context::value_text(value))), } } _ => out.push((f.label.to_string(), scalar(f, value, units))), } } } /// One field's value as text, with its unit. fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String { let unit = f.unit_label(units); match f.kind { Kind::Number { .. } => v.as_f64().map_or_else(|| crate::context::value_text(v), |x| with_unit(x, unit.as_deref())), Kind::Integer { .. } => v.as_i64().map_or_else(|| crate::context::value_text(v), |x| quantity(x as f64)), Kind::Bool => (if v.as_bool() == Some(true) { "yes" } else { "no" }).into(), Kind::Choice(options) => { let s = v.as_str().unwrap_or_default(); options.iter().find(|(o, _)| *o == s).map_or_else(|| s.to_string(), |(_, l)| (*l).to_string()) } Kind::Vector { labels, .. } => vector(labels, v, unit.as_deref()), Kind::Ref(_) | Kind::Text | Kind::Path { .. } => v.as_str().map_or_else(|| crate::context::value_text(v), str::to_string), Kind::RefList(_) => list(v, |x| x.as_str().map(str::to_string)), // `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::>() .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::>>()) { 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 = a .iter() .enumerate() .map(|(i, x)| format!("{} {}", labels.get(i).copied().unwrap_or("?"), x.as_f64().map_or("—".into(), quantity))) .collect(); return match unit { Some(u) => format!("{} {u}", parts.join(", ")), None => parts.join(", "), }; } let mut out = set.join(", "); if !free.is_empty() { out.push_str(&format!("; {} free", free.join(", "))); } out } fn list(v: &Value, item: impl Fn(&Value) -> Option) -> String { let Some(a) = v.as_array() else { return crate::context::value_text(v) }; let names: Vec = a.iter().filter_map(item).collect(); if names.len() > LIST { format!("{}, … ({} in all)", names[..LIST].join(", "), names.len()) } else { names.join(", ") } } fn plural(n: usize, word: &str) -> String { if n == 1 { format!("1 {word}") } else { format!("{n} {word}s") } } /// A set: what it is defined by (design entities by kind and body, mesh labels) and what that /// resolves to in the mesh. fn set_rows(model: &Model, name: &str, obj: &Map) -> Vec<(String, String)> { let mut out = Vec::new(); if let Some(geometry) = obj.get("geometry").and_then(Value::as_array).filter(|g| !g.is_empty()) { let mut kinds: std::collections::BTreeMap<&str, usize> = Default::default(); let mut bodies = std::collections::BTreeSet::new(); for g in geometry { let kind = ["face", "edge", "vertex"].into_iter().find(|k| g.get(*k).is_some()).unwrap_or("body"); *kinds.entry(kind).or_default() += 1; if let Some(b) = g.get("body").and_then(Value::as_str) { bodies.insert(b); } } let what: Vec = kinds .iter() .map(|(k, n)| { if *k == "vertex" && *n != 1 { format!("{n} vertices") } else if *k == "body" && *n != 1 { format!("{n} bodies") } else { plural(*n, k) } }) .collect(); let on = if bodies.len() == 1 && !kinds.contains_key("body") { bodies.iter().next().map(|b| format!(" of {b}")).unwrap_or_default() } else if !kinds.contains_key("body") { format!(" on {} bodies", bodies.len()) } else { String::new() }; out.push(("Geometry".into(), format!("{}{on}", what.join(", ")))); } for (key, label, word) in [("nodes", "Nodes", "node"), ("elements", "Elements", "element"), ("faces", "Element faces", "face"), ("edges", "Element edges", "edge")] { if let Some(a) = obj.get(key).and_then(Value::as_array).filter(|a| !a.is_empty()) { out.push((label.into(), plural(a.len(), word))); } } if let Some(r) = model.resolve_set_partial(name) { let mut parts = vec![plural(r.nodes.len(), "node")]; if !r.elements.is_empty() { parts.push(plural(r.elements.len(), "element")); } if !r.faces.is_empty() { parts.push(plural(r.faces.len(), "element face")); } if !r.edges.is_empty() { parts.push(plural(r.edges.len(), "element edge")); } out.push(("In the mesh".into(), parts.join(", "))); } out } #[cfg(test)] mod tests { use fieldcraft_engine::Session; use super::*; fn text(rows: &[(String, String)]) -> String { rows.iter().map(|(k, v)| format!("{k}: {v}")).collect::>().join("\n") } #[test] fn numbers_are_grouped_and_rounded() { assert_eq!(quantity(210000.0), "210\u{2009}000"); assert_eq!(quantity(-1000.0), "−1\u{2009}000"); assert_eq!(quantity(0.3), "0.3"); assert_eq!(quantity(-2.5), "−2.5"); assert_eq!(quantity(7.85e-9), "7.85e−9"); } /// Definitions read in their forms' words and units, never as JSON (#335). #[test] fn definitions_read_with_labels_and_units() { let mut s = Session::default(); s.run_script(&fieldcraft_engine::sample::cantilever()).unwrap(); let m = &s.model; let steel = text(&rows(m, Def::Material, "Steel").unwrap()); assert!(steel.contains("Elasticity: Isotropic") && steel.contains("MPa") && steel.contains("t/mm³"), "{steel}"); assert!(!steel.contains('{'), "{steel}"); let load = Def::Load.names(m).into_iter().next().unwrap(); let load = text(&rows(m, Def::Load, &load).unwrap()); assert!(load.contains("Type: Force") && load.contains("Target: Set ") && load.contains(" N"), "{load}"); assert!(!load.contains('{') && !load.contains('['), "{load}"); let set = Def::Set.names(m).into_iter().next().unwrap(); let set = text(&rows(m, Def::Set, &set).unwrap()); assert!(set.contains("In the mesh: "), "{set}"); let mut s = Session::default(); s.run_script(&fieldcraft_engine::sample::rubber_seal()).unwrap(); let press = text(&rows(&s.model, Def::BoundaryCondition, "Press").unwrap()); assert!(press.contains("Type: Displacement") && press.contains("U3 −2.5 mm") && press.contains("UR1, UR2, UR3 free"), "{press}"); } }