diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 63776b0..db597e6 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -54,8 +54,10 @@ pub const SET: &[Field] = &[ ) .help("Bodies of the design, or their named faces, edges or vertices. Kept by name, so the set survives remeshing.") .hint("whole bodies, or their faces, edges or vertices"), - json("nodes", "Nodes").help("Mesh node labels, as a JSON list.").hint("[labels]"), - json("elements", "Elements").help("Mesh element labels, as a JSON list.").hint("[labels]"), + labels("nodes", "Nodes", LabelOf::Node).help("Mesh node labels; Use selection takes the picked nodes.").hint("labels: 1, 2, 10-20"), + labels("elements", "Elements", LabelOf::Element) + .help("Mesh element labels; Use selection takes the picked elements.") + .hint("labels: 1, 2, 10-20"), json("faces", "Element faces").help("Element faces, as a JSON list of element label and local face number.").hint("[{\"element\", \"face\"}]"), json("edges", "Element edges").help("Element edges, as a JSON list of element label and local edge number.").hint("[{\"element\", \"edge\"}]"), ]; @@ -351,9 +353,9 @@ pub const REFERENCE_POINT: &[Field] = &[ int("label", "Node label", 0, u32::MAX as i64).help("The node label written to decks.").hint("0: the highest free label"), ]; -const DOFS: Field = json("dofs", "Degrees of freedom") +const DOFS: Field = multi("dofs", "Degrees of freedom", &[("1", "UX"), ("2", "UY"), ("3", "UZ"), ("4", "URX"), ("5", "URY"), ("6", "URZ")]) .help("Which of the six DOFs to couple (1–3 translations, 4–6 rotations); all when empty.") - .hint("[1, …, 6], default all"); + .hint("all"); pub const COUPLING: &[Field] = &[ NAME, @@ -809,9 +811,20 @@ const PROCEDURE: Field = variants( ) .help("What the step solves: linear or nonlinear static, buckling, frequencies, explicit dynamics, heat transfer or flow."); -const OUTPUT: Field = json("output", "Output") - .help("Which results to keep, as JSON; by procedure when empty.") - .hint("[\"displacement\", \"stress\", …]; by procedure when empty"); +const OUTPUT: Field = multi( + "output", + "Output", + &[ + ("\"displacement\"", "Displacement"), + ("\"reaction_force\"", "Reaction force"), + ("\"stress\"", "Stress"), + ("\"strain\"", "Strain"), + ("\"temperature\"", "Temperature"), + ("\"heat_flux\"", "Heat flux"), + ], +) +.help("Which results to keep; by procedure when none is ticked.") +.hint("by procedure"); const STEP_BCS: Field = references("boundary_conditions", "Boundary conditions", C::BoundaryCondition).help("The boundary conditions active in the step."); const STEP_LOADS: Field = references("loads", "Loads", C::Load).help("The loads active in the step."); @@ -1168,7 +1181,7 @@ pub const BLOCK: &[Field] = &[ .help("The element type of the block.") .req() .def("\"hex8\""), - json("corners", "Corners").help("The eight corner points (four for 2D), as JSON.").req().hint("[[x, y, z] × 8] (2D: × 4 at z = 0)"), + points("corners", "Corners", D::Length, 8).help("The eight corner points (four for 2D, at z = 0).").req(), json("divisions", "Divisions").help("Elements along each edge direction, as JSON.").req().hint("[n12, n14, n15] (2D: [n12, n14])"), json("bias", "Bias").help("Element size ratio along each direction, as JSON; 1 is uniform.").hint("[1, 1, 1]"), json("edges", "Curved and named edges").help("Edge curves and names, as JSON."), @@ -1193,7 +1206,7 @@ pub const PLANAR: &[Field] = &[ ]; pub const QUALITY: &[Field] = &[ - json("elements", "Elements").help("The elements to measure, as JSON labels; all when empty.").hint("[labels]; all when empty"), + labels("elements", "Elements", LabelOf::Element).help("The elements to measure; all when empty.").hint("all"), int("bins", "Histogram bins", 1, 1000).help("Number of bars in the quality histogram.").def("10"), ]; @@ -1233,7 +1246,7 @@ pub const ELEMENT_ADD: &[Field] = &[ .help("The element type.") .req() .def("\"hex8\""), - json("nodes", "Nodes").help("The element's node labels in order, as JSON.").req().hint("[labels]"), + labels("nodes", "Nodes", LabelOf::Node).help("The element's node labels in order.").req().hint("labels in order: 1, 2, 3, 4"), ]; pub const SEED_EDGE: &[Field] = &[ @@ -1262,7 +1275,7 @@ pub const SEED_EDGE: &[Field] = &[ pub const SEED_CLEAR: &[Field] = &[json("edges", "Edges").help("The edges to clear, as JSON; every seed when empty.").hint("every seed")]; -pub const DELAUNAY_POINTS: &[Field] = &[json("points", "Points").help("The points to triangulate, as JSON.").req().hint("[[x, y, z], …]")]; +pub const DELAUNAY_POINTS: &[Field] = &[points("points", "Points", D::Length, 20_000).help("The points to triangulate.").req()]; pub const TAG: &[Field] = &[ group( @@ -1277,10 +1290,10 @@ pub const TAG: &[Field] = &[ ) .help("The geometry the mesh entities are tagged with.") .req(), - json("elements", "Elements").help("Element labels, as JSON."), + labels("elements", "Elements", LabelOf::Element).help("Element labels."), json("faces", "Element faces").help("Element faces, as JSON."), json("edges", "Element edges").help("Element edges, as JSON."), - json("nodes", "Nodes").help("Node labels, as JSON."), + labels("nodes", "Nodes", LabelOf::Node).help("Node labels."), ]; // ---- Geometry and files ---- @@ -1419,7 +1432,7 @@ pub const CENTRE: &[Field] = &[ ], ) .help("Faces or edges (a hole's faces or rim give its centre), or one vertex."), - json("nodes", "Nodes").help("Mesh node labels, as a JSON list: their mean.").hint("[labels]"), + labels("nodes", "Nodes", LabelOf::Node).help("Mesh node labels: their mean.").hint("labels: 1, 2, 10-20"), ]; pub const DATUM_POINT: &[Field] = &[ NAME, @@ -1621,13 +1634,13 @@ pub const SOLVE_FLUID: &[Field] = &[ ]; pub const SOLVE_PROBE: &[Field] = &[ SOLVED.req(), - json("nodes", "Nodes").help("Node labels to read, as JSON.").hint("[labels]"), + labels("nodes", "Nodes", LabelOf::Node).help("Node labels to read.").hint("labels: 1, 2, 10-20"), reference("set", "Set", C::Set).help("A set whose nodes to read."), - json("at", "Points").help("Points to read at the nearest nodes, as JSON.").hint("[[x, y, z], …]: nearest nodes"), + points("at", "Points", D::Length, 10_000).help("Points to read at the nearest nodes."), int("mode", "Mode", 1, 1_000_000).help("The mode to read, for frequency and buckling steps."), int("frame", "Frame", 0, 1_000_000_000).help("The frame to read, for explicit and transient steps."), flag("elements", "Element results").help("Also read element results (stresses) around the nodes."), - json("points", "Fluid points").help("Points in the fluid to read, as JSON.").hint("[[x, y, z], …]"), + points("points", "Fluid points", D::Length, 10_000).help("Points in the fluid to read."), json("forces", "Fluid walls").help("Walls whose fluid forces to sum, as JSON.").hint("[\"x_min\", \"F2:top\"]"), ]; @@ -1636,7 +1649,7 @@ pub const SOLVE_PROBE: &[Field] = &[ pub const DEFORMED: &[Field] = &[ SOLVED, flag("show", "Show").help("Show the deformed shape.").def("true"), - json("scale", "Scale").help("Displacement magnification: auto, or a factor (1 is true scale).").def("\"auto\"").hint("\"auto\" or a factor"), + number_or_auto("scale", "Scale", D::None, Some(0.0)).help("Displacement magnification: auto, or a factor (1 is true scale).").def("\"auto\""), ]; pub const CONTOUR: &[Field] = &[ SOLVED, @@ -1692,7 +1705,7 @@ pub const EXPORT_CSV: &[Field] = &[ path("path", "File", true).help("The CSV file to write.").req(), SOLVED, references("sets", "Sets", C::Set).help("Reactions: the sets to sum; the step's BC sets when empty.").hint("the step's BC sets"), - json("nodes", "Nodes").help("Probe: the node labels, e.g. [1, 2, 3]."), + labels("nodes", "Nodes", LabelOf::Node).help("Probe: the node labels."), ]; pub const REACTIONS: &[Field] = &[ SOLVED, diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index ca5b2bf..baeebae 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -95,10 +95,44 @@ pub enum Kind { fields: &'static [Field], max: usize, }, + /// A list of mesh labels, nodes or elements (at most [`MAX_LABELS`]). + Labels(LabelOf), + /// A list of points `[x, y, z]`, at most `max`. + Points { + dim: Dim, + max: usize, + }, + /// Any of these values, as a list: (the value as JSON text, label), e.g. `("1", "UX")` or + /// `("\"stress\"", "Stress")`. + Multi(&'static [(&'static str, &'static str)]), + /// A number at least `min`, or the string `"auto"`. + NumberOrAuto { + dim: Dim, + min: Option, + }, /// Any JSON: what the schema does not describe. Json, } +/// What a [`Kind::Labels`] list labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LabelOf { + Node, + Element, +} + +impl LabelOf { + pub fn label(self) -> &'static str { + match self { + LabelOf::Node => "node", + LabelOf::Element => "element", + } + } +} + +/// Most labels a [`Kind::Labels`] list holds. +pub const MAX_LABELS: usize = 1_000_000; + /// One variant of [`Kind::Variants`]. #[derive(Debug, Clone, Copy)] pub struct Variant { @@ -354,7 +388,7 @@ 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, .. } => Some(dim), + Kind::Number { dim, .. } | Kind::Vector { dim, .. } | Kind::Points { dim, .. } | Kind::NumberOrAuto { dim, .. } => Some(dim), _ => None, } } @@ -451,6 +485,22 @@ pub const fn unit(key: &'static str, label: &'static str) -> Field { pub const fn rows(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field { field(key, label, Kind::Rows { fields, max: MAX_ROWS }) } +/// A list of node or element labels. +pub const fn labels(key: &'static str, label: &'static str, of: LabelOf) -> Field { + field(key, label, Kind::Labels(of)) +} +/// 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 }) +} +/// 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 { + field(key, label, Kind::Multi(options)) +} +/// A number at least `min`, or "auto". +pub const fn number_or_auto(key: &'static str, label: &'static str, dim: Dim, min: Option) -> Field { + field(key, label, Kind::NumberOrAuto { dim, min }) +} pub const fn json(key: &'static str, label: &'static str) -> Field { field(key, label, Kind::Json) } @@ -461,6 +511,11 @@ pub const fn variant(tag: &'static str, label: &'static str, fields: &'static [F /// The `name` of a new or edited definition. pub const NAME: Field = text("name", "Name").req().help("A name for it, unique among its kind; other definitions refer to it by this name."); +/// A [`Kind::Multi`] option's value: its JSON text parsed (text that is not JSON is a string). +pub fn multi_value(option: &str) -> Value { + serde_json::from_str(option).unwrap_or_else(|_| Value::String(option.to_string())) +} + impl Kind { /// The variant of `tag`, for [`Kind::Variants`]. pub fn variant(&self, tag: &str) -> Option<&'static Variant> { @@ -670,6 +725,7 @@ impl Problem { format!("one of {}", options.iter().map(|(o, _)| *o).collect::>().join(", ")) } Some(Kind::Either(options)) => format!("one of {}", options.iter().map(|o| o.key).collect::>().join(", ")), + Some(Kind::Multi(options)) => format!("a list of {}", options.iter().map(|(o, _)| *o).collect::>().join(", ")), _ => "one of the choices".into(), }, ProblemKind::NotAVector { len } => format!("{len} numbers"), @@ -956,6 +1012,29 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec None, + Kind::Labels(_) => match v.as_array() { + Some(a) if a.len() <= MAX_LABELS && a.iter().all(|x| x.as_u64().is_some_and(|n| n >= 1 && n <= u64::from(u32::MAX))) => None, + _ => Some(ProblemKind::NotAList { max: MAX_LABELS, what: "labels" }), + }, + Kind::Points { 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)))) => + { + None + } + _ => Some(ProblemKind::NotAList { max, what: "points [x, y, z]" }), + }, + Kind::Multi(options) => match v.as_array() { + Some(a) if a.iter().all(|x| options.iter().any(|(o, _)| multi_value(o) == *x)) => None, + _ => Some(ProblemKind::NotAChoice), + }, + Kind::NumberOrAuto { min, .. } => match (v, v.as_f64().filter(|x| x.is_finite())) { + (Value::String(s), _) if s == "auto" => None, + (_, Some(x)) if min.is_none_or(|lo| x >= lo) => None, + (_, Some(_)) => Some(ProblemKind::Below(min.unwrap_or(0.0))), + _ => Some(ProblemKind::Invalid("a number or \"auto\"".into())), + }, Kind::Rows { fields, max } => match v.as_array() { Some(a) if a.len() <= max => { for (i, row) in a.iter().enumerate() { @@ -1062,6 +1141,26 @@ fn field_schema(f: &Field, depth: usize) -> Value { }).collect::>()}), Kind::Rows { fields, max } if depth <= MAX_DEPTH => json!({"type": "array", "items": object_schema(fields, depth + 1), "maxItems": max}), Kind::Unit => json!({"const": f.key}), + 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}); + if dim != Dim::None { + item["x-dimension"] = json!(format!("{dim:?}").to_lowercase()); + } + json!({"type": "array", "items": item, "maxItems": max}) + } + Kind::Multi(options) => { + json!({"type": "array", "items": {"enum": options.iter().map(|(o, _)| multi_value(o)).collect::>()}, "uniqueItems": true}) + } + Kind::NumberOrAuto { min, .. } => { + let mut n = json!({"type": "number"}); + if let Some(lo) = min { + n["minimum"] = json!(lo); + } + json!({"oneOf": [n, {"const": "auto"}]}) + } _ => json!({}), }; s["title"] = json!(f.label); @@ -1113,6 +1212,40 @@ mod tests { .def("\"force\""), ]; + /// The typed lists check their values and describe themselves in JSON Schema (#169). + #[test] + fn labels_points_multi_and_auto_check() { + const F: &[Field] = &[ + labels("nodes", "Nodes", LabelOf::Node), + points("at", "Points", Dim::Length, 2), + multi("dofs", "DOFs", &[("1", "UX"), ("2", "UY")]), + multi("output", "Output", &[("\"stress\"", "Stress")]), + number_or_auto("scale", "Scale", Dim::None, Some(0.0)), + ]; + assert!(problems(F, &json!({"nodes": [1, 5], "at": [[0, 0, 1e3]], "dofs": [2], "output": ["stress"], "scale": "auto"})).is_empty()); + assert!(problems(F, &json!({"scale": 2.5})).is_empty()); + for bad in [ + json!({"nodes": [0]}), + json!({"nodes": [1.5]}), + json!({"nodes": [u64::from(u32::MAX) + 1]}), + json!({"at": [[0, 0]]}), + json!({"at": [[0, 0, 0], [0, 0, 0], [0, 0, 0]]}), + json!({"at": [[0, 0, "x"]]}), + json!({"dofs": [3]}), + json!({"dofs": ["1"]}), + json!({"output": ["strain"]}), + json!({"scale": -1}), + json!({"scale": "big"}), + ] { + assert_eq!(problems(F, &bad).len(), 1, "{bad}"); + } + let s = json_schema(F); + assert_eq!(s["properties"]["dofs"]["items"]["enum"], json!([1, 2])); + assert_eq!(s["properties"]["output"]["items"]["enum"], json!(["stress"])); + assert_eq!(s["properties"]["at"]["maxItems"], 2); + assert_eq!(s["properties"]["scale"]["oneOf"][1]["const"], "auto"); + } + #[test] fn prefill_switch_and_check() { let p = prefill(LOADISH, &json!({})); diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index aba47da..cce5c96 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -171,6 +171,15 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { }; }); } + // The nodes and elements picked in the viewport, for the label lists' Use selection. + let mut picked = crate::form::Picked::default(); + for item in app.pick.selection.items() { + match item { + selection::Item::Node(n) => picked.nodes.push(u64::from(*n)), + selection::Item::Element(e) => picked.elements.push(u64::from(*e)), + _ => {} + } + } let mut advanced = d.advanced; heading(ui, t, Level::Section, |ui| { ui.label(RichText::new("Parameters").size(12.0)); @@ -221,6 +230,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { attempted: d.attempted, refused, problems: &problems, + picked: picked.clone(), }; let out = crate::form::show(ui, t, &cx, fields, &mut obj); invalid = out.invalid; diff --git a/crates/ui-egui/src/describe.rs b/crates/ui-egui/src/describe.rs index 4ba0b7d..c850431 100644 --- a/crates/ui-egui/src/describe.rs +++ b/crates/ui-egui/src/describe.rs @@ -179,6 +179,18 @@ fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String { _ => 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), } } diff --git a/crates/ui-egui/src/fields.rs b/crates/ui-egui/src/fields.rs index a93b7d4..25d9cf9 100644 --- a/crates/ui-egui/src/fields.rs +++ b/crates/ui-egui/src/fields.rs @@ -188,6 +188,46 @@ pub fn text(ui: &mut Ui, s: &Style, id: Id, value: &mut String, hint: &str, widt ui.add(egui::TextEdit::singleline(value).id(id).desired_width(width).hint_text(RichText::new(hint).italics().color(s.dim))).changed() } +/// Labels as typed: runs of consecutive labels as `a-b`, the rest by commas ("1-5, 8, 10-12"). +pub fn format_labels(labels: &[u64]) -> String { + let mut out: Vec = Vec::new(); + let mut i = 0; + while let Some(&a) = labels.get(i) { + let mut j = i; + while labels.get(j + 1).is_some_and(|&n| Some(n) == labels.get(j).and_then(|m| m.checked_add(1))) { + j += 1; + } + let b = labels.get(j).copied().unwrap_or(a); + out.push(match j - i { + 0 => a.to_string(), + 1 => format!("{a}, {b}"), + _ => format!("{a}-{b}"), + }); + i = j + 1; + } + out.join(", ") +} + +/// Labels typed as numbers and `a-b` ranges, separated by commas or spaces, in the order typed; +/// `None` when something is not a label from 1, or when there would be more than `max`. +pub fn parse_labels(text: &str, max: usize) -> Option> { + let mut out = Vec::new(); + for part in text.split(|c: char| c == ',' || c.is_whitespace()).filter(|p| !p.is_empty()) { + let (a, b) = match part.split_once('-') { + Some((a, b)) => (a.parse::().ok()?, b.parse::().ok()?), + None => { + let n = part.parse::().ok()?; + (n, n) + } + }; + if a == 0 || b < a || out.len() as u64 + (b - a) >= max as u64 { + return None; + } + out.extend(a..=b); + } + Some(out) +} + /// A short label before a field ("X"), dim. pub fn caption(ui: &mut Ui, s: &Style, text: &str) { ui.label(RichText::new(text).small().color(s.dim)); @@ -204,6 +244,19 @@ pub fn unit(ui: &mut Ui, s: &Style, unit: Option<&str>) { mod tests { use super::*; + #[test] + fn labels_print_and_parse_as_ranges() { + assert_eq!(format_labels(&[1, 2, 3, 4, 5, 8, 10, 11]), "1-5, 8, 10, 11"); + assert_eq!(format_labels(&[]), ""); + assert_eq!(format_labels(&[7, 3, 4, 5]), "7, 3-5", "the order is kept"); + assert_eq!(parse_labels("1-5, 8 10,11", 100), Some(vec![1, 2, 3, 4, 5, 8, 10, 11])); + assert_eq!(parse_labels("", 100), Some(vec![])); + for bad in ["0", "x", "5-3", "1-", "-4", "1-1000"] { + assert_eq!(parse_labels(bad, 100), None, "{bad}"); + } + assert_eq!(parse_labels(&format_labels(&[3, 4, 5, 9]), 100), Some(vec![3, 4, 5, 9])); + } + #[test] fn numbers_print_as_typed() { assert_eq!(format_number(210000.0), "210000"); diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs index 647ae60..1ae3950 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -38,6 +38,15 @@ pub struct Context<'a> { pub refused: Option<(&'a str, &'a str)>, /// What is missing or wrong in the parameters ([`messages`]). pub problems: &'a [(String, String)], + /// The nodes and the elements picked in the viewport, for a label list's Use selection. + pub picked: Picked, +} + +/// The mesh labels picked in the viewport. +#[derive(Clone, Debug, Default)] +pub struct Picked { + pub nodes: Vec, + pub elements: Vec, } /// What a frame of the form did. @@ -320,6 +329,10 @@ 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::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, } } @@ -520,6 +533,142 @@ impl Form<'_> { } } + /// A list of node or element labels typed as numbers and ranges (`1-5, 8`), kept while it + /// does not parse; Use selection takes the labels picked in the viewport. + fn labels(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, of: schema::LabelOf, hint: &str) -> Option { + let current: Option> = match value { + None | Some(Value::Null) => Some(Vec::new()), + Some(v) => v.as_array().and_then(|a| a.iter().map(Value::as_u64).collect()), + }; + let Some(current) = current else { + self.json_only(ui); + return None; + }; + let shown = fields::format_labels(¤t); + let mut text = ui.data(|d| d.get_temp::<(String, String)>(id)).filter(|(_, over)| *over == shown).map_or_else(|| shown.clone(), |(t, _)| t); + let picked = match of { + schema::LabelOf::Node => self.cx.picked.nodes.clone(), + schema::LabelOf::Element => self.cx.picked.elements.clone(), + }; + let mut out = None; + ui.vertical(|ui| { + let parsed = fields::parse_labels(&text, schema::MAX_LABELS); + let mut edit = + egui::TextEdit::singleline(&mut text).id(id).desired_width(fill(ui)).hint_text(RichText::new(hint).italics().color(self.style.dim)); + if parsed.is_none() { + edit = edit.text_color(self.style.error); + self.out.invalid += 1; + } + if ui.add(edit).changed() + && let Some(l) = fields::parse_labels(&text, schema::MAX_LABELS) + { + out = Some(if l.is_empty() { Value::Null } else { json!(l) }); + } + let label = format!("Use selection ({} {}{})", picked.len(), of.label(), if picked.len() == 1 { "" } else { "s" }); + let tip = format!("The {}s picked in the viewport (Select: {}s)", of.label(), of.label()); + if ui + .add_enabled(!picked.is_empty(), egui::Button::new(RichText::new(label).small())) + .on_hover_text(&tip) + .on_disabled_hover_text(&tip) + .clicked() + { + text = fields::format_labels(&picked); + out = Some(json!(picked)); + } + }); + let over = out.as_ref().map_or(shown, |v| { + v.as_array().map_or_else(String::new, |a| fields::format_labels(&a.iter().filter_map(Value::as_u64).collect::>())) + }); + ui.data_mut(|d| d.insert_temp(id, (text, over))); + out + } + + /// 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 { + let current: Vec = match value { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(a)) if a.iter().all(Value::is_array) => a.clone(), + Some(_) => { + self.json_only(ui); + return None; + } + }; + if current.len() > MAX_ROWS { + self.json_only(ui); + return None; + } + let mut next = current.clone(); + let (mut changed, mut remove) = (false, None); + ui.vertical(|ui| { + for (i, p) in current.iter().enumerate() { + ui.horizontal(|ui| { + if ui.small_button("×").on_hover_text("Remove this point").clicked() { + remove = Some(i); + } + if let Some(v) = self.vector(ui, id.with(i), Some(p), f, &["x", "y", "z"], false) + && let Some(slot) = next.get_mut(i) + { + *slot = v; + changed = true; + } + }); + } + 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])); + changed = true; + } + }); + if let Some(i) = remove { + next.remove(i); + changed = true; + } + changed.then_some(if next.is_empty() { Value::Null } else { Value::Array(next) }) + } + + /// Any of `options`: a checkbox each; none ticked leaves the key out. + fn multi(&mut self, ui: &mut Ui, value: Option<&Value>, options: &[(&str, &str)]) -> Option { + let chosen: Vec = value.and_then(Value::as_array).cloned().unwrap_or_default(); + let mut out = None; + ui.horizontal_wrapped(|ui| { + for (o, label) in options { + let v = schema::multi_value(o); + let mut on = chosen.contains(&v); + if ui.checkbox(&mut on, *label).changed() { + // In the options' order. + let next: Vec = + options.iter().map(|(x, _)| schema::multi_value(x)).filter(|x| if *x == v { on } else { chosen.contains(x) }).collect(); + out = Some(if next.is_empty() { Value::Null } else { Value::Array(next) }); + } + } + }); + out + } + + /// A number, or Auto: the checkbox, then the number when it is off. + fn number_or_auto(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, f: &Field) -> Option { + let auto = match value { + None | Some(Value::Null) => f.default.is_some_and(|d| d.contains("auto")), + Some(Value::String(s)) => s == "auto", + Some(_) => false, + }; + let mut on = auto; + let mut out = None; + ui.horizontal(|ui| { + if ui.checkbox(&mut on, "Auto").changed() { + out = Some(if on { json!("auto") } else { json!(1.0) }); + } + if !auto { + 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 { @@ -755,6 +904,7 @@ mod tests { attempted: true, refused: None, problems: &messages(form, ¶ms), + picked: Picked::default(), }; ui.vertical(|ui| { ui.set_max_width(width); @@ -799,6 +949,7 @@ mod tests { attempted: false, refused: None, problems: &[], + picked: Picked::default(), }; ui.vertical(|ui| { ui.set_max_width(width); @@ -904,6 +1055,7 @@ mod tests { attempted, refused, problems: &messages(form, ¶ms), + picked: Picked::default(), }; show(ui, &Tokens::get(false), &cx, form, &mut params); }); @@ -934,7 +1086,16 @@ mod tests { let names = |_: Collection| Vec::new(); let touched = BTreeSet::new(); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let cx = Context { units, names: &names, salt: Id::new("t"), touched: &touched, attempted: false, refused: None, problems: &[] }; + let cx = Context { + units, + names: &names, + salt: Id::new("t"), + touched: &touched, + attempted: false, + refused: None, + problems: &[], + picked: Picked { nodes: vec![4, 5], elements: Vec::new() }, + }; show(ui, &Tokens::get(false), &cx, form, &mut params); }); out.textures_delta.clear(); @@ -957,4 +1118,21 @@ mod tests { let lines: Vec<&str> = load.lines().collect(); assert!(["X", "Y", "Z", "[force]"].iter().all(|w| lines.contains(w)), "{load}"); } + + /// Label lists, point lists, multi-choices and number-or-auto are typed fields (#169): a + /// label list shows its ranges and offers the picked nodes, points show their coordinates, + /// output choices are checkboxes, the scale an Auto box. + #[test] + fn typed_lists_choices_and_auto_show() { + let probe = drawn("solve.probe", UnitSystem::MmTonneS, json!({"nodes": [1, 2, 3, 7], "at": [[1.5, 0, 0]]})); + assert!( + probe.contains("1-3, 7") && probe.contains("Use selection (2 nodes)") && probe.contains("1.5") && probe.contains("Add point"), + "{probe}" + ); + assert!(!probe.contains("set in JSON"), "{probe}"); + let step = drawn("model.step.create", UnitSystem::MmTonneS, json!({"name": "S", "procedure": {"type": "static"}, "output": ["stress"]})); + assert!(step.contains("Reaction force") && step.contains("Heat flux"), "{step}"); + let deformed = drawn("results.deformed", UnitSystem::MmTonneS, json!({"scale": "auto"})); + assert!(deformed.contains("Auto"), "{deformed}"); + } }