From fe557054ef62e96797ff943431f1b05264d9cc32 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:30:51 -0400 Subject: [PATCH] Engine: the setup checklist (Session::progress), returned by model.check The analysis stages in order (geometry, seeds & partitions, mesh, material, section, sets, boundary conditions, loads, step, solve, results), each done, next, todo, blocked or optional, with the command that does it. Built on the command requirements; Solve also reads the model check's first error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/model.rs | 3 + crates/engine/src/lib.rs | 1 + crates/engine/src/progress.rs | 244 +++++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 crates/engine/src/progress.rs diff --git a/crates/engine/src/cmd/model.rs b/crates/engine/src/cmd/model.rs index a5a84e9..73d85dd 100644 --- a/crates/engine/src/cmd/model.rs +++ b/crates/engine/src/cmd/model.rs @@ -57,11 +57,14 @@ fn units(s: &mut Session, p: &Value) -> Result { fn check(s: &mut Session, _: &Value) -> Result { let diagnostics = s.model.check(); let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count(); + let first_error = diagnostics.iter().find(|d| d.severity == Severity::Error).map(ToString::to_string); + let progress = crate::progress::to_json(&s.progress_given(first_error)); Ok(json!({ "ready": errors == 0, "errors": errors, "warnings": diagnostics.len() - errors, "diagnostics": diagnostics, + "progress": progress, })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 49d4ef7..9549e17 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod cmd; pub use cmd::analytic_surfaces; pub mod history; +pub mod progress; pub mod requirement; pub mod results; pub mod sample; diff --git a/crates/engine/src/progress.rs b/crates/engine/src/progress.rs new file mode 100644 index 0000000..90c40e4 --- /dev/null +++ b/crates/engine/src/progress.rs @@ -0,0 +1,244 @@ +//! Where the model is in the analysis workflow: the setup checklist ([`Session::progress`]). +//! +//! The stages run in the usual order — geometry, preparation, mesh, material, section, sets, +//! boundary conditions, loads, step, solve, results — each with whether it is done, the next one +//! to do, still to do, blocked (its command cannot run yet) or optional, and the command that +//! provides it. It is a guide, never a gate: a script can define things in any order, and +//! [`crate::requirement`] still decides what each command needs. +//! +//! Each stage is a [`Requirement`] check (a count), and Solve also reads the model check's first +//! error, so the whole list costs one [`fieldcraft_model::Model::check`]: the desktop reads it +//! once per model change, and `model.check` returns it as `progress`. + +use fieldcraft_model::{Procedure, Severity}; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::Session; +use crate::requirement::Requirement; + +/// A step of the analysis workflow, in order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Stage { + Geometry, + /// Seeds and partitions that steer the mesh (optional). + Prepare, + Mesh, + Material, + Section, + Sets, + BoundaryConditions, + Loads, + Step, + Solve, + Results, +} + +impl Stage { + pub const ALL: [Stage; 11] = [ + Stage::Geometry, + Stage::Prepare, + Stage::Mesh, + Stage::Material, + Stage::Section, + Stage::Sets, + Stage::BoundaryConditions, + Stage::Loads, + Stage::Step, + Stage::Solve, + Stage::Results, + ]; + + pub fn label(self) -> &'static str { + match self { + Stage::Geometry => "Geometry", + Stage::Prepare => "Seeds & partitions", + Stage::Mesh => "Mesh", + Stage::Material => "Material", + Stage::Section => "Section", + Stage::Sets => "Sets", + Stage::BoundaryConditions => "Boundary conditions", + Stage::Loads => "Loads", + Stage::Step => "Step", + Stage::Solve => "Solve", + Stage::Results => "Results", + } + } + + /// What it checks; its provider is the command the stage starts. + fn requirement(self) -> Requirement { + match self { + Stage::Geometry => Requirement::Geometry, + Stage::Prepare => Requirement::Seed, + Stage::Mesh => Requirement::Analysable, + Stage::Material => Requirement::Material, + Stage::Section => Requirement::Section, + Stage::Sets => Requirement::Set, + Stage::BoundaryConditions => Requirement::BoundaryCondition, + Stage::Loads => Requirement::Load, + Stage::Step => Requirement::Step, + Stage::Solve => Requirement::Result, + Stage::Results => Requirement::ResultsShown, + } + } +} + +/// Where a stage is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Done, + /// The first stage still to do. + Next, + /// Still to do after the next one; its command can already run. + Todo, + /// Its command cannot run yet (`detail` says why). + Blocked, + /// Not done, and not needed for this model. + Optional, +} + +/// One row of the checklist. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct StageState { + pub stage: Stage, + pub label: &'static str, + pub status: Status, + /// The command that does it. + pub command: &'static str, + /// Where that command is on the toolbar ("Properties › Material"). + pub place: String, + /// Why it is blocked: what its command misses, or the model check's first error. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +impl Session { + /// The setup checklist for the current model (one model check). + pub fn progress(&self) -> Vec { + let first_error = self.model.check().into_iter().find(|d| d.severity == Severity::Error); + self.progress_given(first_error.map(|d| d.to_string())) + } + + /// The checklist, given the model check's first error (`model.check` has run it already). + pub(crate) fn progress_given(&self, first_error: Option) -> Vec { + let m = &self.model; + let analysable = Requirement::Analysable.met(self); + // Models without elements (point masses, fluid domains) need no material or section. + let elementless = analysable && m.elements().is_empty(); + let modal_only = !m.steps().is_empty() && m.steps().iter().all(|st| matches!(st.procedure, Procedure::Frequency { .. })); + let mut next_given = false; + Stage::ALL + .into_iter() + .map(|stage| { + let r = stage.requirement(); + let done = r.met(self) || (stage == Stage::Geometry && analysable); + let optional = match stage { + Stage::Prepare => true, + Stage::Material | Stage::Section => elementless, + Stage::Loads => modal_only, + _ => false, + }; + let command = r.provider(); + let blocked = match crate::find_command(command).map(|spec| self.availability(spec)) { + Some(Err(u)) => Some(u.lines()), + _ if stage == Stage::Solve => first_error.clone(), + _ => None, + }; + let (status, detail) = if done { + (Status::Done, None) + } else if optional { + (Status::Optional, None) + } else if blocked.is_some() { + next_given = true; + (Status::Blocked, blocked) + } else if next_given { + (Status::Todo, None) + } else { + next_given = true; + (Status::Next, None) + }; + StageState { stage, label: stage.label(), status, command, place: r.place(), detail } + }) + .collect() + } +} + +/// The checklist as JSON, with the next stage's command first for agents: +/// `{"next": {"stage", "command"} | null, "stages": [...]}`. +pub fn to_json(stages: &[StageState]) -> Value { + let next = stages.iter().find(|s| matches!(s.status, Status::Next | Status::Blocked)); + json!({ + "next": next.map(|s| json!({"stage": s.stage, "command": s.command, "status": s.status, "detail": s.detail})), + "stages": stages, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use Status::*; + + fn statuses(s: &Session) -> Vec<(Stage, Status)> { + s.progress().into_iter().map(|st| (st.stage, st.status)).collect() + } + + fn status(s: &Session, stage: Stage) -> Status { + statuses(s).into_iter().find(|(st, _)| *st == stage).map(|(_, st)| st).unwrap() + } + + #[test] + fn empty_model_starts_with_geometry() { + let s = Session::default(); + let p = s.progress(); + assert_eq!(p.len(), Stage::ALL.len()); + assert_eq!((p[0].stage, p[0].status, p[0].command), (Stage::Geometry, Next, "geometry.open")); + assert_eq!(status(&s, Stage::Prepare), Optional); + // Meshing needs geometry; solving needs a mesh. + assert_eq!(status(&s, Stage::Mesh), Blocked); + assert!(p[2].detail.as_deref().is_some_and(|d| d.contains("geometry"))); + assert_eq!(status(&s, Stage::Material), Todo); + assert_eq!(status(&s, Stage::Solve), Blocked); + assert_eq!(statuses(&s).iter().filter(|(_, st)| *st == Next).count(), 1); + } + + #[test] + fn a_meshed_model_asks_for_its_material_next() { + let mut s = 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]]; + s.execute("mesh.block", &json!({"body": "B", "kind": "hex8", "corners": cube, "divisions": [1, 1, 1]})).unwrap(); + assert_eq!(status(&s, Stage::Geometry), Done); + assert_eq!(status(&s, Stage::Mesh), Done); + assert_eq!(status(&s, Stage::Material), Next); + assert_eq!(status(&s, Stage::Section), Blocked); + assert_eq!(status(&s, Stage::Step), Todo); + } + + #[test] + fn a_defined_model_is_ready_to_solve_then_to_show_results() { + let mut s = Session::default(); + crate::cmd::solve::tests::cantilever(&mut s); + let before = statuses(&s); + for (stage, st) in &before[..9] { + assert!(matches!(st, Done | Optional), "{stage:?} {st:?}"); + } + assert_eq!(status(&s, Stage::Solve), Next); + assert_eq!(to_json(&s.progress())["next"]["command"], "solve.run"); + s.execute("solve.run", &json!({})).unwrap(); + // A solve shows its results. + assert!(statuses(&s).iter().all(|(_, st)| matches!(st, Done | Optional))); + assert!(to_json(&s.progress())["next"].is_null()); + s.execute("results.clear", &json!({})).unwrap(); + assert_eq!(status(&s, Stage::Solve), Done); + assert_eq!((status(&s, Stage::Results), to_json(&s.progress())["next"]["command"].clone()), (Next, json!("results.contour"))); + } + + #[test] + fn model_check_returns_the_checklist() { + let mut s = Session::default(); + let v = s.execute("model.check", &json!({})).unwrap(); + assert_eq!(v["progress"]["next"]["command"], "geometry.open"); + assert_eq!(v["progress"]["stages"].as_array().map(Vec::len), Some(Stage::ALL.len())); + } +}