forked from 1b-agent/fieldcraft-upstream
Toolbars for every capability, VectorCraft icons, coming soon for planned ones (#20)
* feat(engine): capability catalogue — every toolbar entry, ready or coming soon, and docs/features.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * feat: 98 toolbar icons drawn with VectorCraft from recipes (cargo xtask icons), attributed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * feat(ui): toolbar of tabs and panels from the catalogue, VectorCraft icons recoloured per theme, coming-soon entries greyed with their milestone; ui.toolbar Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * refactor: ready entries are their command; one tooltip; tabs and panels as catalogue runs; icon cache per theme and size; ui.toolbar read-only Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: roadmap notes the toolbar and icons Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * feat(ui): deck entries run deck.write with their format, ready when the writer is in FORMATS; file and edit entries; compact and collapsed panels instead of scrolling Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: features page after the Nastran writer (#24) made its deck entry ready Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: features page after the LS-DYNA writer (#25) made its deck entry ready Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * feat: solve.static, solve.frequency and solve.probe on the toolbar (Solve and Results tabs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
320 files changed
+2990
-88
No files matched your search
@@ -0,0 +1,450 @@
|
||||
//! The capability catalogue: everything ModelCraft does or will do, in one table.
|
||||
//!
|
||||
//! Each [`Capability`] is a toolbar entry: its tab and panel, its label and one-line tip, and
|
||||
//! either the command of the same id it runs ([`Status::Ready`]) or the milestone that brings it
|
||||
//! ([`Status::Planned`], shown greyed as "Coming soon"). Planned entries are not commands: a
|
||||
//! command always runs. The icon of an entry is `assets/icons/<id>.svg`.
|
||||
//!
|
||||
//! Every registered command is claimed here, as an entry's command or as one of its related
|
||||
//! commands ([`Capability::more`], the entry's drop-down); the tests check it. A new command
|
||||
//! claims its place by turning its planned entry into a ready one (or by adding an entry with
|
||||
//! an icon), see `docs/features.md`.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The toolbar tabs, in the order of an analyst's workflow.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Tab {
|
||||
#[default]
|
||||
Geometry,
|
||||
Mesh,
|
||||
Properties,
|
||||
Assembly,
|
||||
Steps,
|
||||
Loads,
|
||||
Interactions,
|
||||
Solve,
|
||||
Results,
|
||||
Tools,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
pub const ALL: [Tab; 10] =
|
||||
[Tab::Geometry, Tab::Mesh, Tab::Properties, Tab::Assembly, Tab::Steps, Tab::Loads, Tab::Interactions, Tab::Solve, Tab::Results, Tab::Tools];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Tab::Geometry => "Geometry",
|
||||
Tab::Mesh => "Mesh",
|
||||
Tab::Properties => "Properties",
|
||||
Tab::Assembly => "Assembly",
|
||||
Tab::Steps => "Steps",
|
||||
Tab::Loads => "Loads & BCs",
|
||||
Tab::Interactions => "Interactions",
|
||||
Tab::Solve => "Solve",
|
||||
Tab::Results => "Results",
|
||||
Tab::Tools => "Tools",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an entry runs today.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// Runs the command whose id is the entry's.
|
||||
Ready,
|
||||
/// Coming in this roadmap milestone (`M2`…).
|
||||
Planned { milestone: &'static str },
|
||||
/// Runs `deck.write` with this `format` once `modelcraft_decks::FORMATS` has a writer for it;
|
||||
/// until then coming in `milestone`.
|
||||
Deck { format: &'static str, milestone: &'static str },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Capability {
|
||||
/// Our own dotted id; also the icon's file name.
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub tab: Tab,
|
||||
pub panel: &'static str,
|
||||
/// One line: what it does.
|
||||
pub tip: &'static str,
|
||||
pub status: Status,
|
||||
/// Related commands in the entry's drop-down (edit, delete…).
|
||||
pub more: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// The command it runs; `None` while it is coming soon.
|
||||
pub fn command(&self) -> Option<&'static str> {
|
||||
match self.status {
|
||||
Status::Ready => Some(self.id),
|
||||
Status::Planned { .. } => None,
|
||||
Status::Deck { format, .. } => modelcraft_decks::format(format).map(|_| crate::cmd::deck::WRITE.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters its command starts with (an object; empty for most entries).
|
||||
pub fn preset(&self) -> serde_json::Value {
|
||||
match self.status {
|
||||
Status::Deck { format, .. } => serde_json::json!({ "format": format }),
|
||||
Status::Ready | Status::Planned { .. } => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The milestone that brings it, while it is coming soon.
|
||||
pub fn milestone(&self) -> Option<&'static str> {
|
||||
match self.status {
|
||||
Status::Planned { milestone } => Some(milestone),
|
||||
Status::Deck { milestone, .. } if self.command().is_none() => Some(milestone),
|
||||
Status::Ready | Status::Deck { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The hover text: label, tip and command, or when it comes.
|
||||
pub fn tooltip(&self) -> String {
|
||||
match (self.command(), self.milestone()) {
|
||||
(Some(command), _) => format!("{}\n{}\n{command}", self.label, self.tip),
|
||||
(None, m) => format!("{} — coming soon ({})\n{}", self.label, m.unwrap_or("later"), self.tip),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: &[] }
|
||||
}
|
||||
|
||||
const fn ready_more(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
tab: Tab,
|
||||
panel: &'static str,
|
||||
tip: &'static str,
|
||||
more: &'static [&'static str],
|
||||
) -> Capability {
|
||||
Capability { more, ..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: &[] }
|
||||
}
|
||||
|
||||
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: &[] }
|
||||
}
|
||||
|
||||
use Tab::*;
|
||||
|
||||
/// Every capability in toolbar order: grouped by tab (in [`Tab::ALL`] order), then by panel.
|
||||
pub static CATALOGUE: &[Capability] = &[
|
||||
// ---- Geometry ----
|
||||
ready("geometry.open", "Open Design", Geometry, "Import", "Open a SolidCraft design: its bodies with persistent face names"),
|
||||
ready("geometry.import.mesh", "Tagged Mesh", Geometry, "Import", "Read a face-tagged surface mesh (3MF)"),
|
||||
ready("geometry.bodies", "Bodies", Geometry, "Inspect", "List the open design's bodies, volumes and named faces"),
|
||||
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
|
||||
ready(
|
||||
"mesh.surface.check",
|
||||
"Check Surface",
|
||||
Geometry,
|
||||
"Inspect",
|
||||
"Weld, closed, manifold, orientation and self-intersection checks; feature edges",
|
||||
),
|
||||
soon("geometry.partition", "Partition", Geometry, "Prepare", "M5", "Split bodies and faces as SolidCraft features"),
|
||||
soon("geometry.datum", "Datum Plane", Geometry, "Prepare", "M5", "Construction planes for partitions"),
|
||||
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
|
||||
// ---- Mesh ----
|
||||
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
|
||||
soon("mesh.size.seed", "Seed Edges", Mesh, "Size", "M2", "Element counts, sizes and bias per edge and face"),
|
||||
soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
|
||||
soon("mesh.tet.generate", "Tet Mesh", Mesh, "Tet", "M2", "Delaunay tetrahedral mesh of a body, boundary recovered and refined"),
|
||||
soon("mesh.order.quadratic", "Quadratic", Mesh, "Tet", "M2", "Second-order elements with mid-side nodes on the geometry"),
|
||||
ready("mesh.delaunay.points", "Delaunay Points", Mesh, "Tet", "Delaunay tetrahedralization of a point list (debugging aid)"),
|
||||
soon("mesh.quad.faces", "Quad Faces", Mesh, "Structured Hex", "M5", "All-quad meshes of faces on shared edge nodes"),
|
||||
soon("mesh.hex.mapped", "Mapped Hex", Mesh, "Structured Hex", "M5", "Mapped hex mesh of six-sided regions"),
|
||||
soon("mesh.hex.sweep", "Sweep Hex", Mesh, "Structured Hex", "M5", "Hex mesh swept along extrusions and revolutions"),
|
||||
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"),
|
||||
soon("mesh.hex.generate", "All-Hex", Mesh, "Automatic Hex", "M6", "Octree dual all-hex mesh of any part"),
|
||||
soon("mesh.hex.dominant", "Hex-Dominant", Mesh, "Automatic Hex", "M6", "Hex-dominant mesh by recombining tets"),
|
||||
soon("mesh.hex.polycube", "Polycube", Mesh, "Automatic Hex", "M6", "Polycube hex mesh of blocky parts"),
|
||||
soon("mesh.smooth", "Smooth", Mesh, "Improve", "M6", "Smooth node positions, keeping every element valid"),
|
||||
soon("mesh.untangle", "Untangle", Mesh, "Improve", "M6", "Repair inverted elements"),
|
||||
soon("mesh.layers", "Layers", Mesh, "Improve", "M6", "Boundary padding and layers through thin walls"),
|
||||
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
|
||||
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove"]),
|
||||
soon("mesh.node.merge", "Merge Nodes", Mesh, "Edit", "M6", "Merge coincident nodes"),
|
||||
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
|
||||
soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"),
|
||||
ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"),
|
||||
soon("mesh.verify", "Verify Set", Mesh, "Verify", "M2", "Quality and validity per set"),
|
||||
ready_more("mesh.tag", "Tag Geometry", Mesh, "Verify", "Tag elements, faces, edges and nodes with the geometry they mesh", &["mesh.tag.clear"]),
|
||||
// ---- Properties ----
|
||||
ready_more(
|
||||
"model.material.create",
|
||||
"Material",
|
||||
Properties,
|
||||
"Materials",
|
||||
"Elastic (isotropic, orthotropic) material with density",
|
||||
&["model.material.edit", "model.material.delete"],
|
||||
),
|
||||
soon("model.material.plasticity", "Plasticity", Properties, "Materials", "M8", "Plastic hardening data"),
|
||||
soon("model.material.hyperelastic", "Hyperelastic", Properties, "Materials", "M8", "Hyperelastic material models"),
|
||||
soon("model.material.damage", "Damage", Properties, "Materials", "M8", "Damage initiation and evolution"),
|
||||
soon("model.material.thermal", "Thermal", Properties, "Materials", "M8", "Conductivity, specific heat and expansion"),
|
||||
soon(
|
||||
"model.material.eos",
|
||||
"Equation of State",
|
||||
Properties,
|
||||
"Materials",
|
||||
"M11",
|
||||
"Polynomial, ideal gas, Mie–Grüneisen, JWL and tabulated equations of state",
|
||||
),
|
||||
ready_more(
|
||||
"model.section.create",
|
||||
"Section",
|
||||
Properties,
|
||||
"Sections",
|
||||
"Solid, shell, beam or rod section assigned to a set, with its element formulation",
|
||||
&["model.section.edit", "model.section.delete"],
|
||||
),
|
||||
soon("model.section.composite", "Composite", Properties, "Sections", "M8", "Composite layups and continuum shells"),
|
||||
soon("model.orientation", "Orientation", Properties, "Sections", "M8", "Material orientations"),
|
||||
ready_more(
|
||||
"model.set.create",
|
||||
"Set",
|
||||
Properties,
|
||||
"Sets",
|
||||
"A set of nodes, elements and faces, or of named geometry that survives remeshing",
|
||||
&["model.set.edit", "model.set.delete", "model.set.resolve"],
|
||||
),
|
||||
ready("model.units", "Units", Properties, "Model", "The model's unit system (never converted)"),
|
||||
// ---- Assembly ----
|
||||
soon("assembly.instance", "Instances", Assembly, "Instances", "M7", "Part instances placed from SolidCraft occurrences"),
|
||||
// ---- Steps ----
|
||||
ready_more(
|
||||
"model.step.create",
|
||||
"Step",
|
||||
Steps,
|
||||
"Steps",
|
||||
"A linear static or frequency step with its output",
|
||||
&["model.step.edit", "model.step.delete"],
|
||||
),
|
||||
ready("model.step.activate", "Activate", Steps, "Steps", "Choose which loads and boundary conditions act in a step"),
|
||||
ready("model.activity", "Step Table", Steps, "Steps", "The step by load and boundary condition table"),
|
||||
soon("model.amplitude", "Amplitude", Steps, "Procedures", "M7", "Time and frequency amplitudes"),
|
||||
soon("model.step.nonlinear", "Nonlinear", Steps, "Procedures", "M7", "Nonlinear static steps"),
|
||||
soon("model.step.explicit", "Explicit", Steps, "Procedures", "M8", "Explicit dynamic steps"),
|
||||
soon("model.step.heat", "Heat Transfer", Steps, "Procedures", "M8", "Heat transfer steps"),
|
||||
// ---- Loads & BCs ----
|
||||
ready_more(
|
||||
"model.boundary_condition.create",
|
||||
"Boundary Condition",
|
||||
Loads,
|
||||
"Boundary Conditions",
|
||||
"Fixed, displacement or symmetry condition on a set or reference point",
|
||||
&["model.boundary_condition.edit", "model.boundary_condition.delete"],
|
||||
),
|
||||
ready_more(
|
||||
"model.load.create",
|
||||
"Load",
|
||||
Loads,
|
||||
"Loads",
|
||||
"Force, moment, pressure, traction, gravity or body force",
|
||||
&["model.load.edit", "model.load.delete"],
|
||||
),
|
||||
soon("flow.domain", "Fluid Domain", Loads, "Flow", "M12", "The fluid grid voxelised from the face-tagged surface"),
|
||||
soon("flow.boundary", "Inlet & Outlet", Loads, "Flow", "M12", "Velocity inlets, pressure outlets and walls"),
|
||||
// ---- Interactions ----
|
||||
ready_more(
|
||||
"model.reference_point.create",
|
||||
"Reference Point",
|
||||
Interactions,
|
||||
"Constraints",
|
||||
"A reference point for couplings, loads and boundary conditions",
|
||||
&["model.reference_point.edit", "model.reference_point.delete"],
|
||||
),
|
||||
ready_more(
|
||||
"model.coupling.create",
|
||||
"Coupling",
|
||||
Interactions,
|
||||
"Constraints",
|
||||
"Kinematic or distributing coupling of a set to a reference point",
|
||||
&["model.coupling.edit", "model.coupling.delete"],
|
||||
),
|
||||
soon("interaction.tie", "Tie", Interactions, "Constraints", "M7", "Tie non-matching meshes together"),
|
||||
soon("interaction.contact.general", "General Contact", Interactions, "Contact", "M7", "Contact between everything that touches, with friction"),
|
||||
soon("interaction.contact.pair", "Contact Pair", Interactions, "Contact", "M7", "Contact between two surfaces"),
|
||||
soon("interaction.contact.find", "Find Contacts", Interactions, "Contact", "M7", "Detect contact pairs automatically"),
|
||||
soon("interaction.rigid_wall", "Rigid Wall", Interactions, "Contact", "M10", "Rigid walls for explicit dynamics"),
|
||||
soon("interaction.bolt", "Bolt Load", Interactions, "Connectors", "M7", "Bolt pretension"),
|
||||
soon("interaction.spring", "Spring", Interactions, "Connectors", "M7", "Springs and dashpots"),
|
||||
soon("interaction.mass", "Point Mass", Interactions, "Connectors", "M7", "Point masses and inertia"),
|
||||
soon("interaction.connector", "Connector", Interactions, "Connectors", "M7", "Connector elements between points"),
|
||||
soon("interaction.fsi", "FSI Interface", Interactions, "Coupled", "M13", "The fluid–structure interface"),
|
||||
// ---- Solve ----
|
||||
ready("model.check", "Check Model", Solve, "Check", "Name every missing or bad definition before writing or solving"),
|
||||
deck("deck.write.abaqus", "Abaqus Input", "abaqus", "Write an Abaqus input file (.inp)"),
|
||||
deck("deck.write.nastran", "Nastran Deck", "nastran", "Write Nastran bulk data (SOL 101, 103)"),
|
||||
deck("deck.write.lsdyna", "LS-DYNA Deck", "lsdyna", "Write an LS-DYNA keyword file"),
|
||||
soon("deck.read", "Read Deck", Solve, "Decks", "M9", "Read a deck, keeping keywords ModelCraft does not know"),
|
||||
ready("solve.static", "Static", Solve, "Implicit", "Linear static solve of a step with ModelCraft's own solver"),
|
||||
ready("solve.frequency", "Frequency", Solve, "Implicit", "Natural frequencies and mode shapes of a frequency step"),
|
||||
soon("solve.explicit", "Explicit", Solve, "Explicit", "M10", "Explicit dynamics: central differences, contact, erosion"),
|
||||
soon("solve.blast", "Blast & Impact", Solve, "Explicit", "M14", "EOS-driven fluids with fluid–structure contact (ALE or SPH)"),
|
||||
soon("solve.flow", "Lattice Boltzmann", Solve, "Fluids", "M12", "Lattice Boltzmann flow on the CPU or the GPU"),
|
||||
soon("solve.fsi", "Fluid–Structure", Solve, "Fluids", "M13", "Coupled fluid and structure solve"),
|
||||
// ---- Results ----
|
||||
soon("results.deformed", "Deformed", Results, "Display", "M4", "The deformed shape"),
|
||||
soon("results.contour", "Contours", Results, "Display", "M4", "Contour plots of stress, strain and displacement"),
|
||||
soon("results.modes", "Mode Shapes", Results, "Display", "M4", "Mode shapes of a frequency step"),
|
||||
soon("results.animate", "Animate", Results, "Display", "M9", "Animate modes and time histories"),
|
||||
ready("solve.probe", "Probe", Results, "Query", "Displacement, reaction and stress at nodes, or a mode shape"),
|
||||
soon("results.reactions", "Reactions", Results, "Query", "M4", "Reaction force and moment sums"),
|
||||
soon("results.free_body", "Free Body", Results, "Query", "M9", "Free-body forces across a cut"),
|
||||
soon("results.xy", "XY Plot", Results, "Plots", "M9", "Plot a quantity against time or another"),
|
||||
soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"),
|
||||
soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"),
|
||||
soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"),
|
||||
soon("results.streamlines", "Streamlines", Results, "Plots", "M12", "Streamlines and flow fields"),
|
||||
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
|
||||
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
|
||||
// ---- Tools ----
|
||||
ready("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary"),
|
||||
ready("file.open", "Open Model", Tools, "File", "Open a .modelcraft model"),
|
||||
ready("file.save", "Save", Tools, "File", "Save the model to its .modelcraft file"),
|
||||
ready("file.save_as", "Save As", Tools, "File", "Save the model to a new .modelcraft file"),
|
||||
ready_more(
|
||||
"file.recover",
|
||||
"Recover",
|
||||
Tools,
|
||||
"File",
|
||||
"Open a model autosaved by an app that crashed or closed with unsaved changes",
|
||||
&["file.recovery.list", "file.recovery.discard"],
|
||||
),
|
||||
ready("edit.undo", "Undo", Tools, "History", "Undo the last command that changed the model"),
|
||||
ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"),
|
||||
];
|
||||
|
||||
/// The catalogue entry with this id.
|
||||
pub fn find(id: &str) -> Option<&'static Capability> {
|
||||
CATALOGUE.iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// The entries of a tab (one run of the catalogue).
|
||||
pub fn tab_entries(tab: Tab) -> &'static [Capability] {
|
||||
let start = CATALOGUE.iter().position(|c| c.tab == tab).unwrap_or(CATALOGUE.len());
|
||||
let rest = CATALOGUE.get(start..).unwrap_or_default();
|
||||
rest.get(..rest.iter().take_while(|c| c.tab == tab).count()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The panels of a tab in order, each the run of its entries (never empty).
|
||||
pub fn panels(tab: Tab) -> impl Iterator<Item = &'static [Capability]> {
|
||||
tab_entries(tab).chunk_by(|a, b| a.panel == b.panel)
|
||||
}
|
||||
|
||||
/// `docs/features.md`: every capability by tab and panel, ready or coming soon.
|
||||
pub fn features_page() -> String {
|
||||
let ready = CATALOGUE.iter().filter(|c| c.command().is_some()).count();
|
||||
let mut md = String::from(
|
||||
"# Features\n\n\
|
||||
Generated from the capability catalogue (`crates/engine/src/catalogue.rs`) by\n\
|
||||
`cargo run -p modelcraft-cli -- features > docs/features.md`; a test fails when it is out of date.\n\
|
||||
Every entry is a toolbar button. **Ready** entries run their command; **coming soon** entries are\n\
|
||||
shown greyed with the roadmap milestone that brings them (ROADMAP.md).\n\n",
|
||||
);
|
||||
let _ = writeln!(md, "{ready} ready, {} coming soon.", CATALOGUE.len() - ready);
|
||||
for tab in Tab::ALL {
|
||||
let _ = write!(md, "\n## {}\n\n| Panel | Capability | Status | Command |\n|---|---|---|---|\n", tab.label());
|
||||
for c in tab_entries(tab) {
|
||||
let status = c.milestone().map_or_else(|| "ready".to_string(), |m| format!("coming soon ({m})"));
|
||||
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<String> = main.into_iter().chain(c.more.iter().map(|id| format!("`{id}`"))).collect();
|
||||
let _ = writeln!(md, "| {} | {} — {} | {status} | {} |", c.panel, c.label, c.tip, commands.join(", "));
|
||||
}
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{commands, find_command};
|
||||
|
||||
#[test]
|
||||
fn every_command_is_claimed_and_every_ready_entry_runs_a_command() {
|
||||
let mut claimed: Vec<&str> = Vec::new();
|
||||
for c in CATALOGUE {
|
||||
if let Some(command) = c.command() {
|
||||
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);
|
||||
}
|
||||
for m in c.more {
|
||||
assert!(find_command(m).is_some(), "{}: `{m}` is not a registered command", c.id);
|
||||
claimed.push(m);
|
||||
}
|
||||
}
|
||||
for spec in commands() {
|
||||
let n = claimed.iter().filter(|c| **c == spec.id).count();
|
||||
assert!(n >= 1, "command `{}` has no catalogue entry (a ready entry or an entry's `more`)", spec.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deck entries follow the writers: one entry per format, ready exactly when its writer is in
|
||||
/// `modelcraft_decks::FORMATS`, running `deck.write` with its format.
|
||||
#[test]
|
||||
fn deck_entries_follow_the_writers() {
|
||||
let decks: Vec<(&Capability, &str)> =
|
||||
CATALOGUE.iter().filter_map(|c| if let Status::Deck { format, .. } = c.status { Some((c, format)) } else { None }).collect();
|
||||
for f in modelcraft_decks::FORMATS {
|
||||
assert!(decks.iter().any(|(_, name)| *name == f.name), "deck format `{}` has no catalogue entry", f.name);
|
||||
}
|
||||
for (c, format) in decks {
|
||||
let written = modelcraft_decks::format(format).is_some();
|
||||
assert_eq!(c.command(), written.then_some("deck.write"), "{}", c.id);
|
||||
assert_eq!(c.milestone().is_none(), written, "{}", c.id);
|
||||
assert_eq!(c.preset(), serde_json::json!({"format": format}));
|
||||
}
|
||||
assert_eq!(find("deck.write.abaqus").and_then(Capability::command), Some("deck.write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_are_well_formed() {
|
||||
let mut ids: Vec<&str> = CATALOGUE.iter().map(|c| c.id).collect();
|
||||
for c in CATALOGUE {
|
||||
assert!(c.id.split('.').count() >= 2 && c.id.chars().all(|ch| ch.is_ascii_lowercase() || ch == '.' || ch == '_'), "{}", c.id);
|
||||
assert!(!c.label.is_empty() && !c.panel.is_empty() && !c.tip.is_empty(), "{}", c.id);
|
||||
if let Some(m) = c.milestone() {
|
||||
assert!(m.strip_prefix('M').is_some_and(|n| n.parse::<u32>().is_ok_and(|n| (1..=14).contains(&n))), "{}: {m}", c.id);
|
||||
}
|
||||
}
|
||||
ids.sort_unstable();
|
||||
let n = ids.len();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), n, "catalogue ids are unique");
|
||||
// Tabs are runs in Tab::ALL order, and each panel is one run within its tab.
|
||||
let total: usize = Tab::ALL.iter().map(|t| tab_entries(*t).len()).sum();
|
||||
assert_eq!(total, CATALOGUE.len(), "every tab's entries are together, in Tab::ALL order");
|
||||
let order: Vec<Tab> =
|
||||
CATALOGUE.iter().map(|c| c.tab).collect::<Vec<_>>().chunk_by(|a, b| a == b).filter_map(|r| r.first().copied()).collect();
|
||||
assert_eq!(order, Tab::ALL);
|
||||
for tab in Tab::ALL {
|
||||
let names: Vec<&str> = panels(tab).filter_map(|p| p.first().map(|c| c.panel)).collect();
|
||||
assert!(!names.is_empty(), "{tab:?} has no entries");
|
||||
assert!(names.iter().enumerate().all(|(i, n)| !names[..i].contains(n)), "{tab:?}: a panel's entries are together: {names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_page_is_current() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/features.md");
|
||||
let committed = std::fs::read_to_string(&path).unwrap_or_default().replace("\r\n", "\n");
|
||||
assert!(committed == features_page(), "docs/features.md is out of date: run `cargo run -p modelcraft-cli -- features > docs/features.md`");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod autosave;
|
||||
pub mod catalogue;
|
||||
pub mod cmd;
|
||||
pub mod history;
|
||||
pub mod sample;
|
||||
|
||||
@@ -17,3 +17,4 @@ egui-wgpu = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
resvg = { workspace = true }
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Embeds the toolbar icons: `assets/icons/*.svg` become `ICONS`, a sorted `(id, svg)` table
|
||||
//! (`src/icons.rs`).
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
|
||||
let dir = manifest.join("../../assets/icons");
|
||||
println!("cargo:rerun-if-changed={}", dir.display());
|
||||
let mut ids: Vec<String> =
|
||||
std::fs::read_dir(&dir)?.flatten().filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".svg")).map(str::to_string)).collect();
|
||||
ids.sort();
|
||||
let mut out = String::from("/// Every toolbar icon by catalogue id, sorted by id.\npub static ICONS: &[(&str, &str)] = &[\n");
|
||||
for id in &ids {
|
||||
let path = dir.join(format!("{id}.svg"));
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let _ = writeln!(out, " ({id:?}, include_str!({:?})),", path.display().to_string());
|
||||
}
|
||||
out.push_str("];\n");
|
||||
std::fs::write(PathBuf::from(std::env::var("OUT_DIR")?).join("icons.rs"), out)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,6 +114,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"model": {"nodes": app.session.model.nodes().len(), "elements": app.session.model.elements().len()},
|
||||
"version": crate::VERSION,
|
||||
})),
|
||||
"ui.toolbar" => ok(crate::toolbar::describe(app)),
|
||||
"ui.set" => {
|
||||
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
|
||||
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
|
||||
@@ -367,6 +368,8 @@ mod tests {
|
||||
json!({"width": 1e12, "height": -5, "path": ""}),
|
||||
json!({"command": 5, "params": "x", "commands": {"a": 1}}),
|
||||
json!({"key": "NoSuchKey", "text": 7, "view": null, "points": [[1], "x", null]}),
|
||||
json!({"tab": "nope", "command": "mesh.tet.generate"}),
|
||||
json!({"tab": 3}),
|
||||
];
|
||||
for method in [
|
||||
"engine.execute",
|
||||
@@ -384,6 +387,7 @@ mod tests {
|
||||
"ui.worldToScreen",
|
||||
"ui.resize",
|
||||
"ui.render",
|
||||
"ui.toolbar",
|
||||
] {
|
||||
for p in &hostile {
|
||||
let _ = call(&mut app, method, p.clone());
|
||||
@@ -392,6 +396,21 @@ mod tests {
|
||||
assert!(app.synthetic.len() < 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_toolbar_lists_every_entry_and_switches_tabs() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
assert_eq!(call(&mut app, "ui.set", json!({"tab": "mesh"}))["ok"], true);
|
||||
let r = call(&mut app, "ui.toolbar", json!({}));
|
||||
assert_eq!(r["result"]["tab"], "mesh", "{r}");
|
||||
let entries = r["result"]["entries"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), modelcraft_engine::catalogue::CATALOGUE.len());
|
||||
let tet = entries.iter().find(|e| e["id"] == "mesh.tet.generate").unwrap();
|
||||
assert_eq!((tet["coming_soon"].as_str(), tet["command"].is_null()), (Some("M2"), true));
|
||||
assert_eq!(call(&mut app, "ui.set", json!({"tab": "nope"}))["ok"], false);
|
||||
let r = call(&mut app, "ui.start", json!({"command": "mesh.tet.generate"}));
|
||||
assert_eq!((r["ok"].as_bool(), r["error"].as_str()), (Some(false), Some("Tet Mesh is coming soon (M2)")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshots_are_saved_as_png() {
|
||||
let image = egui::ColorImage::filled([4, 3], egui::Color32::RED);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Toolbar icons: original artwork drawn with VectorCraft (`assets/icons`, `cargo xtask icons`),
|
||||
//! embedded at build time and rasterised with resvg.
|
||||
//!
|
||||
//! Icons are drawn in four palette roles, each in one fixed colour (the dark theme's). Before
|
||||
//! rasterising, every role colour is replaced by the theme token it stands for, so the same SVG
|
||||
//! serves the dark and light themes and the greyed "coming soon" state. Textures are cached per
|
||||
//! icon, theme, state and pixel size.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Color32, TextureHandle};
|
||||
use resvg::{tiny_skia, usvg};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/icons.rs"));
|
||||
|
||||
/// The palette roles' colours in the SVG files. Keep in step with `PALETTE` in
|
||||
/// `xtask/src/icons.rs`.
|
||||
pub const PALETTE: [&str; 4] = ["#dde2ea", "#4f9ce8", "#2d4e70", "#f0b44a"];
|
||||
|
||||
/// Largest icon side rasterised (pixels).
|
||||
const MAX_PX: u32 = 256;
|
||||
|
||||
/// The icon's SVG text.
|
||||
pub fn svg(id: &str) -> Option<&'static str> {
|
||||
ICONS.binary_search_by(|(k, _)| (*k).cmp(id)).ok().and_then(|i| ICONS.get(i)).map(|(_, s)| *s)
|
||||
}
|
||||
|
||||
/// The colours the palette roles (line, accent, soft, warm) take in a theme, greyed when the
|
||||
/// entry is not available yet.
|
||||
pub fn colours(t: &Tokens, enabled: bool) -> [Color32; 4] {
|
||||
if enabled { [t.text, t.accent, t.accent_soft, t.warm] } else { [t.text_dim, t.text_dim, t.panel_header, t.text_dim] }
|
||||
}
|
||||
|
||||
/// The SVG with each palette colour replaced by its role's colour.
|
||||
pub fn recolour(svg: &str, colours: [Color32; 4]) -> String {
|
||||
let mut out = svg.to_string();
|
||||
for (from, to) in PALETTE.iter().zip(colours) {
|
||||
let hex = format!("#{:02x}{:02x}{:02x}", to.r(), to.g(), to.b());
|
||||
out = out.replace(&format!("\"{from}\""), &format!("\"{hex}\""));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rasterise an SVG to a `px` × `px` image.
|
||||
pub fn rasterise(svg: &str, px: u32) -> Result<egui::ColorImage, String> {
|
||||
let px = px.clamp(1, MAX_PX);
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).map_err(|e| e.to_string())?;
|
||||
let mut pixmap = tiny_skia::Pixmap::new(px, px).ok_or("empty icon")?;
|
||||
let size = tree.size();
|
||||
let scale = px as f32 / size.width().max(size.height()).max(f32::EPSILON);
|
||||
resvg::render(&tree, tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
|
||||
Ok(egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pixmap.data()))
|
||||
}
|
||||
|
||||
/// Icon textures by (id, enabled) for one theme and pixel size; `None` when the icon is missing
|
||||
/// or does not rasterise, so it is not tried again every frame. A change of theme or size
|
||||
/// (zoom) drops the old textures.
|
||||
#[derive(Default)]
|
||||
pub struct IconCache {
|
||||
theme_px: Option<(bool, u32)>,
|
||||
textures: HashMap<(&'static str, bool), Option<TextureHandle>>,
|
||||
}
|
||||
|
||||
impl IconCache {
|
||||
pub fn get(&mut self, ctx: &egui::Context, id: &'static str, dark: bool, enabled: bool, px: u32) -> Option<TextureHandle> {
|
||||
if self.theme_px != Some((dark, px)) {
|
||||
self.textures.clear();
|
||||
self.theme_px = Some((dark, px));
|
||||
}
|
||||
self.textures
|
||||
.entry((id, enabled))
|
||||
.or_insert_with(|| {
|
||||
let svg = svg(id)?;
|
||||
let image = rasterise(&recolour(svg, colours(&Tokens::get(dark), enabled)), px).map_err(|e| log::warn!("icon {id}: {e}")).ok()?;
|
||||
Some(ctx.load_texture(format!("icon:{id}:{dark}:{enabled}:{px}"), image, egui::TextureOptions::LINEAR))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use modelcraft_engine::catalogue::CATALOGUE;
|
||||
|
||||
#[test]
|
||||
fn every_catalogue_entry_has_an_icon_and_every_icon_an_entry() {
|
||||
for c in CATALOGUE {
|
||||
assert!(svg(c.id).is_some(), "no icon assets/icons/{}.svg", c.id);
|
||||
}
|
||||
for (id, _) in ICONS {
|
||||
assert!(CATALOGUE.iter().any(|c| c.id == *id), "assets/icons/{id}.svg belongs to no catalogue entry");
|
||||
}
|
||||
assert!(ICONS.windows(2).all(|w| w[0].0 < w[1].0), "sorted for the binary search");
|
||||
}
|
||||
|
||||
/// Icons use only the palette, so recolouring reaches every colour, and they rasterise.
|
||||
#[test]
|
||||
fn icons_use_only_palette_colours_and_rasterise() {
|
||||
for (id, text) in ICONS {
|
||||
for part in text.split('"').skip(1).step_by(2) {
|
||||
if part.starts_with('#') {
|
||||
assert!(PALETTE.contains(&part), "{id}: colour {part} is not in the palette");
|
||||
}
|
||||
}
|
||||
let image = rasterise(text, 24).unwrap();
|
||||
assert!(image.pixels.iter().any(|p| p.a() > 0), "{id} draws nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// The SVG colours are the dark theme's own, so the dark theme draws icons as authored.
|
||||
#[test]
|
||||
fn the_palette_is_the_dark_themes_colours() {
|
||||
let hex: Vec<String> = colours(&Tokens::dark(), true).iter().map(|c| format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())).collect();
|
||||
assert_eq!(hex, PALETTE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recolouring_follows_the_theme_and_greys_planned_entries() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0 L24 24" stroke="#4f9ce8" fill="#2d4e70"/></svg>"##;
|
||||
let light = Tokens::light();
|
||||
let out = recolour(svg, colours(&light, true));
|
||||
assert!(out.contains("stroke=\"#1f6fc4\"") && out.contains("fill=\"#c6dcf4\""), "{out}");
|
||||
let grey = recolour(svg, colours(&light, false));
|
||||
let dim = light.text_dim;
|
||||
assert!(grey.contains(&format!("stroke=\"#{:02x}{:02x}{:02x}\"", dim.r(), dim.g(), dim.b())), "{grey}");
|
||||
assert!(rasterise("not svg", 24).is_err());
|
||||
assert_eq!(rasterise(svg, 100_000).unwrap().size, [MAX_PX as usize; 2]);
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,17 @@
|
||||
|
||||
pub mod control;
|
||||
pub mod gpu;
|
||||
pub mod icons;
|
||||
pub mod panels;
|
||||
pub mod scene;
|
||||
pub mod theme;
|
||||
pub mod toolbar;
|
||||
pub mod viewport;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use modelcraft_engine::Session;
|
||||
use modelcraft_engine::catalogue::{self, Tab};
|
||||
use modelcraft_render::{Camera, DisplayMesh, Scene, StandardView};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -41,11 +44,21 @@ pub struct UiState {
|
||||
pub dark: bool,
|
||||
pub show_tree: bool,
|
||||
pub show_commands: bool,
|
||||
/// The toolbar tab shown.
|
||||
pub tab: Tab,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState { style: Style::ShadedEdges, show_nodes: false, perspective: false, dark: true, show_tree: true, show_commands: true }
|
||||
UiState {
|
||||
style: Style::ShadedEdges,
|
||||
show_nodes: false,
|
||||
perspective: false,
|
||||
dark: true,
|
||||
show_tree: true,
|
||||
show_commands: true,
|
||||
tab: Tab::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +115,9 @@ pub struct ModelApp {
|
||||
pub status: Option<Status>,
|
||||
pub dialog: Option<ParamsDialog>,
|
||||
pub viewport: Viewport,
|
||||
pub icons: icons::IconCache,
|
||||
/// The toolbar's buttons drawn last frame, by catalogue id (for the control channel).
|
||||
pub toolbar_rects: Vec<(&'static str, egui::Rect, toolbar::Fit)>,
|
||||
/// The GPU adapter, for the status bar and diagnostics.
|
||||
pub gpu_info: Option<String>,
|
||||
/// Bumped whenever the model may have changed.
|
||||
@@ -131,6 +147,8 @@ impl ModelApp {
|
||||
status: None,
|
||||
dialog: None,
|
||||
viewport: Viewport::default(),
|
||||
icons: icons::IconCache::default(),
|
||||
toolbar_rects: Vec::new(),
|
||||
gpu_info: None,
|
||||
revision: 0,
|
||||
display: (None, DisplayMesh::default()),
|
||||
@@ -200,15 +218,29 @@ impl ModelApp {
|
||||
r
|
||||
}
|
||||
|
||||
/// What clicking a command in the Commands panel does: a command without parameters runs,
|
||||
/// one with parameters opens its form. Programmatic calls use [`ModelApp::run`] instead.
|
||||
/// What clicking a toolbar entry or a command in the Commands panel does: a command without
|
||||
/// parameters runs, one with parameters opens its form. `id` is a command or a catalogue
|
||||
/// entry; an entry that is coming soon does nothing and says when it comes. Programmatic calls
|
||||
/// use [`ModelApp::run`] instead.
|
||||
pub fn start(&mut self, id: &str) -> Result<(), String> {
|
||||
let spec = modelcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
|
||||
let (command, preset) = match catalogue::find(id) {
|
||||
Some(c) => match c.command() {
|
||||
Some(command) => (command, c.preset()),
|
||||
None => {
|
||||
let e = format!("{} is coming soon ({})", c.label, c.milestone().unwrap_or("later"));
|
||||
self.set_status(e.clone(), false);
|
||||
return Err(e);
|
||||
}
|
||||
},
|
||||
None => (id, json!({})),
|
||||
};
|
||||
let spec = modelcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?;
|
||||
if spec.params.trim() == "{}" {
|
||||
self.dialog = None;
|
||||
self.run(spec.id, json!({})).map(|_| ())
|
||||
} else {
|
||||
self.dialog = Some(ParamsDialog { command: spec.id, text: "{}".into(), error: None });
|
||||
// The form starts with the entry's preset; the rest (a path…) is filled in there.
|
||||
self.dialog = Some(ParamsDialog { command: spec.id, text: preset.to_string(), error: None });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -360,6 +392,7 @@ impl ModelApp {
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
self.show_gpu_errors();
|
||||
panels::menu_bar(self, ui);
|
||||
toolbar::toolbar(self, ui);
|
||||
panels::status_bar(self, ui);
|
||||
if self.ui.show_tree {
|
||||
panels::model_tree(self, ui);
|
||||
@@ -480,6 +513,24 @@ mod tests {
|
||||
assert!(app.start("no.such").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_entries_start_their_command_and_planned_ones_do_nothing() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.start("model.material.create").unwrap();
|
||||
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.material.create"));
|
||||
app.dialog = None;
|
||||
let journal = app.session.journal.len();
|
||||
let err = app.start("mesh.tet.generate").unwrap_err();
|
||||
assert_eq!(err, "Tet Mesh is coming soon (M2)");
|
||||
assert!(app.dialog.is_none());
|
||||
assert_eq!(app.session.journal.len(), journal);
|
||||
assert_eq!(app.status.as_ref().map(|s| s.error), Some(false));
|
||||
// A deck entry opens deck.write's form with its format filled in.
|
||||
app.start("deck.write.abaqus").unwrap();
|
||||
let d = app.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.command, d.text.as_str()), ("deck.write", r#"{"format":"abaqus"}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_fills_the_view_and_renders() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct Tokens {
|
||||
pub hover: Color32,
|
||||
pub error: Color32,
|
||||
pub ok: Color32,
|
||||
/// Loads and heat in icons.
|
||||
pub warm: Color32,
|
||||
// ---- viewport ----
|
||||
/// Background gradient, top and bottom.
|
||||
pub viewport_top: Color32,
|
||||
@@ -62,6 +64,7 @@ impl Tokens {
|
||||
hover: Color32::from_rgb(0x3c, 0x43, 0x4f),
|
||||
error: Color32::from_rgb(0xf0, 0x6c, 0x64),
|
||||
ok: Color32::from_rgb(0x8c, 0xc8, 0x8c),
|
||||
warm: Color32::from_rgb(0xf0, 0xb4, 0x4a),
|
||||
viewport_top: Color32::from_rgb(0x3a, 0x41, 0x4d),
|
||||
viewport_bottom: Color32::from_rgb(0x1e, 0x22, 0x28),
|
||||
overlay: Color32::from_rgba_unmultiplied_const(0x1c, 0x20, 0x26, 0xd0),
|
||||
@@ -93,6 +96,7 @@ impl Tokens {
|
||||
hover: Color32::from_rgb(0xd8, 0xde, 0xe6),
|
||||
error: Color32::from_rgb(0xc0, 0x30, 0x28),
|
||||
ok: Color32::from_rgb(0x2c, 0x7a, 0x3a),
|
||||
warm: Color32::from_rgb(0xc0, 0x7a, 0x14),
|
||||
viewport_top: Color32::from_rgb(0xf0, 0xf3, 0xf7),
|
||||
viewport_bottom: Color32::from_rgb(0xc8, 0xd0, 0xdc),
|
||||
overlay: Color32::from_rgba_unmultiplied_const(0xea, 0xee, 0xf3, 0xe0),
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! The toolbar: one tab per stage of the analyst's workflow, each a row of panels of icon buttons,
|
||||
//! all from the capability catalogue (`modelcraft_engine::catalogue`).
|
||||
//!
|
||||
//! When the window is too narrow for labelled buttons, every panel goes icon-only (the label is
|
||||
//! in the tooltip), then panels collapse from the right into one drop-down button each.
|
||||
//!
|
||||
//! A ready entry starts its command (the same as the Commands panel: a command without
|
||||
//! parameters runs, one with parameters opens its form); its related commands (edit, delete…)
|
||||
//! are in the drop-down beside it. A planned entry is drawn greyed with its milestone and does
|
||||
//! nothing; its tooltip says when it comes. Structure follows SolidCraft's
|
||||
//! `crates/ui-egui/src/toolbar.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, FontId, Galley, Rect, RichText, Sense, Stroke, vec2};
|
||||
use modelcraft_engine::catalogue::{self, Capability, Tab};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Icon side (points).
|
||||
const ICON: f32 = 24.0;
|
||||
/// Button height with its label, and the width the label wraps at (points).
|
||||
const BUTTON_H: f32 = 60.0;
|
||||
const LABEL_W: f32 = 64.0;
|
||||
/// A button without its label (compact panels).
|
||||
const COMPACT: f32 = ICON + 12.0;
|
||||
/// The drop-down strip beside a button with related commands.
|
||||
const ARROW_W: f32 = 12.0;
|
||||
/// A collapsed panel: one button that opens the list of its entries.
|
||||
const COLLAPSED_W: f32 = COMPACT + ARROW_W;
|
||||
/// Room a panel separator takes.
|
||||
const PANEL_GAP: f32 = 13.0;
|
||||
/// Room between buttons of a panel.
|
||||
const BUTTON_GAP: f32 = 2.0;
|
||||
|
||||
/// How a panel is drawn on a toolbar that may be too narrow for every labelled button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Fit {
|
||||
/// Icon and label.
|
||||
Full,
|
||||
/// Icon only; the label is in the tooltip.
|
||||
Compact,
|
||||
/// One button that lists the panel's entries.
|
||||
Collapsed,
|
||||
}
|
||||
|
||||
/// Fit panels into `room`: every panel in full when they all fit; otherwise every panel compact,
|
||||
/// then panels wider than a collapsed one collapse from the right until the row fits. Nothing is
|
||||
/// dropped.
|
||||
pub fn fit(full: &[f32], compact: &[f32], room: f32) -> Vec<Fit> {
|
||||
let total = |widths: &mut dyn Iterator<Item = f32>| -> f32 { widths.map(|w| w + PANEL_GAP).sum() };
|
||||
if total(&mut full.iter().copied()) <= room {
|
||||
return vec![Fit::Full; full.len()];
|
||||
}
|
||||
let mut fits = vec![Fit::Compact; compact.len()];
|
||||
for i in (0..fits.len()).rev() {
|
||||
let width = total(&mut compact.iter().zip(&fits).map(|(w, f)| if *f == Fit::Collapsed { COLLAPSED_W } else { *w }));
|
||||
if width <= room {
|
||||
break;
|
||||
}
|
||||
if let (Some(f), Some(w)) = (fits.get_mut(i), compact.get(i))
|
||||
&& *w > COLLAPSED_W
|
||||
{
|
||||
*f = Fit::Collapsed;
|
||||
}
|
||||
}
|
||||
fits
|
||||
}
|
||||
|
||||
/// An entry's label laid out under its icon (at most two lines).
|
||||
fn label(ui: &egui::Ui, t: &Tokens, c: &Capability) -> Arc<Galley> {
|
||||
let colour = if c.command().is_some() { t.text } else { t.text_dim };
|
||||
let mut job = egui::text::LayoutJob::simple(c.label.to_string(), FontId::proportional(11.0), colour, LABEL_W);
|
||||
job.wrap.max_rows = 2;
|
||||
job.halign = egui::Align::Center;
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
fn arrow_w(c: &Capability) -> f32 {
|
||||
if c.command().is_some() && !c.more.is_empty() { ARROW_W } else { 0.0 }
|
||||
}
|
||||
|
||||
fn panel_width(widths: impl Iterator<Item = f32>) -> f32 {
|
||||
let (sum, n) = widths.fold((0.0, 0usize), |(s, n), w| (s + w, n + 1));
|
||||
sum + BUTTON_GAP * n.saturating_sub(1) as f32
|
||||
}
|
||||
|
||||
pub fn toolbar(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let mut action = None;
|
||||
egui::Panel::top("mc_toolbar").frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 4))).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for tab in Tab::ALL {
|
||||
let text = RichText::new(tab.label()).color(if app.ui.tab == tab { t.accent } else { t.text });
|
||||
if ui.selectable_label(app.ui.tab == tab, text).clicked() {
|
||||
app.ui.tab = tab;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
app.toolbar_rects.clear();
|
||||
let panels: Vec<&'static [Capability]> = catalogue::panels(app.ui.tab).collect();
|
||||
let labels: Vec<Vec<Arc<Galley>>> = panels.iter().map(|p| p.iter().map(|c| label(ui, &t, c)).collect()).collect();
|
||||
let full: Vec<f32> = panels
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.map(|(p, l)| panel_width(p.iter().zip(l).map(|(c, g)| g.size().x.max(ICON + 12.0) + 8.0 + arrow_w(c))))
|
||||
.collect();
|
||||
let compact: Vec<f32> = panels.iter().map(|p| panel_width(p.iter().map(|c| COMPACT + arrow_w(c)))).collect();
|
||||
let fits = fit(&full, &compact, ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
for (i, ((entries, galleys), fit)) in panels.iter().zip(labels).zip(&fits).enumerate() {
|
||||
let Some(panel) = entries.first().map(|c| c.panel) else { continue };
|
||||
if i > 0 {
|
||||
ui.separator();
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = BUTTON_GAP;
|
||||
let clicked = match fit {
|
||||
Fit::Collapsed => collapsed(app, ui, &t, panel, entries),
|
||||
Fit::Full | Fit::Compact => {
|
||||
let mut clicked = None;
|
||||
for (c, galley) in entries.iter().zip(galleys) {
|
||||
let galley = (*fit == Fit::Full).then_some(galley);
|
||||
clicked = button(app, ui, &t, c, galley).or(clicked);
|
||||
}
|
||||
clicked
|
||||
}
|
||||
};
|
||||
action = clicked.or(action);
|
||||
});
|
||||
let width = ui.min_rect().width();
|
||||
ui.allocate_ui(vec2(width, 14.0), |ui| {
|
||||
ui.centered_and_justified(|ui| ui.add(egui::Label::new(RichText::new(panel).small().color(t.text_dim)).truncate()));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(id) = action {
|
||||
// Errors already show in the status bar.
|
||||
let _ = app.start(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(app: &mut ModelApp, ui: &egui::Ui, t: &Tokens, c: &'static Capability, rect: Rect) {
|
||||
let px = (ICON * ui.ctx().pixels_per_point()).round() as u32;
|
||||
let p = ui.painter();
|
||||
match app.icons.get(ui.ctx(), c.id, app.ui.dark, c.command().is_some(), px) {
|
||||
Some(tex) => p.image(tex.id(), rect, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE),
|
||||
None => p.rect_stroke(rect.shrink(3.0), 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside),
|
||||
};
|
||||
}
|
||||
|
||||
/// One entry's button, with its label when `label` is given; returns the id to start when it
|
||||
/// (or its drop-down) was clicked.
|
||||
fn button(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, c: &'static Capability, label: Option<Arc<Galley>>) -> Option<&'static str> {
|
||||
let enabled = c.command().is_some();
|
||||
let size = match &label {
|
||||
Some(g) => vec2(g.size().x.max(ICON + 12.0) + 8.0, BUTTON_H),
|
||||
None => vec2(COMPACT, COMPACT),
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
|
||||
app.toolbar_rects.push((c.id, rect, if label.is_some() { Fit::Full } else { Fit::Compact }));
|
||||
if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
let icon_rect = Rect::from_center_size(egui::pos2(rect.center().x, rect.top() + 6.0 + ICON / 2.0), vec2(ICON, ICON));
|
||||
icon(app, ui, t, c, icon_rect);
|
||||
let p = ui.painter();
|
||||
if let Some(g) = label {
|
||||
p.galley(egui::pos2(rect.center().x, icon_rect.bottom() + 4.0), g, t.text);
|
||||
}
|
||||
if let Some(m) = c.milestone() {
|
||||
let text = p.layout_no_wrap(m.to_string(), FontId::proportional(9.0), t.text);
|
||||
let badge = Rect::from_min_size(egui::pos2(rect.right() - text.size().x - 4.0, rect.top()), text.size() + vec2(4.0, 0.0));
|
||||
p.rect_filled(badge, 3.0, t.panel_header);
|
||||
p.rect_stroke(badge, 3.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
p.galley(badge.min + vec2(2.0, 0.0), text, t.text);
|
||||
}
|
||||
let resp = resp.on_hover_ui(|ui| {
|
||||
ui.label(c.tooltip());
|
||||
});
|
||||
let mut action = resp.clicked().then_some(c.id);
|
||||
if enabled && !c.more.is_empty() {
|
||||
let (arrow, arrow_resp) = ui.allocate_exact_size(vec2(ARROW_W, size.y), Sense::click());
|
||||
if arrow_resp.hovered() {
|
||||
ui.painter().rect_filled(arrow, 4.0, t.hover);
|
||||
}
|
||||
ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "▾", FontId::proportional(11.0), t.text_dim);
|
||||
let arrow_resp = arrow_resp.on_hover_text(format!("More {} commands", c.label));
|
||||
egui::Popup::menu(&arrow_resp).show(|ui| {
|
||||
for id in c.more {
|
||||
if ui.button(crate::command_label(id)).clicked() {
|
||||
action = Some(*id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
/// A collapsed panel: its first entry's icon and a drop-down listing every entry (and their
|
||||
/// related commands), coming-soon ones greyed with their milestone.
|
||||
fn collapsed(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, panel: &str, entries: &'static [Capability]) -> Option<&'static str> {
|
||||
let first = entries.first()?;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(COLLAPSED_W, COMPACT), Sense::click());
|
||||
for c in entries {
|
||||
app.toolbar_rects.push((c.id, rect, Fit::Collapsed));
|
||||
}
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
icon(app, ui, t, first, Rect::from_min_size(rect.min + vec2(6.0, 6.0), vec2(ICON, ICON)));
|
||||
ui.painter().text(egui::pos2(rect.right() - ARROW_W / 2.0, rect.center().y), Align2::CENTER_CENTER, "▾", FontId::proportional(11.0), t.text_dim);
|
||||
let resp = resp.on_hover_text(panel);
|
||||
let mut action = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for c in entries {
|
||||
match c.milestone() {
|
||||
Some(m) => {
|
||||
ui.add_enabled(false, egui::Button::new(format!("{} — coming soon ({m})", c.label)));
|
||||
}
|
||||
None => {
|
||||
if ui.button(c.label).on_hover_text(c.tip).clicked() {
|
||||
action = Some(c.id);
|
||||
}
|
||||
for id in c.more {
|
||||
if ui.button(format!(" {}", crate::command_label(id))).clicked() {
|
||||
action = Some(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
action
|
||||
}
|
||||
|
||||
/// The toolbar as the control channel reports it: the tabs, the current tab, and every entry
|
||||
/// with its status and, on the current tab, its button (screen points, last frame).
|
||||
pub fn describe(app: &ModelApp) -> serde_json::Value {
|
||||
let entries: Vec<serde_json::Value> = catalogue::CATALOGUE
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let shown = app.toolbar_rects.iter().find(|(id, ..)| *id == c.id);
|
||||
let rect = shown.map(|(_, r, _)| [r.left(), r.top(), r.width(), r.height()]);
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"label": c.label,
|
||||
"tab": c.tab,
|
||||
"panel": c.panel,
|
||||
"command": c.command(),
|
||||
"more": c.more,
|
||||
"coming_soon": c.milestone(),
|
||||
"rect": rect,
|
||||
"fit": shown.map(|(.., f)| f),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({"tab": app.ui.tab, "tabs": Tab::ALL, "entries": entries})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn panels_go_compact_then_collapse_from_the_right() {
|
||||
let full = [200.0, 300.0, 100.0];
|
||||
let compact = [80.0, 120.0, 40.0];
|
||||
assert_eq!(fit(&full, &compact, 700.0), vec![Fit::Full; 3]);
|
||||
assert_eq!(fit(&full, &compact, 400.0), vec![Fit::Compact; 3]);
|
||||
// The last panel is narrower compact than collapsed, so the middle one goes first.
|
||||
assert_eq!(fit(&full, &compact, 230.0), vec![Fit::Compact, Fit::Collapsed, Fit::Compact]);
|
||||
assert_eq!(fit(&full, &compact, 10.0), vec![Fit::Collapsed, Fit::Collapsed, Fit::Compact], "nothing is dropped");
|
||||
assert!(fit(&[], &[], 0.0).is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user