ui: typed form fields for label lists, point lists, multi-choices and number-or-auto (#169)

Four field kinds in the schema, each checked and described in JSON Schema: labels (nodes or
elements, typed as 1-5, 8 with Use selection taking the viewport's picks), points (rows of
x, y, z, added and removed), multi (checkboxes: coupling DOFs, step output) and number or
auto (the deformed scale). Node and element lists, probe and Delaunay points and block
corners use them; the remaining JSON fields hold geometry-specific shapes.

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-15 11:07:46 -04:00
1 parent c0cbcadf3d
commit 1f85a8caa3
6 files changed
+420 -21

No files matched your search

+32 -19
View File
@@ -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,
+134 -1
View File
@@ -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<f64>,
},
/// 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<Dim> {
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<f64>) -> 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::<Vec<_>>().join(", "))
}
Some(Kind::Either(options)) => format!("one of {}", options.iter().map(|o| o.key).collect::<Vec<_>>().join(", ")),
Some(Kind::Multi(options)) => format!("a list of {}", options.iter().map(|(o, _)| *o).collect::<Vec<_>>().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<Problem
(!ok).then_some(ProblemKind::NotAChoice)
}
Kind::Unit | Kind::Json => 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::<Vec<_>>()}),
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::<Vec<_>>()}, "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!({}));
+10
View File
@@ -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;
+12
View File
@@ -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::<Option<Vec<_>>>()) {
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),
}
}
+53
View File
@@ -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<String> = 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<Vec<u64>> {
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::<u64>().ok()?, b.parse::<u64>().ok()?),
None => {
let n = part.parse::<u64>().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");
+179 -1
View File
@@ -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<u64>,
pub elements: Vec<u64>,
}
/// 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<Value> {
let current: Option<Vec<u64>> = 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(&current);
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::<Vec<_>>()))
});
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<Value> {
let current: Vec<Value> = 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<Value> {
let chosen: Vec<Value> = 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<Value> =
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<Value> {
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<Value> {
@@ -755,6 +904,7 @@ mod tests {
attempted: true,
refused: None,
problems: &messages(form, &params),
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, &params),
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}");
}
}