diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index a57b935..9a44813 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -186,18 +186,19 @@ const HYPERELASTIC: Field = variants( .help("A large-strain rubber model (nonlinear steps), built on the isotropic constants.") .hint("large strain in nonlinear steps, from the isotropic constants"); +/// The material axes 1, 2, 3 (the global X, Y, Z until material orientations land). +const AXES_123: &[&str] = &["1", "2", "3"]; + const THERMAL: Field = group( "thermal", "Thermal", &[ - json("conductivity", "Conductivity") - .help("Thermal conductivity, one value or one per axis. Steel ≈ 50 W/(m·K), i.e. 50 in mm-t-s-N.") - .hint("k or [k1, k2, k3]"), + per_axis("conductivity", "Conductivity", D::Conductivity, Some(0.0), AXES_123) + .help("Thermal conductivity, one value or one per material axis. Steel ≈ 50 W/(m·K), i.e. 50 in mm-t-s-N."), positive("specific_heat", "Specific heat", D::SpecificHeat) .help("Heat that raises a unit mass by one degree. Steel ≈ 460 J/(kg·K), 4.6e8 in mm-t-s."), - json("expansion", "Expansion") - .help("Thermal expansion coefficient, one value or one per axis, in 1/K. Steel ≈ 1.2e-5, aluminium ≈ 2.3e-5.") - .hint("α or [α1, α2, α3], 1/K"), + per_axis("expansion", "Expansion", D::Expansion, None, AXES_123) + .help("Thermal expansion coefficient, one value or one per material axis. Steel ≈ 1.2e-5, aluminium ≈ 2.3e-5."), num("reference_temperature", "Reference temperature", D::Temperature).help("The temperature at which there is no thermal strain.").def("0"), ], ) @@ -371,7 +372,9 @@ pub const COUPLING: &[Field] = &[ pub const MASS: &[Field] = &[ NAME, TARGET, - positive("mass", "Mass", D::Mass).help("A lumped mass at each node of the target.").hint("per node; [mx, my, mz] in JSON"), + per_axis("mass", "Mass", D::Mass, Some(0.0), XYZ) + .help("A lumped mass at each node of the target: one value, or one per direction X, Y, Z.") + .hint("per node"), vector("inertia", "Inertia", D::Inertia, &["I11", "I22", "I33", "I12", "I13", "I23"]) .help("Rotary inertia about the reference point: three moments and three products.") .hint("about the node; needs a reference point"), diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index b7b892f..27b5c6f 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -112,6 +112,13 @@ pub enum Kind { dim: Dim, min: Option, }, + /// One number for every direction, or one per axis (`labels`, e.g. the material axes + /// 1, 2, 3), each at least `min`. + PerAxis { + dim: Dim, + min: Option, + labels: &'static [&'static str], + }, /// Any JSON: what the schema does not describe. Json, } @@ -390,7 +397,11 @@ impl Field { /// Its physical dimension, for a number or a vector of numbers. pub fn dim(&self) -> Option { match self.kind { - Kind::Number { dim, .. } | Kind::Vector { dim, .. } | Kind::Points { dim, .. } | Kind::NumberOrAuto { dim, .. } => Some(dim), + Kind::Number { dim, .. } + | Kind::Vector { dim, .. } + | Kind::Points { dim, .. } + | Kind::NumberOrAuto { dim, .. } + | Kind::PerAxis { dim, .. } => Some(dim), _ => None, } } @@ -509,6 +520,10 @@ pub const fn multi(key: &'static str, label: &'static str, options: &'static [(& pub const fn number_or_auto(key: &'static str, label: &'static str, dim: Dim, min: Option) -> Field { field(key, label, Kind::NumberOrAuto { dim, min }) } +/// One number at least `min`, or one per axis of `labels`. +pub const fn per_axis(key: &'static str, label: &'static str, dim: Dim, min: Option, labels: &'static [&'static str]) -> Field { + field(key, label, Kind::PerAxis { dim, min, labels }) +} pub const fn json(key: &'static str, label: &'static str) -> Field { field(key, label, Kind::Json) } @@ -1045,6 +1060,18 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec Some(ProblemKind::Below(min.unwrap_or(0.0))), _ => Some(ProblemKind::Invalid("a number or \"auto\"".into())), }, + Kind::PerAxis { min, labels, .. } => { + let values = match v { + Value::Array(a) if a.len() == labels.len() => a.iter().map(|x| x.as_f64().filter(|x| x.is_finite())).collect::>>(), + Value::Array(_) => None, + _ => v.as_f64().filter(|x| x.is_finite()).map(|x| vec![x]), + }; + match values { + Some(xs) if xs.iter().all(|&x| min.is_none_or(|lo| x >= lo)) => None, + Some(_) => Some(ProblemKind::Below(min.unwrap_or(0.0))), + None => Some(ProblemKind::Invalid(format!("a number, or {} numbers (one per axis)", labels.len()))), + } + } Kind::Rows { fields, max } => match v.as_array() { Some(a) if a.len() <= max => { for (i, row) in a.iter().enumerate() { @@ -1172,6 +1199,18 @@ fn field_schema(f: &Field, depth: usize) -> Value { } json!({"oneOf": [n, {"const": "auto"}]}) } + Kind::PerAxis { dim, min, labels } => { + let mut n = json!({"type": "number"}); + if let Some(lo) = min { + n["minimum"] = json!(lo); + } + let mut s = + json!({"oneOf": [n.clone(), {"type": "array", "items": n, "minItems": labels.len(), "maxItems": labels.len(), "x-labels": labels}]}); + if dim != Dim::None { + s["x-dimension"] = json!(format!("{dim:?}").to_lowercase()); + } + s + } _ => json!({}), }; s["title"] = json!(f.label); @@ -1270,6 +1309,19 @@ mod tests { assert_eq!(s["properties"]["path"]["items"]["x-columns"], json!(["fraction", "factor"])); } + /// A per-axis field takes one number or one per axis, each at least its minimum. + #[test] + fn per_axis_takes_one_number_or_one_per_axis() { + const F: &[Field] = &[per_axis("k", "K", Dim::Conductivity, Some(0.0), &["1", "2", "3"])]; + assert!(problems(F, &json!({"k": 50})).is_empty()); + assert!(problems(F, &json!({"k": [50, 20, 20.5]})).is_empty()); + for bad in [json!({"k": -1}), json!({"k": [1, 2]}), json!({"k": [1, 2, -3]}), json!({"k": "x"}), json!({"k": [1, 2, "3"]})] { + assert_eq!(problems(F, &bad).len(), 1, "{bad}"); + } + let s = json_schema(F); + assert_eq!(s["properties"]["k"]["oneOf"][1]["maxItems"], 3); + } + #[test] fn prefill_switch_and_check() { let p = prefill(LOADISH, &json!({})); diff --git a/crates/ui-egui/src/describe.rs b/crates/ui-egui/src/describe.rs index a82e8fe..d882b46 100644 --- a/crates/ui-egui/src/describe.rs +++ b/crates/ui-egui/src/describe.rs @@ -189,6 +189,14 @@ fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String { Kind::Multi(options) => { list(v, |x| options.iter().find(|(o, _)| fieldcraft_engine::schema::multi_value(o) == *x).map(|(_, l)| (*l).to_string())) } + Kind::PerAxis { .. } => match v { + Value::Array(a) => a + .iter() + .map(|x| x.as_f64().map_or_else(|| crate::context::value_text(x), |x| with_unit(x, unit.as_deref()))) + .collect::>() + .join(", "), + _ => v.as_f64().map_or_else(|| crate::context::value_text(v), |x| with_unit(x, unit.as_deref())), + }, 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())), diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs index 73497c4..10728c3 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -333,6 +333,7 @@ impl Form<'_> { Kind::Points { columns, max, .. } => self.points(ui, id, value, f, columns, max), Kind::Multi(options) => self.multi(ui, value, options), Kind::NumberOrAuto { .. } => self.number_or_auto(ui, id, value, f), + Kind::PerAxis { labels, .. } => self.per_axis(ui, id, value, f, labels), Kind::Unit | Kind::Group(_) | Kind::Variants { .. } | Kind::Rows { .. } => None, } } @@ -715,6 +716,39 @@ impl Form<'_> { out } + /// One number, or with Per axis ticked one per axis: ticking it copies the number to every + /// axis, unticking keeps the first axis's value. + fn per_axis(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, f: &Field, labels: &[&str]) -> Option { + let split = matches!(value, Some(Value::Array(_))); + let mut on = split; + let mut out = None; + ui.vertical(|ui| { + if ui.checkbox(&mut on, "Per axis").on_hover_text("A different value along each axis").changed() { + out = Some(match (on, value) { + (true, Some(v)) if v.is_number() => Value::Array(vec![v.clone(); labels.len()]), + (true, _) => Value::Null, + (false, Some(Value::Array(a))) => a.first().cloned().unwrap_or(Value::Null), + (false, _) => Value::Null, + }); + } + if split { + if let Some(v) = self.vector(ui, id.with("axes"), value, f, labels, false, None) { + out = Some(v); + } + } else { + ui.horizontal(|ui| { + let unit = f.unit_label(self.cx.units); + let width = (fill(ui) - fields::unit_width(ui, unit.as_deref())).max(FIELD_MIN); + if let Some(v) = self.number(ui, id, value, f.hint, false, width) { + out = Some(v); + } + fields::unit(ui, &self.style, unit.as_deref()); + }); + } + }); + out + } + /// A JSON value typed as text: kept while it does not parse. /// Empty, it shows `hint` (the expected format) greyed. fn json(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, hint: &str) -> Option { @@ -844,7 +878,7 @@ fn caption(component: &str) -> String { pub fn has_system_units(fields: &[Field], depth: usize) -> bool { depth <= MAX_DEPTH && fields.iter().any(|f| match f.kind { - Kind::Number { dim, .. } | Kind::Vector { dim, .. } => dim.follows_system(), + Kind::Number { dim, .. } | Kind::Vector { dim, .. } | Kind::PerAxis { dim, .. } => dim.follows_system(), Kind::Group(inner) | Kind::Rows { fields: inner, .. } | Kind::Either(inner) => has_system_units(inner, depth + 1), Kind::Variants { variants, .. } => variants.iter().any(|v| has_system_units(v.fields, depth + 1)), _ => false,