Engine: each command lists what it needs, and execute checks it first

CommandSpec gains `requires: &[Requirement]` (crates/engine/src/requirement.rs): an open
design, geometry, nodes, a mesh, something to analyse, materials and sections for the
elements, sets, the definition an edit or delete acts on, a step, a result. Session::execute
checks them before running, so a command missing one fails at once with no partial work,
naming each missing piece and the toolbar entry that provides it ("Needs a material —
Properties › Material"). Requirements are real prerequisites (the model's own reference
checks and model.check's rules), never a workflow order. engine.commands / list_commands
list them.

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-14 18:43:29 -04:00
1 parent 06b98f01c7
commit e0d5adba23
20 files changed
+448 -25

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
//! What a command needs in the session before it can run: its [`Requirement`]s.
//!
//! Each [`crate::CommandSpec`] lists its requirements (`requires`) next to its id. A requirement
//! is something the command cannot run without — an open design to mesh, a material to give
//! plasticity to, a result to contour — never a workflow order: a script that builds a model step
//! by step meets each command's requirements as it goes.
//!
//! [`Session::execute`] checks them before running a command, so a command whose requirement is
//! missing fails at once, with no partial work, naming each missing piece and the toolbar entry
//! that provides it ([`Unavailable`]). The desktop UI reads the same check
//! ([`Session::availability`]) to grey the ribbon buttons, the command search and Solve, with the
//! same text as their tooltip. Every check is a count or a few comparisons, cheap enough to run
//! for every button on every frame.
use serde::Serialize;
use crate::{CommandSpec, Session, catalogue};
/// Something a command needs in the session.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Requirement {
/// An open SolidCraft design (`geometry.open`).
Design,
/// Geometry to mesh: an open design or an imported surface.
Geometry,
/// At least one mesh node.
Nodes,
/// At least one element.
Mesh,
/// Something to analyse: elements, point masses or springs, or only fluid steps (whose
/// domains carry their own grid) — the model check's first rule.
Analysable,
/// A material for the elements: met when there is a material or no element.
ElementMaterial,
/// A section for the elements: met when there is a section or no element.
ElementSection,
Set,
/// A set or a reference point (what boundary conditions, masses and springs act on).
Target,
Material,
Section,
ReferencePoint,
Coupling,
Contact,
Tie,
Mass,
Spring,
BoundaryCondition,
Load,
FluidDomain,
Step,
/// A solved step's result (current or stale).
Result,
/// Results on the display.
ResultsShown,
}
impl Requirement {
/// Whether the session meets it.
pub fn met(self, s: &Session) -> bool {
let m = &s.model;
let has_elements = !m.elements().is_empty();
match self {
Requirement::Design => s.design.is_some(),
Requirement::Geometry => s.design.is_some() || !s.surfaces.is_empty(),
Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements,
Requirement::Analysable => {
has_elements
|| !m.masses().is_empty()
|| !m.springs().is_empty()
|| (!m.steps().is_empty() && m.steps().iter().all(|st| st.procedure.fluid_domain().is_some()))
}
Requirement::ElementMaterial => !has_elements || !m.materials().is_empty(),
Requirement::ElementSection => !has_elements || !m.sections().is_empty(),
Requirement::Set => !m.sets().is_empty(),
Requirement::Target => !m.sets().is_empty() || !m.reference_points().is_empty(),
Requirement::Material => !m.materials().is_empty(),
Requirement::Section => !m.sections().is_empty(),
Requirement::ReferencePoint => !m.reference_points().is_empty(),
Requirement::Coupling => !m.couplings().is_empty(),
Requirement::Contact => !m.contacts().is_empty(),
Requirement::Tie => !m.ties().is_empty(),
Requirement::Mass => !m.masses().is_empty(),
Requirement::Spring => !m.springs().is_empty(),
Requirement::BoundaryCondition => !m.boundary_conditions().is_empty(),
Requirement::Load => !m.loads().is_empty(),
Requirement::FluidDomain => !m.fluid_domains().is_empty(),
Requirement::Step => !m.steps().is_empty(),
Requirement::Result => s.result_steps().next().is_some(),
Requirement::ResultsShown => s.results_view.is_some(),
}
}
/// What is missing, as a noun phrase ("a material").
pub fn what(self) -> &'static str {
match self {
Requirement::Design => "an open design",
Requirement::Geometry => "geometry (an open design or an imported surface)",
Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh",
Requirement::ElementMaterial | Requirement::Material => "a material",
Requirement::ElementSection | Requirement::Section => "a section",
Requirement::Set => "a set",
Requirement::Target => "a set or a reference point",
Requirement::ReferencePoint => "a reference point",
Requirement::Coupling => "a coupling",
Requirement::Contact => "a contact",
Requirement::Tie => "a tie",
Requirement::Mass => "a point mass",
Requirement::Spring => "a spring or dashpot",
Requirement::BoundaryCondition => "a boundary condition",
Requirement::Load => "a load",
Requirement::FluidDomain => "a fluid domain",
Requirement::Step => "a step",
Requirement::Result => "a solved step",
Requirement::ResultsShown => "results on the display",
}
}
/// The command that provides it.
pub fn provider(self) -> &'static str {
match self {
Requirement::Design | Requirement::Geometry => "geometry.open",
Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
Requirement::ElementMaterial | Requirement::Material => "model.material.create",
Requirement::ElementSection | Requirement::Section => "model.section.create",
Requirement::Set | Requirement::Target => "model.set.create",
Requirement::ReferencePoint => "model.reference_point.create",
Requirement::Coupling => "model.coupling.create",
Requirement::Contact => "interaction.contact.pair",
Requirement::Tie => "interaction.tie",
Requirement::Mass => "model.mass.create",
Requirement::Spring => "model.spring.create",
Requirement::BoundaryCondition => "model.boundary_condition.create",
Requirement::Load => "model.load.create",
Requirement::FluidDomain => "model.fluid_domain.create",
Requirement::Step => "model.step.create",
Requirement::Result => "solve.run",
Requirement::ResultsShown => "results.contour",
}
}
/// Where the provider is on the toolbar: "Properties › Material".
pub fn place(self) -> String {
let id = self.provider();
match catalogue::entry_of(id) {
Some(c) => format!("{} › {}", c.tab.label(), c.label),
None => id.to_string(),
}
}
}
/// A requirement the session does not meet.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Missing(pub Requirement);
impl std::fmt::Display for Missing {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Needs {} — {}", self.0.what(), self.0.place())
}
}
/// Why a command cannot run now: every requirement it misses, in its spec's order.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Unavailable {
pub command: &'static str,
pub missing: Vec<Missing>,
}
impl std::fmt::Display for Unavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} cannot run yet. {}", self.command, self.joined("; "))
}
}
impl Unavailable {
fn joined(&self, separator: &str) -> String {
self.missing.iter().map(ToString::to_string).collect::<Vec<_>>().join(separator)
}
/// The missing pieces, one per line.
pub fn lines(&self) -> String {
self.joined("\n")
}
/// A hover text: `head`, then the missing pieces when there are any.
pub fn tip(head: &str, missing: Option<&Unavailable>) -> String {
match missing {
Some(u) => format!("{head}\n\n{}", u.lines()),
None => head.to_string(),
}
}
/// As JSON for the control channel and listings: `{"reason", "missing": [{"requirement",
/// "what", "provider", "place"}]}`.
pub fn to_json(&self) -> serde_json::Value {
let missing: Vec<serde_json::Value> = self
.missing
.iter()
.map(|Missing(r)| serde_json::json!({"requirement": r, "what": r.what(), "provider": r.provider(), "place": r.place()}))
.collect();
serde_json::json!({"reason": self.lines(), "missing": missing})
}
}
impl Session {
/// Whether `spec` can run now: `Err` names every requirement it misses.
pub fn availability(&self, spec: &CommandSpec) -> std::result::Result<(), Unavailable> {
let missing: Vec<Missing> = spec.requires.iter().filter(|r| !r.met(self)).map(|&r| Missing(r)).collect();
if missing.is_empty() { Ok(()) } else { Err(Unavailable { command: spec.id, missing }) }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EngineError, commands};
use serde_json::json;
fn missing(s: &Session, id: &str) -> Vec<Requirement> {
let spec = crate::find_command(id).unwrap();
s.availability(spec).err().map(|u| u.missing.into_iter().map(|Missing(r)| r).collect()).unwrap_or_default()
}
/// Every command in an empty session is either available or names what it misses, with the
/// toolbar entry that provides it; and running one that misses something fails at once with
/// that same message, leaving the session as it was.
#[test]
fn every_command_in_an_empty_session_runs_or_names_what_it_misses() {
for spec in commands() {
let mut s = Session::default();
match s.availability(spec) {
Ok(()) => {}
Err(u) => {
assert!(!u.missing.is_empty(), "{}", spec.id);
for Missing(r) in &u.missing {
assert!(crate::find_command(r.provider()).is_some(), "{}: {r:?}", spec.id);
assert!(catalogue::entry_of(r.provider()).is_some(), "{}: {r:?} has no toolbar entry", spec.id);
assert!(!r.met(&s));
}
let journal = s.journal.len();
let e = s.execute(spec.id, &json!({})).unwrap_err();
assert_eq!(e, EngineError::Unavailable(u.clone()), "{}", spec.id);
assert!(e.to_string().contains("Needs "), "{e}");
assert_eq!(s.journal.len(), journal);
}
}
}
}
/// Each provider meets what it provides on its own: a requirement is never one its provider
/// itself cannot meet from an empty model (no circular greying).
#[test]
fn providers_are_not_greyed_by_what_they_provide() {
for spec in commands() {
for r in spec.requires {
let provider = crate::find_command(r.provider()).unwrap();
assert!(!provider.requires.contains(r), "{} provides {r:?} but requires it", provider.id);
}
}
}
#[test]
fn targeted_requirements() {
let empty = Session::default();
assert_eq!(missing(&empty, "model.material.plasticity"), [Requirement::Material]);
assert_eq!(missing(&empty, "results.contour"), [Requirement::Result]);
assert_eq!(missing(&empty, "solve.static"), [Requirement::Analysable, Requirement::Step]);
assert_eq!(missing(&empty, "mesh.tet.generate"), [Requirement::Geometry]);
assert!(missing(&empty, "model.material.create").is_empty());
assert!(missing(&empty, "model.step.create").is_empty());
// Meshed, nothing defined: solving needs a material, a section and a step.
let mut meshed = Session::default();
let cube = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
meshed.execute("mesh.block", &json!({"body": "B", "kind": "hex8", "corners": cube, "divisions": [1, 1, 1]})).unwrap();
assert_eq!(missing(&meshed, "solve.static"), [Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step]);
let e = meshed.execute("solve.run", &json!({})).unwrap_err().to_string();
assert_eq!(
e,
"solve.run cannot run yet. Needs a material — Properties › Material; Needs a section — Properties › Section; Needs a step — Steps › Step"
);
// A defined, solved cantilever: everything a result needs is there.
let mut solved = Session::default();
crate::cmd::solve::tests::cantilever(&mut solved);
assert!(missing(&solved, "solve.run").is_empty());
solved.execute("solve.run", &json!({})).unwrap();
for id in ["results.contour", "results.deformed", "solve.probe", "results.clear", "model.material.plasticity"] {
assert!(missing(&solved, id).is_empty(), "{id}");
}
}
}