ui: one number or one per axis for conductivity, expansion and point mass

A PerAxis field kind: one value for every direction, or Per axis ticked
for one per axis. Thermal conductivity and expansion and the point mass
use it instead of a JSON box (#449).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 01:38:26 -04:00
1 parent b18195ff89
commit e4d1f6c7da
4 files changed
+106 -9

No files matched your search

+10 -7
View File
@@ -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"),
+53 -1
View File
@@ -112,6 +112,13 @@ pub enum Kind {
dim: Dim,
min: Option<f64>,
},
/// 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<f64>,
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<Dim> {
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<f64>) -> 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<f64>, 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<Problem
(_, Some(_)) => 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::<Option<Vec<_>>>(),
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!({}));
+8
View File
@@ -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::<Vec<_>>()
.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())),
+35 -1
View File
@@ -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<Value> {
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<Value> {
@@ -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,