diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 9a44813..7b49ef6 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1238,6 +1238,9 @@ pub const HEX_EXTRUDE: &[Field] = &[ TOLERANCE, ]; +/// A block's edge directions, from corner 1. +const BLOCK_DIRECTIONS: &[&str] = &["1-2", "1-4", "1-5"]; + pub const BLOCK: &[Field] = &[ text("body", "Body").help("Name of the new block body.").req().def("\"Block\""), choice( @@ -1259,8 +1262,12 @@ pub const BLOCK: &[Field] = &[ .req() .def("\"hex8\""), 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]"), + tuple("divisions", "Divisions", BLOCK_DIRECTIONS, 2, true) + .help("Elements along each edge direction: corner 1 to 2, 1 to 4 and 1 to 5; a 2D block takes the first two.") + .req(), + tuple("bias", "Bias", BLOCK_DIRECTIONS, 2, false) + .help("Last over first element size along each direction; 1 is uniform. A 2D block takes the first two.") + .hint("uniform"), json("edges", "Curved and named edges").help("Edge curves and names, as JSON."), json("faces", "Face names").help("Names of the block's faces, as JSON.").hint("{\"1\": \"bottom\"}"), json("vertices", "Corner names").help("Names of the block's corners, as JSON."), diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index 27b5c6f..1c1f1df 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -119,6 +119,13 @@ pub enum Kind { min: Option, labels: &'static [&'static str], }, + /// A list of `min_len` to `labels.len()` positive numbers, one per label in order (a block's + /// divisions: 3 in 3D, 2 in 2D); `whole` numbers only when set. + Tuple { + labels: &'static [&'static str], + min_len: usize, + whole: bool, + }, /// Any JSON: what the schema does not describe. Json, } @@ -524,6 +531,10 @@ pub const fn number_or_auto(key: &'static str, label: &'static str, dim: Dim, mi pub const fn per_axis(key: &'static str, label: &'static str, dim: Dim, min: Option, labels: &'static [&'static str]) -> Field { field(key, label, Kind::PerAxis { dim, min, labels }) } +/// `min_len` to `labels.len()` positive numbers, whole ones when `whole`. +pub const fn tuple(key: &'static str, label: &'static str, labels: &'static [&'static str], min_len: usize, whole: bool) -> Field { + field(key, label, Kind::Tuple { labels, min_len, whole }) +} pub const fn json(key: &'static str, label: &'static str) -> Field { field(key, label, Kind::Json) } @@ -1072,6 +1083,21 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec Some(ProblemKind::Invalid(format!("a number, or {} numbers (one per axis)", labels.len()))), } } + Kind::Tuple { labels, min_len, whole } => match v.as_array() { + Some(a) if (min_len..=labels.len()).contains(&a.len()) => { + let ok = |x: &Value| x.as_f64().is_some_and(|x| x.is_finite() && x > 0.0 && (!whole || x.fract() == 0.0)); + (!a.iter().all(ok)).then_some(if whole { + ProblemKind::Invalid("whole numbers of at least 1".into()) + } else { + ProblemKind::NotPositive + }) + } + _ => Some(ProblemKind::Invalid(if min_len == labels.len() { + format!("{min_len} numbers") + } else { + format!("{min_len} to {} numbers", labels.len()) + })), + }, Kind::Rows { fields, max } => match v.as_array() { Some(a) if a.len() <= max => { for (i, row) in a.iter().enumerate() { @@ -1192,6 +1218,10 @@ fn field_schema(f: &Field, depth: usize) -> Value { Kind::Multi(options) => { json!({"type": "array", "items": {"enum": options.iter().map(|(o, _)| multi_value(o)).collect::>()}, "uniqueItems": true}) } + Kind::Tuple { labels, min_len, whole } => { + let item = if whole { json!({"type": "integer", "minimum": 1}) } else { json!({"type": "number", "exclusiveMinimum": 0}) }; + json!({"type": "array", "items": item, "minItems": min_len, "maxItems": labels.len(), "x-labels": labels}) + } Kind::NumberOrAuto { min, .. } => { let mut n = json!({"type": "number"}); if let Some(lo) = min { @@ -1322,6 +1352,26 @@ mod tests { assert_eq!(s["properties"]["k"]["oneOf"][1]["maxItems"], 3); } + /// 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() { + const F: &[Field] = &[tuple("n", "N", &["a", "b", "c"], 2, true), tuple("r", "R", &["a", "b", "c"], 2, false)]; + assert!(problems(F, &json!({"n": [2, 3, 4], "r": [1.5, 1]})).is_empty()); + for bad in [ + json!({"n": [2]}), + json!({"n": [1, 2, 3, 4]}), + json!({"n": [1.5, 2]}), + json!({"n": [0, 2]}), + json!({"r": [1, -1]}), + json!({"r": [1, null]}), + ] { + assert_eq!(problems(F, &bad).len(), 1, "{bad}"); + } + let s = json_schema(F); + assert_eq!(s["properties"]["n"]["minItems"], 2); + assert_eq!(s["properties"]["n"]["items"]["type"], "integer"); + } + #[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 d882b46..51fff91 100644 --- a/crates/ui-egui/src/describe.rs +++ b/crates/ui-egui/src/describe.rs @@ -151,6 +151,7 @@ fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String { options.iter().find(|(o, _)| *o == s).map_or_else(|| s.to_string(), |(_, l)| (*l).to_string()) } Kind::Vector { labels, .. } => vector(labels, v, unit.as_deref()), + Kind::Tuple { labels, .. } => vector(labels, v, None), Kind::Ref(_) | Kind::Text | Kind::Path { .. } => v.as_str().map_or_else(|| crate::context::value_text(v), str::to_string), Kind::RefList(_) => list(v, |x| x.as_str().map(str::to_string)), // `Bracket.3: Tet, Bracket.7: Swept`. diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs index 10728c3..f8667d4 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -307,6 +307,7 @@ impl Form<'_> { fields::combo(ui, id, selected, &labels, fill(ui)).map(|i| options.get(i).map_or(Value::Null, |(v, _)| json!(v))) } Kind::Vector { labels, nullable, .. } => self.vector(ui, id, value, f, labels, nullable, None), + Kind::Tuple { labels, whole, .. } => self.tuple(ui, id, value, labels, whole), Kind::Ref(c) => self.reference(ui, id, path, value, c, f.hint, f.required), Kind::RefList(c) => self.reference_list(ui, path, value, c), Kind::RefChoices(c, options) => self.reference_choices(ui, id, value, c, options), @@ -440,6 +441,53 @@ impl Form<'_> { changed.then(|| Value::Array(next.into_iter().map(|x| if x.is_null() && !nullable { json!(0.0) } else { x }).collect())) } + /// A number per label on one line, captioned; trailing empty slots are left out (a 2D + /// block's divisions are the first two of three). + fn tuple(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, labels: &[&str], whole: bool) -> Option { + let mut slots: Vec = match value { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(a)) if a.len() <= labels.len() => a.clone(), + Some(_) => { + self.json_only(ui); + return None; + } + }; + slots.resize(labels.len(), Value::Null); + let gap = 3.0; + let small = egui::TextStyle::Small.resolve(ui.style()); + let caption_w = labels.iter().map(|l| crate::elide::width(ui, &caption(l), &small)).fold(0.0, f32::max) + 2.0 * gap; + // All on one line when each still shows a value whole; otherwise one per line. + let room = fill(ui); + let n = labels.len().max(1); + let per_line = if (room - gap * (n as f32 - 1.0)) / n as f32 - caption_w >= VECTOR_MIN { n } else { 1 }; + let width = ((room - gap * (per_line as f32 - 1.0)) / per_line as f32 - caption_w - 1.0).max(1.0); + let mut changed = false; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = gap; + for (i, label) in labels.iter().enumerate() { + if per_line == 1 && i > 0 { + ui.end_row(); + } + fields::caption(ui, &self.style, &caption(label)); + let slot = slots.get(i).cloned().unwrap_or(Value::Null); + let shown = (!slot.is_null()).then_some(&slot); + if let Some(v) = self.number(ui, id.with(i), shown, label, whole, width) { + if let Some(s) = slots.get_mut(i) { + *s = v; + } + changed = true; + } + } + }); + if !changed { + return None; + } + while slots.last().is_some_and(Value::is_null) { + slots.pop(); + } + Some(if slots.is_empty() { Value::Null } else { Value::Array(slots) }) + } + /// A drop-down of the collection's items; never free text. With none, the drop-down is /// greyed and a button opens the command that makes one ([`Outcome::create`]). #[allow(clippy::too_many_arguments)]