diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index 7b49ef6..3cbf666 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -1179,15 +1179,20 @@ pub const CLEAR_MESH: &[Field] = &[references("bodies", "Bodies", C::Body)
.help("The bodies whose meshes go. When none is ticked: the meshes of bodies the open design no longer has because its partitions changed (model.check lists them).")
.hint("the bodies partitions removed")];
+const MIRROR_DATUM: Field = reference("", "Datum plane", C::DatumPlane).help("A datum plane of the model.");
+const MIRROR_POINT_NORMAL: &[Field] = &[
+ xyz("point", "Point", D::Length).help("A point on the plane.").req(),
+ xyz("normal", "Normal", D::None).help("The plane's normal; its length does not matter.").req(),
+];
+
pub const MIRROR: &[Field] = &[
- json("plane", "Plane")
- .help("The mirror plane: a datum plane's name, or a point and normal as JSON.")
- .req()
- .hint(r#""Datum" or {"point": [x, y, z], "normal": [nx, ny, nz]}"#),
+ name_or("plane", "Plane", &MIRROR_DATUM, "Point and normal", MIRROR_POINT_NORMAL)
+ .help("The mirror plane: a datum plane, or a point on it and its normal.")
+ .req(),
references("bodies", "Bodies", C::Body).help("The meshed bodies to mirror; every meshed body when empty.").hint("every meshed body"),
- json("as", "Images")
- .help("Names for the mirrored bodies, as JSON.")
- .hint(r#"{"body": "image body"}; default: the design's body at the reflected place"#),
+ reference_text("as", "Images", C::Body)
+ .help("The body each mirrored body becomes; when empty, the design's body at the reflected place, else
/mirror.")
+ .hint("default"),
];
pub const SYMMETRY: &[Field] = &[
diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs
index 1c1f1df..b0f315f 100644
--- a/crates/engine/src/schema.rs
+++ b/crates/engine/src/schema.rs
@@ -126,6 +126,17 @@ pub enum Kind {
min_len: usize,
whole: bool,
},
+ /// A name (`name`, a [`Kind::Ref`] or [`Kind::Choice`] whose key is unused) as a bare
+ /// string, or an object of `fields` (`object` labels that choice): a mirror plane is a datum
+ /// plane's name or a point and a normal.
+ NameOr {
+ name: &'static Field,
+ object: &'static str,
+ fields: &'static [Field],
+ },
+ /// 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),
/// Any JSON: what the schema does not describe.
Json,
}
@@ -535,6 +546,14 @@ pub const fn per_axis(key: &'static str, label: &'static str, dim: Dim, min: Opt
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 })
}
+/// A name as `name` gives it, or an object of `fields` (labelled `object`).
+pub const fn name_or(key: &'static str, label: &'static str, name: &'static Field, object: &'static str, fields: &'static [Field]) -> Field {
+ field(key, label, Kind::NameOr { name, object, fields })
+}
+/// A text per item of `c`.
+pub const fn reference_text(key: &'static str, label: &'static str, c: Collection) -> Field {
+ field(key, label, Kind::RefText(c))
+}
pub const fn json(key: &'static str, label: &'static str) -> Field {
field(key, label, Kind::Json)
}
@@ -930,7 +949,7 @@ pub fn field_at(fields: &'static [Field], path: &str) -> Option<&'static Field>
}
field = match field.kind {
Kind::Rows { .. } if segment.parse::().is_ok() => field,
- Kind::Group(inner) | Kind::Rows { fields: inner, .. } => find(inner, segment)?,
+ Kind::Group(inner) | Kind::Rows { fields: inner, .. } | Kind::NameOr { fields: inner, .. } => find(inner, segment)?,
Kind::Variants { tag, .. } if tag == segment => field,
Kind::Variants { variants, .. } => variants.iter().find_map(|v| find(v.fields, segment))?,
Kind::Either(options) => options.iter().find(|o| o.key == segment)?,
@@ -1025,6 +1044,19 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec Some(ProblemKind::NotAnObject),
},
+ Kind::NameOr { name, fields, .. } => match v {
+ Value::String(_) => value_problem(name, v, path, out, depth + 1),
+ Value::Object(o) => {
+ check(fields, o, path, out, depth + 1);
+ None
+ }
+ _ => Some(ProblemKind::Invalid(format!("a {} or an object", name.label.to_lowercase()))),
+ },
+ Kind::RefText(_) => match v.as_object() {
+ Some(o) if o.len() <= MAX_NAMES && o.values().all(Value::is_string) => None,
+ Some(_) => Some(ProblemKind::NotText),
+ None => Some(ProblemKind::NotAnObject),
+ },
Kind::Variants { tag, .. } => match v.as_object() {
Some(o) => match o.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
Some(var) => {
@@ -1197,6 +1229,10 @@ fn field_schema(f: &Field, depth: usize) -> Value {
"additionalProperties": {"enum": options.iter().map(|(o, _)| *o).collect::>()},
}),
Kind::Group(fields) if depth <= MAX_DEPTH => object_schema(fields, depth + 1),
+ Kind::NameOr { name, fields, .. } if depth <= MAX_DEPTH => {
+ json!({"oneOf": [field_schema(name, depth + 1), object_schema(fields, depth + 1)]})
+ }
+ Kind::RefText(c) => json!({"type": "object", "x-references": c.label(), "additionalProperties": {"type": "string"}}),
Kind::Variants { tag, variants } if depth <= MAX_DEPTH => json!({"oneOf": variant_schemas(tag, variants, depth + 1)}),
Kind::Either(options) if depth <= MAX_DEPTH => json!({"oneOf": options.iter().map(|o| match o.kind {
Kind::Unit => json!({"const": o.key}),
@@ -1352,6 +1388,24 @@ mod tests {
assert_eq!(s["properties"]["k"]["oneOf"][1]["maxItems"], 3);
}
+ /// A name-or-object field takes the name's kind as a string or its fields as an object; a
+ /// text per item takes an object of strings.
+ #[test]
+ fn name_or_object_and_text_per_item() {
+ const NAME: Field = reference("", "Datum plane", Collection::DatumPlane);
+ const OBJ: &[Field] = &[xyz("point", "Point", Dim::Length).req()];
+ const F: &[Field] = &[name_or("plane", "Plane", &NAME, "Point", OBJ), reference_text("as", "Images", Collection::Body)];
+ assert!(problems(F, &json!({"plane": "Sym", "as": {"A": "B"}})).is_empty());
+ assert!(problems(F, &json!({"plane": {"point": [0, 0, 1]}})).is_empty());
+ for bad in [json!({"plane": 3}), json!({"plane": {}}), json!({"plane": {"point": [0, 0]}}), json!({"as": {"A": 1}}), json!({"as": "B"})] {
+ assert_eq!(problems(F, &bad).len(), 1, "{bad}");
+ }
+ assert_eq!(problems(F, &json!({"plane": {}})).first().map(|p| p.path.as_str()), Some("plane/point"));
+ let s = json_schema(F);
+ assert_eq!(s["properties"]["plane"]["oneOf"][0]["type"], "string");
+ assert_eq!(s["properties"]["as"]["additionalProperties"]["type"], "string");
+ }
+
/// 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/describe.rs b/crates/ui-egui/src/describe.rs
index 51fff91..6a2a994 100644
--- a/crates/ui-egui/src/describe.rs
+++ b/crates/ui-egui/src/describe.rs
@@ -167,6 +167,14 @@ fn scalar(f: &Field, v: &Value, units: UnitSystem) -> String {
.join(", ")
},
),
+ Kind::NameOr { .. } => match v {
+ Value::String(s) => s.clone(),
+ _ => crate::context::value_text(v),
+ },
+ Kind::RefText(_) => v.as_object().map_or_else(
+ || crate::context::value_text(v),
+ |o| o.iter().map(|(k, x)| format!("{k}: {}", x.as_str().unwrap_or_default())).collect::>().join(", "),
+ ),
Kind::Either(options) => match v {
Value::String(s) => options.iter().find(|o| o.key == s).map_or_else(|| s.clone(), |o| o.label.to_string()),
Value::Object(o) => o
diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs
index f8667d4..bc63fc0 100644
--- a/crates/ui-egui/src/form.rs
+++ b/crates/ui-egui/src/form.rs
@@ -234,6 +234,7 @@ impl Form<'_> {
let path = join(prefix, f.key);
match f.kind {
Kind::Group(_) | Kind::Variants { .. } | Kind::Rows { .. } => self.section(ui, f, obj, &path, depth),
+ Kind::NameOr { name, object, fields } => self.name_or(ui, f, name, object, fields, obj, &path, depth),
_ => {
let value = obj.get(f.key).cloned();
let style = self.style;
@@ -311,6 +312,7 @@ impl Form<'_> {
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),
+ Kind::RefText(c) => self.reference_text(ui, id, value, c, f.hint),
Kind::Either(options) => {
let key = match value {
Some(Value::String(s)) => Some(s.as_str()),
@@ -335,7 +337,7 @@ impl Form<'_> {
Kind::Multi(options) => self.multi(ui, value, options),
Kind::NumberOrAuto { .. } => self.number_or_auto(ui, id, value, f),
Kind::PerAxis { labels, .. } => self.per_axis(ui, id, value, f, labels),
- Kind::Unit | Kind::Group(_) | Kind::Variants { .. } | Kind::Rows { .. } => None,
+ Kind::Unit | Kind::Group(_) | Kind::Variants { .. } | Kind::Rows { .. } | Kind::NameOr { .. } => None,
}
}
@@ -589,6 +591,92 @@ impl Form<'_> {
changed
}
+ /// A text per item of `c`, each after the item's name; an empty text leaves the item out.
+ fn reference_text(&mut self, ui: &mut Ui, id: Id, value: Option<&Value>, c: Collection, hint: &str) -> Option {
+ let current = value.and_then(Value::as_object).cloned().unwrap_or_default();
+ let names = self.names(c).to_vec();
+ let mut changed = None;
+ ui.vertical(|ui| {
+ if names.is_empty() {
+ ui.label(RichText::new(format!("No {} yet", plural(c.label()))).color(self.style.dim));
+ }
+ for (i, n) in names.iter().take(MAX_ROWS).enumerate() {
+ let mut text = current.get(n).and_then(Value::as_str).unwrap_or_default().to_string();
+ let row = ui.horizontal(|ui| {
+ let width = (ui.available_width() * 0.5).max(FIELD_MIN);
+ ui.add_sized([width, ui.spacing().interact_size.y], egui::Label::new(n.as_str()).truncate());
+ fields::text(ui, &self.style, id.with(i), &mut text, hint, fill(ui))
+ });
+ if row.response.contains_pointer() && c == Collection::Body {
+ self.out.hover_body = Some(n.clone());
+ }
+ if row.inner {
+ let mut next = current.clone();
+ if text.is_empty() {
+ next.remove(n);
+ } else {
+ next.insert(n.clone(), json!(text));
+ }
+ changed = Some(if next.is_empty() { Value::Null } else { Value::Object(next) });
+ }
+ }
+ });
+ changed
+ }
+
+ /// A [`Kind::NameOr`] field: a choice between the name and the object, then the chosen one's
+ /// field or fields. Switching starts the other form empty.
+ #[allow(clippy::too_many_arguments)]
+ fn name_or(
+ &mut self,
+ ui: &mut Ui,
+ f: &Field,
+ name: &'static Field,
+ object: &str,
+ fields: &'static [Field],
+ obj: &mut Map,
+ path: &str,
+ depth: usize,
+ ) {
+ let is_object = matches!(obj.get(f.key), Some(Value::Object(_)));
+ let id = self.id(path);
+ let help = f.help_text(self.cx.units);
+ let style = self.style;
+ let labels = [name.label, object];
+ let selected = if is_object { object } else { name.label };
+ if let Some(i) = fields::row(ui, &style, f.label, f.required, &help, |ui| fields::combo(ui, id.with("form"), selected, &labels, fill(ui)))
+ && (i == 1) != is_object
+ {
+ if i == 1 {
+ obj.insert(f.key.into(), schema::prefill(fields, &json!({})));
+ } else {
+ obj.remove(f.key);
+ }
+ self.touch(path);
+ }
+ if is_object {
+ ui.horizontal(|ui| {
+ ui.add_space(INDENT);
+ ui.vertical(|ui| {
+ if let Some(Value::Object(child)) = obj.get_mut(f.key) {
+ self.fields(ui, fields, fields, child, path, depth + 1);
+ }
+ });
+ });
+ } else {
+ let value = obj.get(f.key).cloned();
+ let help = name.help_text(self.cx.units);
+ let new = fields::row(ui, &style, name.label, false, &help, |ui| self.scalar(ui, name, value.as_ref(), path));
+ if let Some(new) = new {
+ match new {
+ Value::Null => obj.remove(f.key),
+ v => obj.insert(f.key.into(), v),
+ };
+ self.touch(path);
+ }
+ }
+ }
+
/// "Create material…": opens the command that makes an item of `c`, then returns here.
fn create_button(&mut self, ui: &mut Ui, c: Collection, path: &str) {
let r = c.requirement();
@@ -927,7 +1015,9 @@ pub fn has_system_units(fields: &[Field], depth: usize) -> bool {
depth <= MAX_DEPTH
&& fields.iter().any(|f| match f.kind {
Kind::Number { dim, .. } | Kind::Vector { dim, .. } | Kind::PerAxis { dim, .. } => dim.follows_system(),
- Kind::Group(inner) | Kind::Rows { fields: inner, .. } | Kind::Either(inner) => has_system_units(inner, depth + 1),
+ Kind::Group(inner) | Kind::Rows { fields: inner, .. } | Kind::Either(inner) | Kind::NameOr { fields: inner, .. } => {
+ has_system_units(inner, depth + 1)
+ }
Kind::Variants { variants, .. } => variants.iter().any(|v| has_system_units(v.fields, depth + 1)),
_ => false,
})