diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 7e45b3b..a57b935 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -661,9 +661,9 @@ const PROCEDURE: Field = variants( flag("line_search", "Line search") .help("Scale each Newton correction to reduce the residual; helps with hard convergence.") .def("false"), - json("load_path", "Load path") - .help("How the load factor varies through the step, as JSON pairs of step fraction and factor; a linear ramp when empty.") - .hint("[[fraction, factor], …]; a ramp when empty"), + table("load_path", "Load path", D::None, &["fraction", "factor"], fieldcraft_model::MAX_LOAD_PATH_POINTS) + .help("How the load factor varies through the step: rows of step fraction (0 to 1) and load factor; a linear ramp when empty.") + .hint("a ramp when empty"), group( "imperfection", "Imperfection", diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index baeebae..b7b892f 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -97,9 +97,11 @@ pub enum Kind { }, /// A list of mesh labels, nodes or elements (at most [`MAX_LABELS`]). Labels(LabelOf), - /// A list of points `[x, y, z]`, at most `max`. + /// A list of points `[x, y, z]`, or of number rows with these `columns` (a load path's + /// `[fraction, factor]` pairs), at most `max`. Points { dim: Dim, + columns: &'static [&'static str], max: usize, }, /// Any of these values, as a list: (the value as JSON text, label), e.g. `("1", "UX")` or @@ -489,9 +491,15 @@ pub const fn rows(key: &'static str, label: &'static str, fields: &'static [Fiel pub const fn labels(key: &'static str, label: &'static str, of: LabelOf) -> Field { field(key, label, Kind::Labels(of)) } +/// The columns of a point, `[x, y, z]`. +pub const XYZ: &[&str] = &["x", "y", "z"]; /// A list of points `[x, y, z]`. pub const fn points(key: &'static str, label: &'static str, dim: Dim, max: usize) -> Field { - field(key, label, Kind::Points { dim, max }) + field(key, label, Kind::Points { dim, columns: XYZ, max }) +} +/// A table: a list of number rows with these columns. +pub const fn table(key: &'static str, label: &'static str, dim: Dim, columns: &'static [&'static str], max: usize) -> Field { + field(key, label, Kind::Points { dim, columns, max }) } /// Any of `options` (each a value as JSON text, and a label), as a list. pub const fn multi(key: &'static str, label: &'static str, options: &'static [(&'static str, &'static str)]) -> Field { @@ -1016,14 +1024,16 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec= 1 && n <= u64::from(u32::MAX))) => None, _ => Some(ProblemKind::NotAList { max: MAX_LABELS, what: "labels" }), }, - Kind::Points { max, .. } => match v.as_array() { + Kind::Points { columns, max, .. } => match v.as_array() { Some(a) if a.len() <= max - && a.iter().all(|p| p.as_array().is_some_and(|c| c.len() == 3 && c.iter().all(|x| x.as_f64().is_some_and(f64::is_finite)))) => + && a.iter().all(|p| { + p.as_array().is_some_and(|c| c.len() == columns.len() && c.iter().all(|x| x.as_f64().is_some_and(f64::is_finite))) + }) => { None } - _ => Some(ProblemKind::NotAList { max, what: "points [x, y, z]" }), + _ => Some(ProblemKind::NotAList { max, what: if columns == XYZ { "points [x, y, z]" } else { "rows of numbers" } }), }, Kind::Multi(options) => match v.as_array() { Some(a) if a.iter().all(|x| options.iter().any(|(o, _)| multi_value(o) == *x)) => None, @@ -1144,8 +1154,9 @@ fn field_schema(f: &Field, depth: usize) -> Value { Kind::Labels(of) => { json!({"type": "array", "items": {"type": "integer", "minimum": 1, "maximum": u32::MAX}, "maxItems": MAX_LABELS, "x-labels": of.label()}) } - Kind::Points { dim, max } => { - let mut item = json!({"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}); + Kind::Points { dim, columns, max } => { + let n = columns.len(); + let mut item = json!({"type": "array", "items": {"type": "number"}, "minItems": n, "maxItems": n, "x-columns": columns}); if dim != Dim::None { item["x-dimension"] = json!(format!("{dim:?}").to_lowercase()); } @@ -1246,6 +1257,19 @@ mod tests { assert_eq!(s["properties"]["scale"]["oneOf"][1]["const"], "auto"); } + /// A table is a list of number rows of its column count: a load path's [fraction, factor]. + #[test] + fn table_rows_have_its_columns() { + const F: &[Field] = &[table("path", "Path", Dim::None, &["fraction", "factor"], 3)]; + assert!(problems(F, &json!({"path": [[0, 0], [0.5, 1], [1, 1]]})).is_empty()); + for bad in [json!({"path": [[0, 0, 0]]}), json!({"path": [[0]]}), json!({"path": [[0, 0], [0, 0], [0, 0], [1, 1]]})] { + assert_eq!(problems(F, &bad).len(), 1, "{bad}"); + } + let s = json_schema(F); + assert_eq!(s["properties"]["path"]["items"]["minItems"], 2); + assert_eq!(s["properties"]["path"]["items"]["x-columns"], json!(["fraction", "factor"])); + } + #[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 c850431..a82e8fe 100644 --- a/crates/ui-egui/src/describe.rs +++ b/crates/ui-egui/src/describe.rs @@ -4,7 +4,7 @@ //! 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_engine::schema::{self, Field, Kind}; use fieldcraft_model::{Model, UnitSystem}; use serde_json::{Map, Value}; @@ -183,7 +183,9 @@ fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String { 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::Points { columns, .. } => { + v.as_array().map_or_else(|| crate::context::value_text(v), |a| plural(a.len(), if columns == schema::XYZ { "point" } else { "row" })) + } Kind::Multi(options) => { list(v, |x| options.iter().find(|(o, _)| fieldcraft_engine::schema::multi_value(o) == *x).map(|(_, l)| (*l).to_string())) } diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs index 1ae3950..7c0de77 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -330,7 +330,7 @@ impl Form<'_> { } Kind::Json => self.json(ui, id, value, f.hint), Kind::Labels(of) => self.labels(ui, id, value, of, f.hint), - Kind::Points { max, .. } => self.points(ui, id, value, f, max), + 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::Unit | Kind::Group(_) | Kind::Variants { .. } | Kind::Rows { .. } => None, @@ -584,7 +584,7 @@ impl Form<'_> { } /// A list of points: a row of x, y, z each, removable, and Add point. - fn points(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, f: &Field, max: usize) -> Option { + fn points(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, f: &Field, columns: &[&str], max: usize) -> Option { let current: Vec = match value { None | Some(Value::Null) => Vec::new(), Some(Value::Array(a)) if a.iter().all(Value::is_array) => a.clone(), @@ -602,10 +602,10 @@ impl Form<'_> { ui.vertical(|ui| { for (i, p) in current.iter().enumerate() { ui.horizontal(|ui| { - if ui.small_button("×").on_hover_text("Remove this point").clicked() { + if ui.small_button("×").on_hover_text("Remove this row").clicked() { remove = Some(i); } - if let Some(v) = self.vector(ui, id.with(i), Some(p), f, &["x", "y", "z"], false) + if let Some(v) = self.vector(ui, id.with(i), Some(p), f, columns, false) && let Some(slot) = next.get_mut(i) { *slot = v; @@ -613,8 +613,14 @@ impl Form<'_> { } }); } - if ui.add_enabled(current.len() < max.min(MAX_ROWS), egui::Button::new(RichText::new("Add point").small())).clicked() { - next.push(json!([0.0, 0.0, 0.0])); + if ui + .add_enabled( + current.len() < max.min(MAX_ROWS), + egui::Button::new(RichText::new(if columns == schema::XYZ { "Add point" } else { "Add row" }).small()), + ) + .clicked() + { + next.push(Value::Array(vec![json!(0.0); columns.len()])); changed = true; } });