From 70c5c9b26f9a6c7dde72ac5b637d542ec185c9f3 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:30:24 -0400 Subject: [PATCH] Ribbon: Hex Mesh and Partitioned Mesh replace the duplicate hex entries (#270) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- ATTRIBUTION.md | 5 +- assets/icons/mesh.cells.svg | 2 +- assets/icons/mesh.hex.generate.svg | 2 +- crates/engine/src/catalogue.rs | 87 ++++++++++++++++++++---------- crates/ui-egui/src/palette.rs | 4 +- crates/ui-egui/src/toolbar.rs | 1 + docs/features.md | 9 ++-- 7 files changed, 69 insertions(+), 41 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b4556dd..5def8f0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -57,11 +57,10 @@ is committed and listed here with its exports. | assets/icons/mesh.element.add.svg, assets/icons/mesh.element.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.element.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.hex.decompose.svg, assets/icons/mesh.hex.decompose.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.decompose.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.hex.dominant.svg, assets/icons/mesh.hex.dominant.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.dominant.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | -| assets/icons/mesh.hex.blocks.svg, assets/icons/mesh.hex.blocks.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.blocks.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | -| assets/icons/mesh.hex.mapped.svg, assets/icons/mesh.hex.mapped.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.mapped.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/icons/mesh.cells.svg, assets/icons/mesh.cells.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.cells.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/icons/mesh.hex.generate.svg, assets/icons/mesh.hex.generate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.generate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.hex.octree.svg, assets/icons/mesh.hex.octree.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.octree.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.hex.polycube.svg, assets/icons/mesh.hex.polycube.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.polycube.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | -| assets/icons/mesh.hex.sweep.svg, assets/icons/mesh.hex.sweep.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.sweep.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.import.orphan.svg, assets/icons/mesh.import.orphan.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.import.orphan.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.layers.svg, assets/icons/mesh.layers.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.layers.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.node.add.svg, assets/icons/mesh.node.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | diff --git a/assets/icons/mesh.cells.svg b/assets/icons/mesh.cells.svg index 0627062..7ab53cc 100644 --- a/assets/icons/mesh.cells.svg +++ b/assets/icons/mesh.cells.svg @@ -1,6 +1,6 @@ - mesh.hex.blocks + mesh.cells diff --git a/assets/icons/mesh.hex.generate.svg b/assets/icons/mesh.hex.generate.svg index 3dc91d9..ef3a924 100644 --- a/assets/icons/mesh.hex.generate.svg +++ b/assets/icons/mesh.hex.generate.svg @@ -1,6 +1,6 @@ - mesh.hex.mapped + mesh.hex.generate diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index e3e1233..b660366 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -155,6 +155,9 @@ pub struct Capability { pub status: Status, /// Related commands in the entry's drop-down (edit, delete…). pub more: &'static [&'static str], + /// Commands the entry's own form covers (an option of it does the same): claimed by the entry + /// for the command search and the ribbon's stage, but not listed in the ribbon. + pub covers: &'static [&'static str], } impl Capability { @@ -194,7 +197,7 @@ impl Capability { } const fn ready(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, tip: &'static str) -> Capability { - Capability { id, label, tab, panel, tip, status: Status::Ready, more: &[] } + Capability { id, label, tab, panel, tip, status: Status::Ready, more: &[], covers: &[] } } const fn ready_more( @@ -208,12 +211,23 @@ const fn ready_more( Capability { more, ..ready(id, label, tab, panel, tip) } } +const fn covering( + id: &'static str, + label: &'static str, + tab: Tab, + panel: &'static str, + tip: &'static str, + covers: &'static [&'static str], +) -> Capability { + Capability { covers, ..ready(id, label, tab, panel, tip) } +} + const fn soon(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, milestone: &'static str, tip: &'static str) -> Capability { - Capability { id, label, tab, panel, tip, status: Status::Planned { milestone }, more: &[] } + Capability { id, label, tab, panel, tip, status: Status::Planned { milestone }, more: &[], covers: &[] } } const fn deck(id: &'static str, label: &'static str, format: &'static str, tip: &'static str) -> Capability { - Capability { id, label, tab: Tab::Solve, panel: "Decks", tip, status: Status::Deck { format, milestone: "M3" }, more: &[] } + Capability { id, label, tab: Tab::Solve, panel: "Decks", tip, status: Status::Deck { format, milestone: "M3" }, more: &[], covers: &[] } } use Tab::*; @@ -277,29 +291,21 @@ pub static CATALOGUE: &[Capability] = &[ "Quad or triangle mesh of a planar face or polygon in the XY plane for plane stress, plane strain and axisymmetric models; edges tagged", ), soon("mesh.quad.faces", "Quad Faces", Mesh, "Structured Hex", "M5", "All-quad meshes of faces on shared edge nodes"), - ready_more( - "mesh.hex.mapped", - "Mapped Hex", + covering( + "mesh.hex.generate", + "Hex Mesh", Mesh, "Structured Hex", - "Mapped hex mesh of a six-sided block (curved faces allowed)", - &["mesh.hex.generate"], + "Structured hex mesh of a body: automatic, or extruded, swept, revolved or mapped (the Method drop-down)", + &["mesh.hex.sweep", "mesh.hex.mapped"], ), - ready_more( - "mesh.hex.sweep", - "Sweep Hex", + covering( + "mesh.cells", + "Partitioned Mesh", Mesh, "Structured Hex", - "Hex mesh swept along extrusions (several levels), around revolutions, or from a source face to a target", - &["mesh.hex.generate"], - ), - ready_more( - "mesh.hex.blocks", - "Block Hex", - Mesh, - "Structured Hex", - "One conforming hex mesh of a part split into six-sided blocks: shared faces meshed once", - &["mesh.cells"], + "One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions", + &["mesh.hex.blocks"], ), soon("mesh.hex.decompose", "Decompose", Mesh, "Structured Hex", "M5", "Find sweepable and mappable regions and propose the splits"), soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"), @@ -452,13 +458,13 @@ pub static CATALOGUE: &[Capability] = &[ "Contact between everything that may touch, or self contact of a surface, with friction (explicit dynamics and nonlinear static steps)", &["interaction.contact.edit", "interaction.contact.delete"], ), - ready_more( + // Its edit and delete rows are General Contact's: they act on contacts of either kind. + ready( "interaction.contact.pair", "Contact Pair", Interactions, "Contact", "Contact between two surfaces: with friction in explicit dynamics, mortar contact with friction in nonlinear static steps", - &["interaction.contact.edit", "interaction.contact.delete"], ), ready( "interaction.contact.find", @@ -566,9 +572,12 @@ pub fn find(id: &str) -> Option<&'static Capability> { } /// The entry that claims a command: the one running it, else the one listing it among its -/// related commands. Every registered command has one (a test checks it). +/// related commands or covering it. Every registered command has one (a test checks it). pub fn entry_of(command: &str) -> Option<&'static Capability> { - CATALOGUE.iter().find(|c| c.command() == Some(command)).or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command))) + CATALOGUE + .iter() + .find(|c| c.command() == Some(command)) + .or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command) || c.covers.contains(&command))) } /// The tab the ribbon follows to when `command` has run from anywhere but the ribbon (a script, @@ -611,7 +620,8 @@ pub fn features_page() -> String { let preset = c.preset(); let main = c.command().map(|id| if preset.as_object().is_some_and(|o| !o.is_empty()) { format!("`{id} {preset}`") } else { format!("`{id}`") }); - let commands: Vec = main.into_iter().chain(c.more.iter().map(|id| format!("`{id}`"))).collect(); + let commands: Vec = + main.into_iter().chain(c.more.iter().map(|id| format!("`{id}`"))).chain(c.covers.iter().map(|id| format!("(`{id}`)"))).collect(); let _ = writeln!(md, "| {} | {} — {} | {status} | {} |", c.panel, c.label, c.tip, commands.join(", ")); } } @@ -641,6 +651,25 @@ mod tests { } } + /// Each command has one place in the ribbon: no two menu rows (an entry or a drop-down row) + /// run the same command with the same preset, and a command an entry's form covers is no row. + #[test] + fn no_command_appears_twice_across_ribbon_menus() { + let mut rows: Vec = Vec::new(); + for c in CATALOGUE { + let entry = c.command().map(|id| format!("{id} {}", c.preset())); + for row in entry.into_iter().chain(c.more.iter().map(|id| format!("{id} {{}}"))) { + assert!(!rows.contains(&row), "{}: `{row}` is in the ribbon twice", c.id); + rows.push(row); + } + } + for c in CATALOGUE { + for m in c.covers { + assert!(!rows.iter().any(|r| r.split(' ').next() == Some(m)), "{}: `{m}` is covered by its form and also a ribbon row", c.id); + } + } + } + #[test] fn the_ribbon_follows_each_command_to_its_stage() { assert_eq!(stage_tab("mesh.hex.sweep"), Some(Tab::Mesh)); @@ -655,7 +684,7 @@ mod tests { assert_eq!(stage_tab("no.such"), None); // A ribbon click (an entry's command, or one of its drop-down's) stays on the tab shown. for c in CATALOGUE { - for command in c.command().into_iter().chain(c.more.iter().copied()) { + for command in c.command().into_iter().chain(c.more.iter().copied()).chain(c.covers.iter().copied()) { assert!(stage_tab(command).is_none_or(|t| t == c.tab), "{}: `{command}` would move the ribbon from {:?}", c.id, c.tab); } } @@ -669,9 +698,9 @@ mod tests { assert!(find_command(command).is_some(), "{}: `{command}` is not a registered command", c.id); claimed.push(command); } else { - assert!(c.more.is_empty(), "{}: a planned entry has no commands", c.id); + assert!(c.more.is_empty() && c.covers.is_empty(), "{}: a planned entry has no commands", c.id); } - for m in c.more { + for m in c.more.iter().chain(c.covers) { assert!(find_command(m).is_some(), "{}: `{m}` is not a registered command", c.id); claimed.push(m); } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 75fe123..ab65773 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -58,7 +58,7 @@ pub fn matches(query: &str) -> Vec { Hit { id: c.id, label: c.label, place: place.clone(), icon: c.id, soon }, )); } - for m in c.more { + for m in c.more.iter().chain(c.covers) { let label = crate::command_label(m); if q.is_empty() || format!("{label} {m}").to_lowercase().contains(&q) { hits.push((rank(label, m), Hit { id: m, label, place: place.clone(), icon: c.id, soon: None })); @@ -267,7 +267,7 @@ mod tests { let soon = hits.iter().position(|h| h.soon.is_some()).unwrap(); assert!(hits.iter().skip(soon).all(|h| h.soon.is_some()), "coming soon last"); assert_eq!(matches("").len(), { - let mut ids: Vec<&str> = CATALOGUE.iter().flat_map(|c| std::iter::once(c.id).chain(c.more.iter().copied())).collect(); + let mut ids: Vec<&str> = CATALOGUE.iter().flat_map(|c| std::iter::once(c.id).chain(c.more.iter().chain(c.covers).copied())).collect(); ids.sort(); ids.dedup(); ids.len() diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a1770da..55d1507 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -423,6 +423,7 @@ pub fn describe(app: &ModelApp) -> serde_json::Value { "panel": c.panel, "command": c.command(), "more": c.more, + "covers": c.covers, "coming_soon": c.milestone(), "enabled": c.command().is_some() && missing.is_none(), "unavailable": missing.map(|u| u.to_json()), diff --git a/docs/features.md b/docs/features.md index 4a4c0f9..3f8b829 100644 --- a/docs/features.md +++ b/docs/features.md @@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line, the control channel and AI agents over MCP (docs/mcp.md) run the same commands. -63 ready, 41 coming soon. +62 ready, 41 coming soon. ## Geometry @@ -33,9 +33,8 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Tet | Delaunay Points — Delaunay tetrahedralization of a point list (debugging aid) | ready | `mesh.delaunay.points` | | 2D | 2D Mesh — Quad or triangle mesh of a planar face or polygon in the XY plane for plane stress, plane strain and axisymmetric models; edges tagged | ready | `mesh.planar` | | Structured Hex | Quad Faces — All-quad meshes of faces on shared edge nodes | coming soon (M5) | | -| Structured Hex | Mapped Hex — Mapped hex mesh of a six-sided block (curved faces allowed) | ready | `mesh.hex.mapped`, `mesh.hex.generate` | -| Structured Hex | Sweep Hex — Hex mesh swept along extrusions (several levels), around revolutions, or from a source face to a target | ready | `mesh.hex.sweep`, `mesh.hex.generate` | -| Structured Hex | Block Hex — One conforming hex mesh of a part split into six-sided blocks: shared faces meshed once | ready | `mesh.hex.blocks`, `mesh.cells` | +| Structured Hex | Hex Mesh — Structured hex mesh of a body: automatic, or extruded, swept, revolved or mapped (the Method drop-down) | ready | `mesh.hex.generate`, (`mesh.hex.sweep`), (`mesh.hex.mapped`) | +| Structured Hex | Partitioned Mesh — One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions | ready | `mesh.cells`, (`mesh.hex.blocks`) | | Structured Hex | Decompose — Find sweepable and mappable regions and propose the splits | coming soon (M5) | | | Structured Hex | Transition — Conforming tet–hex interfaces with pyramids | coming soon (M5) | | | Automatic Hex | Octree Hex — Unstructured all-hex mesh of any part: adaptive grid, boundary layers on every sharp edge, untangled and proven valid | ready | `mesh.hex.octree` | @@ -104,7 +103,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Constraints | Coupling — Kinematic or distributing coupling of a set to a reference point | ready | `model.coupling.create`, `model.coupling.edit`, `model.coupling.delete` | | Constraints | Tie — Tie non-matching meshes together (dual mortar constraints) | ready | `interaction.tie`, `interaction.tie.edit`, `interaction.tie.delete` | | Contact | General Contact — Contact between everything that may touch, or self contact of a surface, with friction (explicit dynamics and nonlinear static steps) | ready | `interaction.contact.general`, `interaction.contact.edit`, `interaction.contact.delete` | -| Contact | Contact Pair — Contact between two surfaces: with friction in explicit dynamics, mortar contact with friction in nonlinear static steps | ready | `interaction.contact.pair`, `interaction.contact.edit`, `interaction.contact.delete` | +| Contact | Contact Pair — Contact between two surfaces: with friction in explicit dynamics, mortar contact with friction in nonlinear static steps | ready | `interaction.contact.pair` | | Contact | Find Contacts — Find the parts of the mesh that touch, and make contact pairs of them | ready | `interaction.contact.find` | | Contact | Rigid Wall — Rigid walls for explicit dynamics | coming soon (M10) | | | Connectors | Bolt Load — Bolt pretension | coming soon (M7) | |