Files
OneCAD-Simulation/crates/engine/src/requirement.rs
T

375 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
/// A datum plane or axis.
Datum,
/// A datum plane (what a partition cuts by).
DatumPlane,
/// A partition of the design.
Partition,
/// 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::Datum => !m.datums().is_empty(),
Requirement::DatumPlane => m.datums().values().any(|d| d.plane().is_some()),
Requirement::Partition => !m.partitions().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::Datum => "a datum plane or axis",
Requirement::DatumPlane => "a datum plane",
Requirement::Partition => "a partition",
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::Datum | Requirement::DatumPlane => "geometry.datum",
Requirement::Partition => "geometry.partition",
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(),
}
}
}
impl crate::schema::Collection {
/// What an item of the collection is as a requirement: its [`Requirement::provider`] is the
/// command that makes one, which a form offers when a reference field has nothing to pick.
pub fn requirement(self) -> Requirement {
use crate::schema::Collection as C;
match self {
C::Set => Requirement::Set,
C::Material => Requirement::Material,
C::Section => Requirement::Section,
C::ReferencePoint => Requirement::ReferencePoint,
C::Coupling => Requirement::Coupling,
C::Contact => Requirement::Contact,
C::Tie => Requirement::Tie,
C::Mass => Requirement::Mass,
C::Spring => Requirement::Spring,
C::BoundaryCondition => Requirement::BoundaryCondition,
C::Load => Requirement::Load,
C::FluidDomain => Requirement::FluidDomain,
C::Step => Requirement::Step,
C::SolvedStep => Requirement::Result,
C::Body => Requirement::Design,
C::Datum => Requirement::Datum,
C::DatumPlane => Requirement::DatumPlane,
C::Partition => Requirement::Partition,
}
}
}
/// 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);
}
}
}
/// A collection's requirement is met exactly when it has a name to pick, and its provider is
/// a command on the toolbar.
#[test]
fn collections_require_what_they_list() {
use crate::schema::Collection as C;
let all = [
C::Set,
C::Material,
C::Section,
C::ReferencePoint,
C::Coupling,
C::Contact,
C::Tie,
C::Mass,
C::Spring,
C::BoundaryCondition,
C::Load,
C::FluidDomain,
C::Step,
C::SolvedStep,
C::Body,
C::Datum,
C::DatumPlane,
C::Partition,
];
let empty = Session::default();
for c in all {
let r = c.requirement();
assert!(empty.names(c).is_empty() && !r.met(&empty), "{c:?}");
assert!(catalogue::entry_of(r.provider()).is_some(), "{c:?}");
}
let mut s = Session::default();
s.execute("model.material.create", &json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})).unwrap();
assert!(C::Material.requirement().met(&s) && s.names(C::Material) == ["Steel"]);
}
#[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}");
}
}
}