forked from 1b-agent/fieldcraft-upstream
ui: element faces and edges as element:side lists
A Sides field kind: element faces or edges typed as element:side with ranges (1-20:3, 25:1), no row cap, and Use selection for picked faces. Set and mesh-tag forms 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:
1 parent
9b705f5334
commit
f8b92fcd89
6 files changed
+192
-6
No files matched your search
@@ -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."),
|
||||
];
|
||||
|
||||
|
||||
@@ -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<Problem
|
||||
(!ok).then_some(ProblemKind::NotAChoice)
|
||||
}
|
||||
Kind::Unit | Kind::Json => 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::<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::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<Value> = (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() {
|
||||
|
||||
@@ -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))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Option<Vec<_>>>())
|
||||
{
|
||||
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::<Option<Vec<_>>>()) {
|
||||
Some(l) => crate::fields::format_labels(&l),
|
||||
None => crate::context::value_text(v),
|
||||
|
||||
@@ -228,6 +228,44 @@ pub fn parse_labels(text: &str, max: usize) -> Option<Vec<u64>> {
|
||||
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<String> = 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<Vec<(u64, u64)>> {
|
||||
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::<u64>().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");
|
||||
|
||||
@@ -47,6 +47,8 @@ pub struct Context<'a> {
|
||||
pub struct Picked {
|
||||
pub nodes: Vec<u64>,
|
||||
pub elements: Vec<u64>,
|
||||
/// 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<Value> {
|
||||
let key = side.key();
|
||||
let current: Option<Vec<(u64, u64)>> = 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<Value> {
|
||||
let current: Vec<Value> = 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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user