forked from 1b-agent/fieldcraft-upstream
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:
1 parent
06b98f01c7
commit
e0d5adba23
20 files changed
+448
-25
No files matched your search
@@ -20,6 +20,7 @@ pub const BLOCK: CommandSpec = CommandSpec {
|
||||
params: r#"{"body": "Plate", "kind": "hex8|hex20|tet4|tet10|wedge6", "corners": [[x, y, z] × 8] (hex8 order: 1-2-3-4 counter-clockwise seen from 5-6-7-8), "divisions": [n12, n14, n15] (elements along 1→2, 1→4, 1→5), "bias": [1, 1, 1] (optional: last over first element size along each direction), "edges": [{"corners": [1, 2], "arc": {"center": [x, y, z]}} | {"corners": [2, 3], "ellipse": {"center": [x, y, z], "axes": [[a], [b]] (perpendicular)}} | {"corners": [5, 6], "name": "tip_edge"}] (optional: curved edges, the shorter arc between the corners, and edge names), "faces": {"1": "bottom", "4": "tip"} (optional: names of block faces, numbered as hex8 faces: 1 = 1-2-3-4, 2 = 5-6-7-8, 3 = 1-2-6-5, 4 = 2-3-7-6, 5 = 3-4-8-7, 6 = 4-1-5-8), "vertices": {"1": "A"} (optional: names of corners), "merge": distance (optional, default 1e-6 of the block's diagonal; 0 never joins)} — adds a mapped mesh of the block (transfinite interpolation of its edges; quadratic nodes on the curves), uses existing nodes within `merge` of new ones so blocks that share a face join (the shared face must have the same elements on both sides, or nothing changes), and tags the elements with the body and element faces, edges and nodes with the named faces, edges and vertices. 2D: "kind": "quad4|quad8|tri3|tri6", four "corners" at z = 0 (counter-clockwise from +z), "divisions": [n12, n14], "bias": [r, r], "face": "area" (the face of the body the elements fill, default `area`), edges and vertices numbered among the four corners; for plane stress, plane strain and axisymmetric sections"#,
|
||||
form: Some(super::forms::BLOCK),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: block_mesh,
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, Friction, InitialP
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{name, opt};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// The options every contact takes, from the parameters.
|
||||
@@ -46,6 +47,7 @@ pub const PAIR: CommandSpec = CommandSpec {
|
||||
),
|
||||
form: Some(super::forms::CONTACT_PAIR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Set],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let surfaces = ContactSurfaces::Pair { main: text(p, "main")?.to_string(), secondary: text(p, "secondary")?.to_string() };
|
||||
@@ -64,6 +66,7 @@ pub const GENERAL: CommandSpec = CommandSpec {
|
||||
),
|
||||
form: Some(super::forms::CONTACT_GENERAL),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let surface = match p.get("surface") {
|
||||
@@ -81,6 +84,7 @@ pub const EDIT: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "impact", "surfaces": {"type": "pair", "main": "plate", "secondary": "ball"} | {"type": "general", "surface": "skin" (optional)}, "friction": {…}, "penalty_scale": 1, "initial_penetration": "adjust|refuse"} — replaces the contact of that name (the model's form; see interaction.contact.pair)"#,
|
||||
form: Some(super::forms::CONTACT_EDIT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Contact],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let mut obj = p.as_object().cloned().ok_or_else(|| EngineError::Params("the parameters of a contact are a JSON object".into()))?;
|
||||
@@ -97,6 +101,7 @@ pub const DELETE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "impact"}"#,
|
||||
form: Some(super::forms::CONTACT_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Contact],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.remove_contact(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
@@ -118,6 +123,7 @@ pub const TIE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "glue", "main": "plate" (a set of element faces), "secondary": "rib" (another), "position_tolerance": 0.1 (optional: how far a secondary node may lie from the main surface and still be tied; default half the main element's thickness)} — bonds the secondary surface to the main one across non-matching meshes: each secondary node follows the main surface under it (dual mortar constraints in the implicit analyses; a constant stress crosses the tie exactly); fails if the name is taken"#,
|
||||
form: Some(super::forms::TIE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Set],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.add_tie(n, tie(p)?)?;
|
||||
@@ -131,6 +137,7 @@ pub const TIE_EDIT: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "glue", "main": "plate", "secondary": "rib", "position_tolerance": 0.1} — replaces the tie of that name (see interaction.tie)"#,
|
||||
form: Some(super::forms::TIE_EDIT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Tie],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.replace_tie(n, tie(p)?)?;
|
||||
@@ -144,6 +151,7 @@ pub const TIE_DELETE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "glue"}"#,
|
||||
form: Some(super::forms::TIE_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Tie],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.remove_tie(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
@@ -159,6 +167,7 @@ pub const FIND: CommandSpec = CommandSpec {
|
||||
),
|
||||
form: Some(super::forms::CONTACT_FIND),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: find,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use fieldcraft_io::roots::Access;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::req;
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const WRITE: CommandSpec = CommandSpec {
|
||||
@@ -14,6 +15,7 @@ pub const WRITE: CommandSpec = CommandSpec {
|
||||
params: r#"{"format": "abaqus|nastran|lsdyna", "path": "part.inp", "steps": ["Load"]} — writes the model as a solver deck (formats: fieldcraft_decks::FORMATS); `steps` (optional) writes only those steps, as a Nastran run needs (static or frequency steps, not both); LS-DYNA writes several steps as *CASE analyses (run it with CASE); fails naming every error of the model check and everything the format cannot express"#,
|
||||
form: Some(super::forms::DECK),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection],
|
||||
run: write,
|
||||
};
|
||||
|
||||
@@ -60,7 +62,7 @@ mod tests {
|
||||
assert!(s.execute("deck.write", &json!({"path": other})).is_err());
|
||||
assert!(s.execute("deck.write", &json!({"format": "abaqus"})).is_err());
|
||||
let mut empty = Session::default();
|
||||
assert!(matches!(empty.execute("deck.write", &json!({"format": "abaqus", "path": other})), Err(EngineError::Deck(_))));
|
||||
assert!(matches!(empty.execute("deck.write", &json!({"format": "abaqus", "path": other})), Err(EngineError::Unavailable(_))));
|
||||
assert!(!dir.join("other.inp").exists());
|
||||
assert!(matches!(s.execute("deck.write", &json!({"format": "abaqus", "path": dir.to_string_lossy()})), Err(EngineError::File(_))));
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{name, opt, req};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// The parameters other than `name`, read as a definition's JSON form.
|
||||
@@ -26,13 +27,14 @@ fn fields<T: DeserializeOwned>(p: &Value, what: &str) -> Result<T> {
|
||||
/// Create, edit and delete commands for one kind of definition.
|
||||
macro_rules! definition_commands {
|
||||
($what:literal, $ty:ty, $id:literal, $label:literal, $doc:literal, $form:expr, $delete_form:expr, $add:ident, $replace:ident, $remove:ident,
|
||||
$CREATE:ident, $EDIT:ident, $DELETE:ident) => {
|
||||
$CREATE:ident, $EDIT:ident, $DELETE:ident, requires: $requires:expr, exists: $exists:ident) => {
|
||||
pub const $CREATE: CommandSpec = CommandSpec {
|
||||
id: concat!("model.", $id, ".create"),
|
||||
label: concat!("New ", $label),
|
||||
params: concat!($doc, " — fails if the name is taken"),
|
||||
form: Some($form),
|
||||
kind: CommandKind::Model,
|
||||
requires: $requires,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$add(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -45,6 +47,7 @@ macro_rules! definition_commands {
|
||||
params: concat!($doc, " — replaces the existing definition of that name"),
|
||||
form: Some($form),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::$exists],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$replace(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -57,6 +60,7 @@ macro_rules! definition_commands {
|
||||
params: r#"{"name": "…"} — refused while another definition refers to it"#,
|
||||
form: Some($delete_form),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::$exists],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.$remove(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
@@ -78,7 +82,9 @@ definition_commands!(
|
||||
remove_set,
|
||||
SET_CREATE,
|
||||
SET_EDIT,
|
||||
SET_DELETE
|
||||
SET_DELETE,
|
||||
requires: &[],
|
||||
exists: Set
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -94,7 +100,9 @@ definition_commands!(
|
||||
remove_material,
|
||||
MATERIAL_CREATE,
|
||||
MATERIAL_EDIT,
|
||||
MATERIAL_DELETE
|
||||
MATERIAL_DELETE,
|
||||
requires: &[],
|
||||
exists: Material
|
||||
);
|
||||
|
||||
/// A copy of material `name` with `change` applied, put back through the model's validation.
|
||||
@@ -112,6 +120,7 @@ pub const MATERIAL_PLASTICITY: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "Steel", "plastic": {"type": "bilinear", "yield_stress": 250, "hardening_modulus": 1000, "hardening": "isotropic|kinematic"} | {"type": "table", "points": [{"strain": 0, "stress": 250}, {"strain": 0.1, "stress": 300}]} | {"type": "johnson_cook", "a": 792, "b": 510, "n": 0.26, "c": 0.014, "m": 1.03, "reference_strain_rate": 1, "room_temperature": 294, "melt_temperature": 1793, "specific_heat": 4.77e8, "taylor_quinney": 0.9} | null} — von Mises plasticity of an isotropic material (null removes it); explicit dynamics uses it, the linear solver the elastic constants alone"#,
|
||||
form: Some(super::forms::MATERIAL_PLASTICITY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Material],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
change_material(s, p, |m, p| {
|
||||
m.plastic = req(p, "plastic")?;
|
||||
@@ -126,6 +135,7 @@ pub const MATERIAL_DAMAGE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "Steel", "damage": {"type": "johnson_cook", "d1": 0.05, "d2": 3.44, "d3": -2.12, "d4": 0.002, "d5": 0.61} | null, "erosion": {"plastic_strain": 1.0} | null} — damage to failure and element erosion of a plastic material (each key optional; null removes it); a failed element is eroded in explicit dynamics"#,
|
||||
form: Some(super::forms::MATERIAL_DAMAGE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Material],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
change_material(s, p, |m, p| {
|
||||
// Absent leaves a field as it is; null removes it.
|
||||
@@ -157,7 +167,9 @@ definition_commands!(
|
||||
remove_section,
|
||||
SECTION_CREATE,
|
||||
SECTION_EDIT,
|
||||
SECTION_DELETE
|
||||
SECTION_DELETE,
|
||||
requires: &[Requirement::Material, Requirement::Set],
|
||||
exists: Section
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -173,7 +185,9 @@ definition_commands!(
|
||||
remove_reference_point,
|
||||
REFERENCE_POINT_CREATE,
|
||||
REFERENCE_POINT_EDIT,
|
||||
REFERENCE_POINT_DELETE
|
||||
REFERENCE_POINT_DELETE,
|
||||
requires: &[],
|
||||
exists: ReferencePoint
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -189,7 +203,9 @@ definition_commands!(
|
||||
remove_coupling,
|
||||
COUPLING_CREATE,
|
||||
COUPLING_EDIT,
|
||||
COUPLING_DELETE
|
||||
COUPLING_DELETE,
|
||||
requires: &[Requirement::ReferencePoint, Requirement::Set],
|
||||
exists: Coupling
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -205,7 +221,9 @@ definition_commands!(
|
||||
remove_mass,
|
||||
MASS_CREATE,
|
||||
MASS_EDIT,
|
||||
MASS_DELETE
|
||||
MASS_DELETE,
|
||||
requires: &[Requirement::Target],
|
||||
exists: Mass
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -221,7 +239,9 @@ definition_commands!(
|
||||
remove_spring,
|
||||
SPRING_CREATE,
|
||||
SPRING_EDIT,
|
||||
SPRING_DELETE
|
||||
SPRING_DELETE,
|
||||
requires: &[Requirement::Target],
|
||||
exists: Spring
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -237,7 +257,9 @@ definition_commands!(
|
||||
remove_boundary_condition,
|
||||
BOUNDARY_CONDITION_CREATE,
|
||||
BOUNDARY_CONDITION_EDIT,
|
||||
BOUNDARY_CONDITION_DELETE
|
||||
BOUNDARY_CONDITION_DELETE,
|
||||
requires: &[Requirement::Target],
|
||||
exists: BoundaryCondition
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -253,7 +275,9 @@ definition_commands!(
|
||||
remove_load,
|
||||
LOAD_CREATE,
|
||||
LOAD_EDIT,
|
||||
LOAD_DELETE
|
||||
LOAD_DELETE,
|
||||
requires: &[],
|
||||
exists: Load
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -269,7 +293,9 @@ definition_commands!(
|
||||
remove_fluid_domain,
|
||||
FLUID_DOMAIN_CREATE,
|
||||
FLUID_DOMAIN_EDIT,
|
||||
FLUID_DOMAIN_DELETE
|
||||
FLUID_DOMAIN_DELETE,
|
||||
requires: &[Requirement::Material],
|
||||
exists: FluidDomain
|
||||
);
|
||||
|
||||
pub const FLUID_BOUNDARY: CommandSpec = CommandSpec {
|
||||
@@ -278,6 +304,7 @@ pub const FLUID_BOUNDARY: CommandSpec = CommandSpec {
|
||||
params: r#"{"domain": "Flow", "at": "x_min" (a box face) | {"body": "Body1", "face": "F2:top"} (an obstacle face), "condition": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"} | {"type": "pressure", "pressure": 0} | {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w]} | {"type": "slip"} | {"type": "periodic"}} — sets one boundary's condition (obstacle faces take walls and moving walls)"#,
|
||||
form: Some(super::forms::FLUID_BOUNDARY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::FluidDomain],
|
||||
run: fluid_boundary,
|
||||
};
|
||||
|
||||
@@ -295,6 +322,7 @@ pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
form: Some(super::forms::STEP_CREATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: step_create,
|
||||
};
|
||||
|
||||
@@ -304,6 +332,7 @@ pub const STEP_EDIT: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "Bend", "procedure", "output", "boundary_conditions", "loads"} — as model.step.create; what is left out keeps its value"#,
|
||||
form: Some(super::forms::STEP_EDIT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Step],
|
||||
run: step_edit,
|
||||
};
|
||||
|
||||
@@ -313,6 +342,7 @@ pub const STEP_DELETE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "Bend"}"#,
|
||||
form: Some(super::forms::STEP_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Step],
|
||||
run: |s, p| s.model.remove_step(name(p)?).map(|_| Value::Null).map_err(Into::into),
|
||||
};
|
||||
|
||||
@@ -322,6 +352,7 @@ pub const STEP_ACTIVATE: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Bend", "boundary_conditions": [names], "loads": [names], "active": true} — switches the listed items on (or off with false) in that step only"#,
|
||||
form: Some(super::forms::STEP_ACTIVATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Step],
|
||||
run: step_activate,
|
||||
};
|
||||
|
||||
@@ -331,6 +362,7 @@ pub const ACTIVITY: CommandSpec = CommandSpec {
|
||||
params: "{}",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: |s, _| Ok(activity(&s.model)),
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ pub const UNDO: CommandSpec = CommandSpec {
|
||||
params: r#"{"count"?: 1} — takes back the last command that changed the model (it leaves the journal and can be redone); `count` takes back that many, as far as the history reaches"#,
|
||||
form: Some(super::forms::COUNT),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: |s, p| repeat(s, p, Session::undo),
|
||||
};
|
||||
|
||||
@@ -47,6 +48,7 @@ pub const REDO: CommandSpec = CommandSpec {
|
||||
params: r#"{"count"?: 1} — runs the last undone command's change again; `count` redoes that many, as far as there are undone commands"#,
|
||||
form: Some(super::forms::COUNT),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: |s, p| repeat(s, p, Session::redo),
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ pub const OPEN: CommandSpec = CommandSpec {
|
||||
params: r#"{"path": "bracket.fieldcraft"} — opens the model, its mesh and its journal, and opens its design and imported meshes again from their files (one that is missing or changed is listed in `warnings`; the model still opens); unsaved changes of the current model are discarded and the undo history starts empty"#,
|
||||
form: Some(super::forms::FILE_OPEN),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: open,
|
||||
};
|
||||
|
||||
@@ -34,6 +35,7 @@ pub const SAVE: CommandSpec = CommandSpec {
|
||||
params: r#"{"path"?: "bracket.fieldcraft"} — saves to `path`, else to the file the model was opened from or last saved to; written atomically, the previous version kept as `<file>.bak`"#,
|
||||
form: Some(super::forms::FILE_SAVE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: save,
|
||||
};
|
||||
|
||||
@@ -43,6 +45,7 @@ pub const SAVE_AS: CommandSpec = CommandSpec {
|
||||
params: r#"{"path": "bracket.fieldcraft"} — saves to a new file, which becomes the model's file"#,
|
||||
form: Some(super::forms::FILE_SAVE_AS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: save_as,
|
||||
};
|
||||
|
||||
@@ -52,6 +55,7 @@ pub const RECOVERY_LIST: CommandSpec = CommandSpec {
|
||||
params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first (default folder: FIELDCRAFT_RECOVERY_DIR or the per-user data folder)"#,
|
||||
form: Some(super::forms::RECOVERY_LIST),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: recovery_list,
|
||||
};
|
||||
|
||||
@@ -61,6 +65,7 @@ pub const RECOVER: CommandSpec = CommandSpec {
|
||||
params: r#"{"id": "…", "dir"?: "…"} — opens an autosaved model (an id from file.recovery.list) with its original file path, as unsaved changes; the autosave stays until file.recovery.discard"#,
|
||||
form: Some(super::forms::RECOVER),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: recover,
|
||||
};
|
||||
|
||||
@@ -70,6 +75,7 @@ pub const RECOVERY_DISCARD: CommandSpec = CommandSpec {
|
||||
params: r#"{"id": "…"} or {"all": true}; "dir"?: "…" — deletes autosaves of apps that are no longer running"#,
|
||||
form: Some(super::forms::RECOVERY_DISCARD),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: recovery_discard,
|
||||
};
|
||||
|
||||
@@ -79,6 +85,7 @@ pub const SAMPLE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "cantilever"} — opens a sample model (fieldcraft_engine::sample; the list is in docs/examples.md) in place of the current one, unsaved changes discarded; every sample but `parts` is ready to solve (solve.run)"#,
|
||||
form: Some(super::forms::SAMPLE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: open_sample,
|
||||
};
|
||||
|
||||
@@ -405,6 +412,9 @@ mod tests {
|
||||
refused(s.execute("file.recovery.discard", &json!({"all": true, "dir": input})));
|
||||
s.execute("geometry.open", &json!({"path": input.join("plate.solidcraft")})).unwrap();
|
||||
defined(&mut s);
|
||||
// A section, so deck.write has all it needs and reaches its path.
|
||||
s.execute("model.set.create", &json!({"name": "all", "elements": [1]})).unwrap();
|
||||
s.execute("model.section.create", &json!({"name": "solid", "set": "all", "material": "Steel", "kind": {"type": "solid"}})).unwrap();
|
||||
// Writes go to the write root only: not the read root, not elsewhere.
|
||||
refused(s.execute("file.save_as", &json!({"path": input.join("m.fieldcraft")})));
|
||||
refused(s.execute("file.save_as", &json!({"path": outside.join("m.fieldcraft")})));
|
||||
|
||||
@@ -9,6 +9,7 @@ use fieldcraft_model::Model;
|
||||
use fieldcraft_model::geometry::{Entity, GeometryRef};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
@@ -17,6 +18,7 @@ pub const OPEN: CommandSpec = CommandSpec {
|
||||
params: r#"{"path": "part.solidcraft", "model"?: "keep"} — rebuilds the SolidCraft design and makes its bodies the geometry. `model`: "keep" (default) keeps the mesh and every definition, so opening the same design again after an edit picks up the change and sets follow their persistent face names; "new" starts a new model with the design (no mesh, sets, materials, sections, BCs, loads or steps; the unit system stays; the model is untitled until saved, and undo restores the previous file). The reply's `related` is false when the model (before the open) refers to bodies (mesh tags, set geometry) and the design has none of them, and `stale` lists what no longer resolves: `mesh_bodies` (bodies the mesh is tagged with that the design lacks) and `sets` (sets naming a body or face it lacks). One undo step"#,
|
||||
form: Some(super::forms::OPEN_DESIGN),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: open,
|
||||
};
|
||||
|
||||
@@ -26,6 +28,7 @@ pub const BODIES: CommandSpec = CommandSpec {
|
||||
params: "{} — the open design's bodies: measures and persistent face names (by face index)",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[], // No design is an answer too: no bodies.
|
||||
run: bodies,
|
||||
};
|
||||
|
||||
@@ -35,6 +38,7 @@ pub const TESSELLATE: CommandSpec = CommandSpec {
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} — triangulates the body's boundary within the chordal tolerance (mm); reports triangles per face"#,
|
||||
form: Some(super::forms::TESSELLATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: tessellate,
|
||||
};
|
||||
|
||||
@@ -44,6 +48,7 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
|
||||
params: r#"{"path": "part.3mf"} — adds each build item of a 3MF package as a surface; triangles keep their B-rep face tags (docs/formats/tagged-mesh.md)"#,
|
||||
form: Some(super::forms::IMPORT_MESH),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: import_mesh,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::{GeometryRef, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HEX_GENERATE: CommandSpec = CommandSpec {
|
||||
@@ -17,6 +18,7 @@ pub const HEX_GENERATE: CommandSpec = CommandSpec {
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the mesh with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
|
||||
form: Some(super::forms::HEX_GENERATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_generate,
|
||||
};
|
||||
|
||||
@@ -120,6 +122,7 @@ pub const HEX_SWEEP: CommandSpec = CommandSpec {
|
||||
params: r#"the parameters of mesh.hex.generate, with "method": "swept" (default: extrude, sweep or revolve, whichever fits first) | "extrude" | "sweep" | "revolve" — mesh.hex.generate limited to the sweeping methods"#,
|
||||
form: Some(super::forms::HEX_SWEEP),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_sweep,
|
||||
};
|
||||
|
||||
@@ -129,6 +132,7 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec {
|
||||
params: r#"the parameters of mesh.hex.generate without "method" — mesh.hex.generate with the mapped method: a six-sided block (six faces of four edges, three edges at each of eight corners)"#,
|
||||
form: Some(super::forms::HEX_MAPPED),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_mapped,
|
||||
};
|
||||
|
||||
@@ -138,6 +142,7 @@ pub const HEX_BLOCKS: CommandSpec = CommandSpec {
|
||||
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the mesh with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
|
||||
form: Some(super::forms::HEX_BLOCKS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: hex_blocks,
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{label, labels, listed, optional_number, points, xyz};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const NODE_ADD: CommandSpec = CommandSpec {
|
||||
@@ -21,6 +22,7 @@ pub const NODE_ADD: CommandSpec = CommandSpec {
|
||||
params: r#"{"label": 1, "xyz": [x, y, z]} — adds the node, or moves it if the label exists"#,
|
||||
form: Some(super::forms::NODE_ADD),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: node_add,
|
||||
};
|
||||
|
||||
@@ -30,6 +32,7 @@ pub const NODE_REMOVE: CommandSpec = CommandSpec {
|
||||
params: r#"{"label": 1} — only a node no element uses"#,
|
||||
form: Some(super::forms::LABEL),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Nodes],
|
||||
run: node_remove,
|
||||
};
|
||||
|
||||
@@ -39,6 +42,7 @@ pub const ELEMENT_ADD: CommandSpec = CommandSpec {
|
||||
params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|hex8|hex20", "nodes": [labels]} — adds or replaces"#,
|
||||
form: Some(super::forms::ELEMENT_ADD),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Nodes],
|
||||
run: element_add,
|
||||
};
|
||||
|
||||
@@ -48,6 +52,7 @@ pub const ELEMENT_REMOVE: CommandSpec = CommandSpec {
|
||||
params: r#"{"label": 1}"#,
|
||||
form: Some(super::forms::LABEL),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: element_remove,
|
||||
};
|
||||
|
||||
@@ -57,6 +62,7 @@ pub const QUALITY: CommandSpec = CommandSpec {
|
||||
params: r#"{"elements": [labels] (optional, default all), "bins": 10 (1..=1000)} — certified validity (det J > 0 everywhere), volume, and min/max/mean/worst/histogram per metric (angles in degrees; histograms over each metric's fixed range, out-of-range values in the end bins)"#,
|
||||
form: Some(super::forms::QUALITY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: quality_report,
|
||||
};
|
||||
|
||||
@@ -66,6 +72,7 @@ pub const DELAUNAY_POINTS: CommandSpec = CommandSpec {
|
||||
params: r#"{"points": [[x, y, z], ...] (at most 20000)} — the exact Delaunay tetrahedralization (a debugging report; the model is not changed): {"dimension", "vertices", "duplicates": [[point, kept point], ...], "tets": [[i, j, k, l], ...]} with 0-based point indices in tet4 order; collinear or coplanar points give no tets"#,
|
||||
form: Some(super::forms::DELAUNAY_POINTS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: delaunay_points,
|
||||
};
|
||||
|
||||
@@ -92,6 +99,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, optional "weld": 1e-9 (0 or 1e-15..=1e-3 of the bounding-box diagonal), "weld_distance": mm (default: the surface's own, twice SolidCraft's linear tolerance for a tessellated body, 0 for an imported mesh), "feature_angle": 45 (degrees, (0, 180]) — welds the tessellated body or imported surface and checks it can bound a volume mesh: closed, manifold, outward, no self-intersections; reports shells, genus, volume and feature edges, or the located defects"#,
|
||||
form: Some(super::forms::SURFACE_CHECK),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: surface_check,
|
||||
};
|
||||
|
||||
@@ -101,6 +109,7 @@ pub const TET_GENERATE: CommandSpec = CommandSpec {
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Reports the totals and, under `bodies`, each body's counts, size and quality report"#,
|
||||
form: Some(super::forms::TET),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: tet_generate,
|
||||
};
|
||||
|
||||
@@ -350,6 +359,7 @@ pub const PLANAR: CommandSpec = CommandSpec {
|
||||
params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#,
|
||||
form: Some(super::forms::PLANAR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: planar,
|
||||
};
|
||||
|
||||
@@ -837,7 +847,7 @@ mod tests {
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("mesh.tet.generate", &json!({})).unwrap_err().to_string().contains("open a design first"));
|
||||
assert!(s.execute("mesh.tet.generate", &json!({})).unwrap_err().to_string().contains("Needs geometry"));
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let r = s.execute("mesh.tet.generate", &json!({})).unwrap();
|
||||
let bodies = r["bodies"].as_array().unwrap();
|
||||
@@ -860,7 +870,7 @@ mod tests {
|
||||
#[test]
|
||||
fn quality_summary_counts_invalid_elements() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(s.execute("mesh.quality", &json!({})).unwrap()["summary"], "no elements to measure");
|
||||
assert!(matches!(s.execute("mesh.quality", &json!({})), Err(crate::EngineError::Unavailable(_))), "no elements to measure");
|
||||
for (label, xyz) in [(1, [0, 0, 0]), (2, [1, 0, 0]), (3, [0, 1, 0]), (4, [0, 0, 1])] {
|
||||
s.execute("mesh.node.add", &json!({"label": label, "xyz": xyz})).unwrap();
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ use fieldcraft_model::{Severity, UnitSystem};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{listed, name};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const INFO: CommandSpec =
|
||||
CommandSpec { id: "model.info", label: "Model Info", params: "{}", form: Some(&[]), kind: CommandKind::Model, run: info };
|
||||
CommandSpec { id: "model.info", label: "Model Info", params: "{}", form: Some(&[]), kind: CommandKind::Model, requires: &[], run: info };
|
||||
|
||||
pub const UNITS: CommandSpec = CommandSpec {
|
||||
id: "model.units",
|
||||
@@ -13,11 +14,12 @@ pub const UNITS: CommandSpec = CommandSpec {
|
||||
params: r#"{"system": "m-kg-s-N-Pa|mm-t-s-N-MPa|mm-kg-ms-kN-GPa|mm-g-ms-N-MPa|in-lbf-s-psi|unspecified"} — labels the system the model's numbers are in; nothing is converted"#,
|
||||
form: Some(super::forms::UNITS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: units,
|
||||
};
|
||||
|
||||
pub const CHECK: CommandSpec =
|
||||
CommandSpec { id: "model.check", label: "Check Model", params: "{}", form: Some(&[]), kind: CommandKind::Model, run: check };
|
||||
CommandSpec { id: "model.check", label: "Check Model", params: "{}", form: Some(&[]), kind: CommandKind::Model, requires: &[], run: check };
|
||||
|
||||
pub const SET_RESOLVE: CommandSpec = CommandSpec {
|
||||
id: "model.set.resolve",
|
||||
@@ -25,6 +27,7 @@ pub const SET_RESOLVE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "root"} — the set's nodes, elements and element faces on the current mesh (the first 100 of each); fails naming every member that is not on the mesh"#,
|
||||
form: Some(super::forms::SET_RESOLVE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Set],
|
||||
run: set_resolve,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::hex::{Input, inputs, number, tag_faces};
|
||||
use super::mesh::optional_count;
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HEX_OCTREE: CommandSpec = CommandSpec {
|
||||
@@ -17,6 +18,7 @@ pub const HEX_OCTREE: CommandSpec = CommandSpec {
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the mesh with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
|
||||
form: Some(super::forms::HEX_OCTREE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_octree,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{opt, xyz};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::results::{Field, Kind, MAX_BANDS, MIN_BANDS, ResultsView};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
@@ -14,6 +15,7 @@ pub const DEFORMED: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Load", "show": true, "scale": "auto"} — shows a solved step's deformed shape (`show: false` the undeformed mesh); `scale` is a factor (0 draws the undeformed shape, so a sequence from 0 swings it out) or "auto" (the largest displacement drawn a tenth of the model's size); `step` defaults to the step shown or the first solved one"#,
|
||||
form: Some(super::forms::DEFORMED),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: deformed,
|
||||
};
|
||||
|
||||
@@ -23,6 +25,7 @@ pub const CONTOUR: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Load", "field": "von_mises", "bands": 12} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands"#,
|
||||
form: Some(super::forms::CONTOUR),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: contour,
|
||||
};
|
||||
|
||||
@@ -32,6 +35,7 @@ pub const MODES: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Modes", "mode": 1} — shows a mode shape of a solved frequency step (from 1) and lists every mode's frequency"#,
|
||||
form: Some(super::forms::MODES),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: modes,
|
||||
};
|
||||
|
||||
@@ -41,6 +45,7 @@ pub const ANIMATE: CommandSpec = CommandSpec {
|
||||
params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#,
|
||||
form: Some(super::forms::ANIMATE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: animate,
|
||||
};
|
||||
|
||||
@@ -50,6 +55,7 @@ pub const FRAME: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#,
|
||||
form: Some(super::forms::FRAME),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: frame,
|
||||
};
|
||||
|
||||
@@ -59,6 +65,7 @@ pub const REACTIONS: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0]} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` defaults to the sets the step's boundary conditions hold"#,
|
||||
form: Some(super::forms::REACTIONS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: reactions,
|
||||
};
|
||||
|
||||
@@ -68,6 +75,7 @@ pub const CLEAR: CommandSpec = CommandSpec {
|
||||
params: "{} — back to the model: hides the deformed shape, contours and modes",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::ResultsShown],
|
||||
run: clear,
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use fieldcraft_solver::{
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{labels, opt, points, req};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, StepResult};
|
||||
|
||||
pub const RUN: CommandSpec = CommandSpec {
|
||||
@@ -21,6 +22,7 @@ pub const RUN: CommandSpec = CommandSpec {
|
||||
params: r#"{} — solves every step in order with its procedure's solver (solve.static, solve.frequency, solve.explicit, solve.fluid with their default caps) and keeps the results; stops at the first step that fails, naming it"#,
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_all,
|
||||
};
|
||||
|
||||
@@ -30,6 +32,7 @@ pub const STATIC: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Load", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — solves a linear static or coupled temperature–displacement step (the only one when `step` is left out) and keeps the result in the session; the caps may only be lowered; `threads` (1 to 256, by default the session's: `FIELDCRAFT_THREADS` or the cores) changes only the time, never the result"#,
|
||||
form: Some(super::forms::SOLVE_STATIC),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_static,
|
||||
};
|
||||
|
||||
@@ -39,6 +42,7 @@ pub const NONLINEAR: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment, and with contact pairs (interaction.contact.pair: mortar contact by augmented Lagrangian, Coulomb friction by a return map) each contact's force, normal force, largest pressure, closed and slipping nodes, deepest penetration and augmentations (solve.probe gives each secondary node's pressure, gap and status, and with friction its shear traction and slip); the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_NONLINEAR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_nonlinear,
|
||||
};
|
||||
|
||||
@@ -48,6 +52,7 @@ pub const HEAT: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Heat", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — steady or transient heat conduction of a heat transfer step (the only one when `step` is left out): temperatures, heat flux and the heat flow at prescribed temperatures, kept in the session; the caps may only be lowered; `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_STATIC),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_heat,
|
||||
};
|
||||
|
||||
@@ -57,6 +62,7 @@ pub const FREQUENCY: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Modes", "mass": "consistent|lumped", "shift": 0.0, "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — natural frequencies and mode shapes of a frequency step (its modes and range); `max_iterations` caps the Lanczos vectors; the result stays in the session; `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_FREQUENCY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_frequency,
|
||||
};
|
||||
|
||||
@@ -66,6 +72,7 @@ pub const BUCKLE: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Buckle", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — linear buckling of a buckling step (the only one when `step` is left out): the step's loads as a linear preload, then the lowest load factors λ with (K + λ K_σ) φ = 0 and their mode shapes (largest component 1), or those above the step's shift (whose LDLᵀ factor counts the load factors below it); negative load factors found are reported; the result stays in the session and its modes display like frequency modes; `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_BUCKLE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_buckle,
|
||||
};
|
||||
|
||||
@@ -75,6 +82,7 @@ pub const EXPLICIT: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Impact", "max_dofs": 6000000, "max_increments": 1000000000, "max_output_values": 50000000, "max_contact_faces": 10000000, "threads": 8} — transient response of an explicit dynamic step (the only one when `step` is left out) by central differences, with the model's contacts (interaction.contact.*) and the step's rigid walls; keeps its histories and field frames in the session and reports each contact's peak force, first and last contact, deepest penetration, friction dissipated and the nodes adjusted at the start; the caps may only be lowered (the step's own increment cap applies too); `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_EXPLICIT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_explicit,
|
||||
};
|
||||
|
||||
@@ -84,6 +92,7 @@ pub const HISTORY: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Impact", "every": 1} — the time histories of a solved explicit step: time, increment, energies, momentum, constraint force, the normal force across each contact (in `contacts` order) and the history nodes' displacement and velocity; `every` keeps one sample in so many (by default enough to return at most 10000)"#,
|
||||
form: Some(super::forms::SOLVE_HISTORY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Result],
|
||||
run: history,
|
||||
};
|
||||
|
||||
@@ -93,6 +102,7 @@ pub const FLUID: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Flow", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; the caps may only be lowered; `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_FLUID),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::FluidDomain, Requirement::Step],
|
||||
run: run_fluid,
|
||||
};
|
||||
|
||||
@@ -102,6 +112,7 @@ pub const PROBE: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
|
||||
form: Some(super::forms::SOLVE_PROBE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Result],
|
||||
run: probe,
|
||||
};
|
||||
|
||||
@@ -792,7 +803,7 @@ pub(crate) mod tests {
|
||||
s.execute("solve.run", &json!({})).unwrap();
|
||||
assert!(s.result("Impact").is_some());
|
||||
let mut empty = Session::default();
|
||||
assert!(empty.execute("solve.run", &json!({})).unwrap_err().to_string().contains("no steps"));
|
||||
assert!(empty.execute("solve.run", &json!({})).unwrap_err().to_string().contains("Needs a mesh"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -5,6 +5,7 @@ use fieldcraft_model::{ElementFace, GeometryRef, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{opt, req};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const TAG: CommandSpec = CommandSpec {
|
||||
@@ -13,11 +14,19 @@ pub const TAG: CommandSpec = CommandSpec {
|
||||
params: r#"{"geometry": {"body": "Body1"} | {"body", "face"} | {"body", "edge"} | {"body", "vertex"}, "elements": [labels] (filling a body, face or edge), "faces": [{"element": 1, "face": 3}] (on a face), "edges": [{"element": 1, "edge": 2}] (on an edge), "nodes": [labels] (on a vertex)} — records where the mesh lies on the geometry, so sets named by geometry resolve"#,
|
||||
form: Some(super::forms::TAG),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: tag,
|
||||
};
|
||||
|
||||
pub const TAG_CLEAR: CommandSpec =
|
||||
CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", form: Some(&[]), kind: CommandKind::Model, run: tag_clear };
|
||||
pub const TAG_CLEAR: CommandSpec = CommandSpec {
|
||||
id: "mesh.tag.clear",
|
||||
label: "Clear Geometry Tags",
|
||||
params: "{}",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: tag_clear,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod cmd;
|
||||
|
||||
pub use cmd::analytic_surfaces;
|
||||
pub mod history;
|
||||
pub mod requirement;
|
||||
pub mod results;
|
||||
pub mod sample;
|
||||
pub mod schema;
|
||||
@@ -56,6 +57,9 @@ pub enum EngineError {
|
||||
Explicit(#[from] fieldcraft_explicit::Error),
|
||||
#[error(transparent)]
|
||||
Fluid(#[from] fieldcraft_lbm::Error),
|
||||
/// The session lacks what the command needs ([`requirement`]); nothing was done.
|
||||
#[error("{0}")]
|
||||
Unavailable(requirement::Unavailable),
|
||||
/// A command panicked; the model is unchanged.
|
||||
#[error("internal error in {command}: {message}")]
|
||||
Internal { command: String, message: String },
|
||||
@@ -113,6 +117,9 @@ pub struct CommandSpec {
|
||||
/// (the form is then the JSON text alone).
|
||||
pub form: Option<&'static [schema::Field]>,
|
||||
pub kind: CommandKind,
|
||||
/// What the session must hold before the command can run ([`requirement`]): checked by
|
||||
/// [`Session::execute`], and by the UI to grey the command with the missing pieces named.
|
||||
pub requires: &'static [requirement::Requirement],
|
||||
pub run: fn(&mut Session, &Value) -> Result<Value>,
|
||||
}
|
||||
|
||||
@@ -124,9 +131,9 @@ pub fn commands() -> &'static [CommandSpec] {
|
||||
impl CommandSpec {
|
||||
/// The command as JSON for listings (`fieldcraft-cli commands --json`, the control channel's
|
||||
/// `engine.commands`, MCP `list_commands`): id, label, the parameter doc and, when described,
|
||||
/// the parameters as JSON Schema.
|
||||
/// the parameters as JSON Schema, and what it requires.
|
||||
pub fn listing(&self) -> Value {
|
||||
let mut v = serde_json::json!({"id": self.id, "label": self.label, "params": self.params});
|
||||
let mut v = serde_json::json!({"id": self.id, "label": self.label, "params": self.params, "requires": self.requires});
|
||||
if let Some(form) = self.form {
|
||||
v["schema"] = schema::json_schema(form);
|
||||
}
|
||||
@@ -216,6 +223,7 @@ impl Session {
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
// An id from hostile input is not echoed in full.
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.chars().take(100).collect()))?;
|
||||
self.availability(spec).map_err(EngineError::Unavailable)?;
|
||||
// Session commands (open, save, undo, redo) change the session only in their last step,
|
||||
// after everything that can fail, so they need no copy of the model to restore.
|
||||
let before = (spec.kind == CommandKind::Model).then(|| (self.snapshot(), self.document()));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,8 @@ fn file_access_stays_inside_the_roots() {
|
||||
refused(&mut s, "save", json!({"path": outside.join("m.fieldcraft")}));
|
||||
refused(&mut s, "save", json!({"path": input.join("m.fieldcraft")}));
|
||||
refused(&mut s, "execute", json!({"command": "file.save_as", "params": {"path": outside.join("m.fieldcraft")}}));
|
||||
// A model with all a deck needs, so write_deck reaches its path.
|
||||
ok(&mut s, "execute", json!({"command": "file.sample", "params": {"name": "cantilever"}}));
|
||||
refused(&mut s, "write_deck", json!({"format": "abaqus", "path": outside.join("m.inp")}));
|
||||
refused(&mut s, "screenshot", json!({"path": outside.join("x.png")}));
|
||||
refused(&mut s, "screenshot", json!({"path": input.join("x.png")}));
|
||||
|
||||
@@ -1256,8 +1256,8 @@ mod tests {
|
||||
#[test]
|
||||
fn mesh_quality_reports_in_a_line() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.run("mesh.quality", json!({})).unwrap();
|
||||
assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Mesh Quality: no elements to measure"));
|
||||
// Nothing to measure: the command needs a mesh and says so.
|
||||
assert!(app.run("mesh.quality", json!({})).is_err());
|
||||
app.run_script(&fieldcraft_engine::sample::script()).unwrap();
|
||||
app.run("mesh.quality", json!({})).unwrap();
|
||||
let status = app.status.clone().unwrap();
|
||||
|
||||
@@ -352,6 +352,14 @@ step count and by an estimate of the memory the kept states hold (oldest steps g
|
||||
Commands of kind `Session` (`file.*`, `edit.*`) are never journalled. `file.save` and `file.open`
|
||||
write and read `.fieldcraft` files; the desktop app autosaves unsaved work for `file.recover`.
|
||||
|
||||
Each command lists what it cannot run without (`requires`: an open design, geometry, a mesh, a
|
||||
material, a step, a result, …; `crates/engine/src/requirement.rs`), next to its id. A requirement
|
||||
is a real prerequisite, never a workflow order. `Session::execute` checks them first and fails
|
||||
without doing anything, naming each missing piece and the toolbar entry that provides it ("Needs
|
||||
a material — Properties › Material"). The desktop UI reads the same check
|
||||
(`Session::availability`, a few counts, evaluated every frame) to grey ribbon buttons, command
|
||||
search results and Solve, with the same text as their tooltip.
|
||||
|
||||
## Test corpus
|
||||
|
||||
Meshers are measured on our own parts (`docs/adr/0001-libraries.md`, test geometry). Each part is
|
||||
|
||||
+2
-2
@@ -91,8 +91,8 @@ software on the computer.
|
||||
|
||||
| Tool | Arguments | What it does |
|
||||
|---|---|---|
|
||||
| `list_commands` | `filter?` | Every command with its label, parameter documentation and `schema` (its parameters as JSON Schema: types, units as `x-dimension`, defaults, required fields, one-of variants); `filter` matches id and label. |
|
||||
| `execute` | `command`, `params?` | Run any command (`list_commands`). A failure leaves the model unchanged and names what to fix. |
|
||||
| `list_commands` | `filter?` | Every command with its label, parameter documentation and `schema` (its parameters as JSON Schema: types, units as `x-dimension`, defaults, required fields, one-of variants) and `requires` (what the session must hold first: `design`, `geometry`, `mesh`, `material`, `step`, `result`, …); `filter` matches id and label. |
|
||||
| `execute` | `command`, `params?` | Run any command (`list_commands`). A failure leaves the model unchanged and names what to fix; a command whose `requires` are not met fails before doing anything, naming each missing piece and the command that provides it. |
|
||||
| `batch` | `commands: [{command, params}]` | Run steps in order, stop at the first error; the reply has every result and the failing step (`failed.index`, `failed.command`, `failed.error`). Steps before it stay applied (`undo` takes them back). |
|
||||
| `inspect_model` | — | `model.info` (counts, units, definitions), `model.activity` (the step table) and `geometry.bodies` (bodies with measures and persistent face names, which sets name as `{body, face}`). |
|
||||
| `check_model` | — | `model.check`: errors and warnings, each naming its item. |
|
||||
|
||||
Reference in new issue
Block a user