diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 79d2471..d5822d7 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -58,8 +58,14 @@ pub const SET: &[Field] = &[ 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\"}]"), + sides("faces", "Element faces", Side::Face) + .help( + "Element faces as element:face (local face number), ranges of elements sharing one: 1-20:3, 25:1. Use selection takes the picked faces.", + ) + .hint("element:face, e.g. 1-20:3, 25:1"), + sides("edges", "Element edges", Side::Edge) + .help("Element edges as element:edge (local edge number), ranges of elements sharing one: 1-20:3, 25:1.") + .hint("element:edge, e.g. 1-20:3"), ]; const ELASTIC: Field = variants( @@ -1468,8 +1474,8 @@ pub const TAG: &[Field] = &[ .help("The geometry the mesh entities are tagged with.") .req(), 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."), + sides("faces", "Element faces", Side::Face).help("Element faces as element:face: 1-20:3, 25:1.").hint("element:face"), + sides("edges", "Element edges", Side::Edge).help("Element edges as element:edge: 1-20:3, 25:1.").hint("element:edge"), labels("nodes", "Nodes", LabelOf::Node).help("Node labels."), ]; diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index b0f315f..93d9f0b 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -137,6 +137,9 @@ pub enum Kind { /// A text per item: an object from the names of a collection's items to a string; an item /// left out takes the command's default. RefText(Collection), + /// A list of element faces or edges, `{"element", "face"}` or `{"element", "edge"}` (at most + /// [`MAX_LABELS`]). + Sides(Side), /// Any JSON: what the schema does not describe. Json, } @@ -148,6 +151,23 @@ pub enum LabelOf { Element, } +/// What a [`Kind::Sides`] list holds: element faces or element edges. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Face, + Edge, +} + +impl Side { + /// The key of the local number in each entry, and its word. + pub fn key(self) -> &'static str { + match self { + Side::Face => "face", + Side::Edge => "edge", + } + } +} + impl LabelOf { pub fn label(self) -> &'static str { match self { @@ -520,6 +540,10 @@ 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)) } +/// A list of element faces or edges. +pub const fn sides(key: &'static str, label: &'static str, side: Side) -> Field { + field(key, label, Kind::Sides(side)) +} /// The columns of a point, `[x, y, z]`. pub const XYZ: &[&str] = &["x", "y", "z"]; /// A list of points `[x, y, z]`. @@ -1078,6 +1102,21 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec None, + Kind::Sides(side) => match v.as_array() { + Some(a) + if a.len() <= MAX_LABELS + && a.iter().all(|x| { + x.as_object().is_some_and(|o| { + o.len() == 2 + && o.get("element").and_then(Value::as_u64).is_some_and(|n| n >= 1 && n <= u64::from(u32::MAX)) + && o.get(side.key()).and_then(Value::as_u64).is_some_and(|n| n >= 1 && n <= u64::from(u8::MAX)) + }) + }) => + { + None + } + _ => Some(ProblemKind::NotAList { max: MAX_LABELS, what: if side == Side::Face { "element faces" } else { "element edges" } }), + }, 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" }), @@ -1240,6 +1279,16 @@ 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::Sides(side) => json!({ + "type": "array", + "maxItems": MAX_LABELS, + "items": { + "type": "object", + "properties": {"element": {"type": "integer", "minimum": 1, "maximum": u32::MAX}, side.key(): {"type": "integer", "minimum": 1, "maximum": u8::MAX}}, + "required": ["element", side.key()], + "additionalProperties": false, + }, + }), Kind::Labels(of) => { json!({"type": "array", "items": {"type": "integer", "minimum": 1, "maximum": u32::MAX}, "maxItems": MAX_LABELS, "x-labels": of.label()}) } @@ -1406,6 +1455,24 @@ mod tests { assert_eq!(s["properties"]["as"]["additionalProperties"]["type"], "string"); } + /// A sides list holds element and local face (or edge) objects, many more than a form's rows. + #[test] + fn sides_hold_element_and_local_number() { + const F: &[Field] = &[sides("faces", "Faces", Side::Face), sides("edges", "Edges", Side::Edge)]; + let many: Vec = (1..=5000).map(|e| json!({"element": e, "face": 3})).collect(); + assert!(problems(F, &json!({"faces": many, "edges": [{"element": 2, "edge": 12}]})).is_empty()); + for bad in [ + json!({"faces": [{"element": 0, "face": 1}]}), + json!({"faces": [{"element": 1, "face": 0}]}), + json!({"faces": [{"element": 1, "edge": 1}]}), + json!({"faces": [{"element": 1, "face": 1, "x": 1}]}), + json!({"edges": [3]}), + ] { + assert_eq!(problems(F, &bad).len(), 1, "{bad}"); + } + assert_eq!(json_schema(F)["properties"]["edges"]["items"]["required"], json!(["element", "edge"])); + } + /// A tuple takes its minimum to its full length of positive numbers, whole ones when asked. #[test] fn tuple_takes_two_or_three_positive_numbers() { diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index 60f000c..df12e45 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -178,12 +178,13 @@ 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. + // The nodes, elements and element faces picked in the viewport, for 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)), + selection::Item::Face(f) => picked.faces.push((u64::from(f.element), u64::from(f.face))), _ => {} } } diff --git a/crates/ui-egui/src/describe.rs b/crates/ui-egui/src/describe.rs index 6a2a994..92eb044 100644 --- a/crates/ui-egui/src/describe.rs +++ b/crates/ui-egui/src/describe.rs @@ -188,6 +188,13 @@ 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::Sides(side) => match v + .as_array() + .and_then(|a| a.iter().map(|x| Some((x.get("element")?.as_u64()?, x.get(side.key())?.as_u64()?))).collect::>>()) + { + Some(l) => crate::fields::format_sides(&l), + None => crate::context::value_text(v), + }, 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), diff --git a/crates/ui-egui/src/fields.rs b/crates/ui-egui/src/fields.rs index 25d9cf9..87a446c 100644 --- a/crates/ui-egui/src/fields.rs +++ b/crates/ui-egui/src/fields.rs @@ -228,6 +228,44 @@ pub fn parse_labels(text: &str, max: usize) -> Option> { Some(out) } +/// Element faces or edges as typed: `element:side`, runs of consecutive elements with the same +/// side as `a-b:side` ("1-5:3, 8:1"). +pub fn format_sides(sides: &[(u64, u64)]) -> String { + let mut out: Vec = Vec::new(); + let mut i = 0; + while let Some(&(a, side)) = sides.get(i) { + let mut j = i; + while sides.get(j + 1).is_some_and(|&(n, s)| s == side && Some(n) == sides.get(j).and_then(|(m, _)| m.checked_add(1))) { + j += 1; + } + let b = sides.get(j).map_or(a, |(n, _)| *n); + out.push(match j - i { + 0 => format!("{a}:{side}"), + 1 => format!("{a}:{side}, {b}:{side}"), + _ => format!("{a}-{b}:{side}"), + }); + i = j + 1; + } + out.join(", ") +} + +/// Element faces or edges typed as `element:side` or `a-b:side`, separated by commas or spaces; +/// `None` when an element is not a label from 1, a side not 1–255, or there would be more than +/// `max`. +pub fn parse_sides(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 (elements, side) = part.split_once(':')?; + let side = side.parse::().ok().filter(|s| (1..=u64::from(u8::MAX)).contains(s))?; + let labels = parse_labels(elements, max.saturating_sub(out.len()))?; + if labels.is_empty() { + return None; + } + out.extend(labels.into_iter().map(|e| (e, side))); + } + 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)); @@ -257,6 +295,18 @@ mod tests { assert_eq!(parse_labels(&format_labels(&[3, 4, 5, 9]), 100), Some(vec![3, 4, 5, 9])); } + #[test] + fn sides_print_and_parse_as_ranges() { + let s = [(1, 3), (2, 3), (3, 3), (5, 1), (6, 2), (7, 2)]; + assert_eq!(format_sides(&s), "1-3:3, 5:1, 6:2, 7:2"); + assert_eq!(parse_sides(&format_sides(&s), 100).as_deref(), Some(&s[..])); + assert_eq!(parse_sides("4:1 9-10:6", 100), Some(vec![(4, 1), (9, 6), (10, 6)])); + assert_eq!(parse_sides("", 100), Some(vec![])); + for bad in ["4", "4:", ":3", "0:1", "4:0", "4:256", "1-200:1", "x:1"] { + assert_eq!(parse_sides(bad, 100), None, "{bad}"); + } + } + #[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 bc63fc0..d78cb9e 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -47,6 +47,8 @@ pub struct Context<'a> { pub struct Picked { pub nodes: Vec, pub elements: Vec, + /// Element faces: (element, local face). + pub faces: Vec<(u64, u64)>, } /// What a frame of the form did. @@ -333,6 +335,7 @@ impl Form<'_> { } Kind::Json => self.json(ui, id, value, f.hint), Kind::Labels(of) => self.labels(ui, id, value, of, f.hint), + Kind::Sides(side) => self.sides(ui, id, value, side, f.hint), 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), @@ -739,6 +742,58 @@ impl Form<'_> { out } + /// Element faces or edges typed as `element:side` ranges ([`fields::parse_sides`]); for faces, + /// Use selection takes the faces picked in the viewport. + fn sides(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, side: schema::Side, hint: &str) -> Option { + let key = side.key(); + let current: Option> = match value { + None | Some(Value::Null) => Some(Vec::new()), + Some(v) => v.as_array().and_then(|a| a.iter().map(|x| Some((x.get("element")?.as_u64()?, x.get(key)?.as_u64()?))).collect()), + }; + let Some(current) = current else { + self.json_only(ui); + return None; + }; + let to_value = + |l: &[(u64, u64)]| if l.is_empty() { Value::Null } else { Value::Array(l.iter().map(|(e, s)| json!({"element": e, key: s})).collect()) }; + let shown = fields::format_sides(¤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 mut out = None; + let mut over = shown.clone(); + ui.vertical(|ui| { + let parsed = fields::parse_sides(&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_sides(&text, schema::MAX_LABELS) + { + over = fields::format_sides(&l); + out = Some(to_value(&l)); + } + if side == schema::Side::Face { + let picked = self.cx.picked.faces.clone(); + let label = format!("Use selection ({} face{})", picked.len(), if picked.len() == 1 { "" } else { "s" }); + let tip = "The element faces picked in the viewport (Select: Element faces)"; + 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_sides(&picked); + over = text.clone(); + out = Some(to_value(&picked)); + } + } + }); + 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, columns: &[&str], max: usize) -> Option { let current: Vec = match value { @@ -1312,7 +1367,7 @@ mod tests { attempted: false, refused: None, problems: &[], - picked: Picked { nodes: vec![4, 5], elements: Vec::new() }, + picked: Picked { nodes: vec![4, 5], ..Picked::default() }, }; show(ui, &Tokens::get(false), &cx, form, &mut params); });