Files
OneCAD-Simulation/crates/engine/src/cmd/deck.rs
T
0899587127 MCP server: modelcraft-cli mcp (headless or bridged), read/write roots, end-to-end analysis over MCP (#48)
* io: read and write roots that confine an automation client's file access

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: every file a command reads or writes is checked against the session's roots

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: headless pictures of a session (model, geometry, deformed shape and contours)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* mcp: MCP server over stdio with headless and connected backends, roots and caps

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* cli: modelcraft-cli mcp [--connect ADDR | --headless] with automation read and write roots

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* mcp: end-to-end analysis of a corpus part over MCP (open, tet mesh, define, solve, probe, render)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: MCP server (docs/mcp.md), Claude Code setup, roots; architecture, README, roadmap, attribution

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 22:18:08 -04:00

108 lines
6.3 KiB
Rust

//! Solver decks: the model written in a solver's input format.
use std::path::Path;
use modelcraft_io::roots::Access;
use serde_json::{Value, json};
use super::req;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const WRITE: CommandSpec = CommandSpec {
id: "deck.write",
label: "Write Solver Deck",
params: r#"{"format": "abaqus|nastran|lsdyna", "path": "part.inp", "steps": ["Load"]} — writes the model as a solver deck (formats: modelcraft_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"#,
kind: CommandKind::Session,
run: write,
};
fn write(s: &mut Session, p: &Value) -> Result<Value> {
let name: &str = req(p, "format")?;
let format = modelcraft_decks::format(name).ok_or_else(|| {
let shown: String = name.chars().take(32).collect();
let known: Vec<&str> = modelcraft_decks::FORMATS.iter().map(|f| f.name).collect();
EngineError::Params(format!("unknown deck format `{shown}`; known: {}", known.join(", ")))
})?;
let path = super::file_param(s, p, "path", Access::Write)?;
let steps: Option<Vec<String>> = super::opt(p, "steps")?;
let deck = format.write_steps(&s.model, steps.as_deref())?;
modelcraft_io::fs::write_atomic(Path::new(&path), deck.text.as_bytes(), false)?;
Ok(json!({"format": format.name, "path": path, "bytes": deck.text.len(), "warnings": deck.warnings}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_the_defined_model_and_refuses_what_it_cannot() {
let dir = std::env::temp_dir().join(format!("modelcraft-deck-write-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bar.inp");
let path_str = path.to_string_lossy().into_owned();
let mut s = Session::default();
crate::cmd::define::tests::defined_bar(&mut s);
let journal = s.journal.len();
let out = s.execute("deck.write", &json!({"format": "abaqus", "path": path_str})).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, modelcraft_decks::abaqus::write(&s.model).unwrap());
assert_eq!(out["bytes"], written.len());
assert_eq!(out["warnings"].as_array().map(Vec::len), Some(1), "`press` is in no step: {out}");
assert!(written.contains("*ELEMENT, TYPE=C3D8I"), "{written}");
assert_eq!(s.journal.len(), journal, "writing a deck does not change the model");
assert!(std::fs::read_dir(&dir).unwrap().count() == 1, "no partial file is left behind");
// Unknown formats, missing parameters and models that are not ready fail without writing.
let other = dir.join("other.inp").to_string_lossy().into_owned();
assert!(s.execute("deck.write", &json!({"format": "nope", "path": other})).unwrap_err().to_string().contains("known: abaqus"));
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!(!dir.join("other.inp").exists());
assert!(matches!(s.execute("deck.write", &json!({"format": "abaqus", "path": dir.to_string_lossy()})), Err(EngineError::File(_))));
// Nastran: one run is static or frequency steps, so the bar's two steps need two decks.
let bdf = dir.join("bar.bdf").to_string_lossy().into_owned();
let e = s.execute("deck.write", &json!({"format": "nastran", "path": bdf})).unwrap_err().to_string();
assert!(e.contains("not supported by Nastran") && e.contains("separate decks"), "{e}");
for (step, sol) in [("Bend", "SOL 101"), ("Modes", "SOL 103")] {
s.execute("deck.write", &json!({"format": "nastran", "path": bdf, "steps": [step]})).unwrap();
let deck = std::fs::read_to_string(&bdf).unwrap();
assert!(deck.contains(sol) && deck.contains("RBE2") && deck.ends_with("ENDDATA\n"), "{deck}");
}
assert_eq!(s.journal.len(), journal);
for steps in [json!([]), json!(["Nope"]), json!("Bend"), json!([1, 2]), json!([["Bend"]])] {
assert!(s.execute("deck.write", &json!({"format": "nastran", "path": bdf, "steps": steps})).is_err(), "{steps}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn writes_lsdyna_keyword_decks() {
let dir = std::env::temp_dir().join(format!("modelcraft-deck-lsdyna-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bar.k");
let mut s = Session::default();
crate::cmd::define::tests::defined_bar(&mut s);
let out = s.execute("deck.write", &json!({"format": "lsdyna", "path": path.to_string_lossy()})).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, modelcraft_decks::lsdyna::write(&s.model).unwrap().text);
assert_eq!(out["format"], "lsdyna");
assert!(written.starts_with("*KEYWORD long=y\n") && written.contains("*CASE_BEGIN_2"), "two steps, two cases");
// One chosen step: a single analysis, no cases.
s.execute("deck.write", &json!({"format": "lsdyna", "path": path.to_string_lossy(), "steps": ["Modes"]})).unwrap();
let one = std::fs::read_to_string(&path).unwrap();
assert!(one.contains("*CONTROL_IMPLICIT_EIGENVALUE") && !one.contains("*CASE_BEGIN") && !one.contains("*CONTROL_IMPLICIT_SOLUTION"), "{one}");
// What LS-DYNA cannot take is named and nothing is written.
s.execute("model.load.create", &json!({"name": "heat", "type": "body_force", "set": "all", "vector": [0, 0, 1]})).unwrap();
s.execute("model.step.activate", &json!({"step": "Bend", "loads": ["heat"]})).unwrap();
let other = dir.join("heat.k");
let e = s.execute("deck.write", &json!({"format": "lsdyna", "path": other.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("not supported by LS-DYNA") && e.contains("load `heat`"), "{e}");
assert!(!other.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}