ui: block divisions and bias as two or three typed fields

A Tuple field kind: min_len to labels.len() positive numbers, whole when
asked, trailing blanks left out. The block's divisions and bias use it
instead of a JSON box (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 01:42:51 -04:00
1 parent e4d1f6c7da
commit d17cf414f7
4 files changed
+108 -2

No files matched your search

+9 -2
View File
@@ -1238,6 +1238,9 @@ pub const HEX_EXTRUDE: &[Field] = &[
TOLERANCE, TOLERANCE,
]; ];
/// A block's edge directions, from corner 1.
const BLOCK_DIRECTIONS: &[&str] = &["1-2", "1-4", "1-5"];
pub const BLOCK: &[Field] = &[ pub const BLOCK: &[Field] = &[
text("body", "Body").help("Name of the new block body.").req().def("\"Block\""), text("body", "Body").help("Name of the new block body.").req().def("\"Block\""),
choice( choice(
@@ -1259,8 +1262,12 @@ pub const BLOCK: &[Field] = &[
.req() .req()
.def("\"hex8\""), .def("\"hex8\""),
points("corners", "Corners", D::Length, 8).help("The eight corner points (four for 2D, at z = 0).").req(), 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])"), tuple("divisions", "Divisions", BLOCK_DIRECTIONS, 2, true)
json("bias", "Bias").help("Element size ratio along each direction, as JSON; 1 is uniform.").hint("[1, 1, 1]"), .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("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("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."), json("vertices", "Corner names").help("Names of the block's corners, as JSON."),
+50
View File
@@ -119,6 +119,13 @@ pub enum Kind {
min: Option<f64>, min: Option<f64>,
labels: &'static [&'static str], 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. /// Any JSON: what the schema does not describe.
Json, 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<f64>, labels: &'static [&'static str]) -> Field { pub const fn per_axis(key: &'static str, label: &'static str, dim: Dim, min: Option<f64>, labels: &'static [&'static str]) -> Field {
field(key, label, Kind::PerAxis { dim, min, labels }) 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 { pub const fn json(key: &'static str, label: &'static str) -> Field {
field(key, label, Kind::Json) field(key, label, Kind::Json)
} }
@@ -1072,6 +1083,21 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec<Problem
None => Some(ProblemKind::Invalid(format!("a number, or {} numbers (one per axis)", labels.len()))), None => 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() { Kind::Rows { fields, max } => match v.as_array() {
Some(a) if a.len() <= max => { Some(a) if a.len() <= max => {
for (i, row) in a.iter().enumerate() { for (i, row) in a.iter().enumerate() {
@@ -1192,6 +1218,10 @@ fn field_schema(f: &Field, depth: usize) -> Value {
Kind::Multi(options) => { Kind::Multi(options) => {
json!({"type": "array", "items": {"enum": options.iter().map(|(o, _)| multi_value(o)).collect::<Vec<_>>()}, "uniqueItems": true}) json!({"type": "array", "items": {"enum": options.iter().map(|(o, _)| multi_value(o)).collect::<Vec<_>>()}, "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, .. } => { Kind::NumberOrAuto { min, .. } => {
let mut n = json!({"type": "number"}); let mut n = json!({"type": "number"});
if let Some(lo) = min { if let Some(lo) = min {
@@ -1322,6 +1352,26 @@ mod tests {
assert_eq!(s["properties"]["k"]["oneOf"][1]["maxItems"], 3); 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] #[test]
fn prefill_switch_and_check() { fn prefill_switch_and_check() {
let p = prefill(LOADISH, &json!({})); let p = prefill(LOADISH, &json!({}));
+1
View File
@@ -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()) 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::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::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)), Kind::RefList(_) => list(v, |x| x.as_str().map(str::to_string)),
// `Bracket.3: Tet, Bracket.7: Swept`. // `Bracket.3: Tet, Bracket.7: Swept`.
+48
View File
@@ -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))) 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::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::Ref(c) => self.reference(ui, id, path, value, c, f.hint, f.required),
Kind::RefList(c) => self.reference_list(ui, path, value, c), Kind::RefList(c) => self.reference_list(ui, path, value, c),
Kind::RefChoices(c, options) => self.reference_choices(ui, id, value, c, options), 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())) 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<Value> {
let mut slots: Vec<Value> = 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 /// 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`]). /// greyed and a button opens the command that makes one ([`Outcome::create`]).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]