ModelCraft foundation: workspace, quality gates, FE model, command engine, geometry boundary

A pure-Rust CAE modeling workspace following the SolidCraft conventions:
- modelcraft-model: nodes and elements by label with hostile-input validation
- modelcraft-geometry: SolidCraft's kernel (pinned git dependency) behind our own API;
  tessellation into a face-tagged boundary surface for volume meshing
- modelcraft-engine: CommandSpec registry, Session::execute with rollback and panic guard,
  scripts and a journal
- modelcraft-cli: list commands, run scripts
- xtask: layers (only geometry may use solidcraft-*), assets, licences, tests, wasm, ci
- AGENTS.md, architecture, ADR 0001 (libraries and licences), roadmap, CI

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 committed 2026-10-13 13:56:42 -04:00
commit b64ec3f698
38 files changed
+3931

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
use modelcraft_model::{ElementKind, MAX_LABEL};
use serde_json::{Value, json};
use super::{label, xyz};
use crate::{CommandSpec, EngineError, Result, Session};
pub const NODE_ADD: CommandSpec = CommandSpec {
id: "mesh.node.add",
label: "Add Node",
params: r#"{"label": 1, "xyz": [x, y, z]} — adds the node, or moves it if the label exists"#,
run: node_add,
};
pub const NODE_REMOVE: CommandSpec =
CommandSpec { id: "mesh.node.remove", label: "Remove Node", params: r#"{"label": 1} — only a node no element uses"#, run: node_remove };
pub const ELEMENT_ADD: CommandSpec = CommandSpec {
id: "mesh.element.add",
label: "Add Element",
params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|hex8|hex20", "nodes": [labels]} — adds or replaces"#,
run: element_add,
};
pub const ELEMENT_REMOVE: CommandSpec =
CommandSpec { id: "mesh.element.remove", label: "Remove Element", params: r#"{"label": 1}"#, run: element_remove };
fn node_add(s: &mut Session, p: &Value) -> Result<Value> {
let l = label(p, "label")?;
s.model.set_node(l, xyz(p, "xyz")?)?;
Ok(json!({"label": l}))
}
fn node_remove(s: &mut Session, p: &Value) -> Result<Value> {
s.model.remove_node(label(p, "label")?)?;
Ok(Value::Null)
}
fn element_add(s: &mut Session, p: &Value) -> Result<Value> {
let l = label(p, "label")?;
let name = p.get("kind").and_then(Value::as_str).unwrap_or_default();
let kind = ElementKind::from_name(name).ok_or_else(|| EngineError::Params(format!("unknown element kind `{name}`")))?;
let raw = p.get("nodes").and_then(Value::as_array).ok_or_else(|| EngineError::Params("`nodes` must be a list of node labels".into()))?;
if raw.len() != kind.node_count() {
return Err(EngineError::Params(format!("{name} needs {} nodes, got {}", kind.node_count(), raw.len())));
}
let nodes = raw
.iter()
.map(|v| v.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|n| (1..=MAX_LABEL).contains(n)))
.collect::<Option<Vec<u32>>>()
.ok_or_else(|| EngineError::Params(format!("`nodes` must be node labels in 1..={MAX_LABEL}")))?;
s.model.set_element(l, kind, nodes)?;
Ok(json!({"label": l}))
}
fn element_remove(s: &mut Session, p: &Value) -> Result<Value> {
s.model.remove_element(label(p, "label")?)?;
Ok(Value::Null)
}
+29
View File
@@ -0,0 +1,29 @@
//! The command registry. Each area has its own module; [`ALL`] lists every command.
mod mesh;
mod model;
use serde_json::Value;
use crate::{CommandSpec, EngineError, Result};
pub static ALL: &[CommandSpec] = &[model::INFO, mesh::NODE_ADD, mesh::NODE_REMOVE, mesh::ELEMENT_ADD, mesh::ELEMENT_REMOVE];
/// A required label parameter (1..=u32::MAX; the model checks its own range).
pub(crate) fn label(p: &Value, key: &str) -> Result<u32> {
p.get(key)
.and_then(Value::as_u64)
.and_then(|v| u32::try_from(v).ok())
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a positive whole number")))
}
/// A required `[x, y, z]` parameter.
pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> {
let bad = || EngineError::Params(format!("`{key}` must be [x, y, z]"));
let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 3).ok_or_else(bad)?;
let mut out = [0.0; 3];
for (o, v) in out.iter_mut().zip(a) {
*o = v.as_f64().ok_or_else(bad)?;
}
Ok(out)
}
+10
View File
@@ -0,0 +1,10 @@
use serde_json::{Value, json};
use crate::{CommandSpec, Result, Session};
pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", run: info };
fn info(s: &mut Session, _: &Value) -> Result<Value> {
let counts: serde_json::Map<String, Value> = s.model.element_counts().into_iter().map(|(k, n)| (k.name().to_string(), json!(n))).collect();
Ok(json!({"nodes": s.model.nodes().len(), "elements": counts}))
}
+155
View File
@@ -0,0 +1,155 @@
//! The command engine.
//!
//! Everything a user can do is a command ([`CommandSpec`]) with a JSON form. The desktop UI,
//! scripts, the CLI and the control channel all run the same commands through
//! [`Session::execute`], which never lets a failure or a panic damage the model: the model is
//! restored and an [`EngineError`] comes back.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod cmd;
use modelcraft_model::Model;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("{0}")]
Params(String),
#[error(transparent)]
Model(#[from] modelcraft_model::Error),
/// A command panicked; the model is unchanged.
#[error("internal error in {command}: {message}")]
Internal { command: String, message: String },
}
pub type Result<T> = std::result::Result<T, EngineError>;
/// A command: one user-visible action.
pub struct CommandSpec {
/// Our own dotted, lower-case id: `<area>.<thing>[.<variant>]`, such as `mesh.node.add`.
pub id: &'static str,
pub label: &'static str,
/// The JSON parameters, documented for scripts, `modelcraft-cli commands` and agents.
pub params: &'static str,
pub run: fn(&mut Session, &Value) -> Result<Value>,
}
/// All commands.
pub fn commands() -> &'static [CommandSpec] {
cmd::ALL
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
commands().iter().find(|c| c.id == id)
}
/// The model being edited and the commands that built it.
#[derive(Debug, Clone, Default)]
pub struct Session {
pub model: Model,
/// Commands that changed the model, in order (a script that rebuilds it).
pub journal: Vec<(String, Value)>,
}
impl Session {
/// Run a command. On an error or a panic the model is left as it was.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
let before = self.model.clone();
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let result = match outcome {
Ok(r) => r,
Err(p) => {
let message = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
log::error!("command {id} panicked: {message}");
Err(EngineError::Internal { command: id.to_string(), message })
}
};
match &result {
Ok(_) if self.model != before => self.journal.push((id.to_string(), params.clone())),
Ok(_) => {}
Err(_) => self.model = before,
}
result
}
/// Run a script: a JSON array of `{"command": id, "params": {...}}`.
pub fn run_script(&mut self, script: &Value) -> Result<()> {
let steps = script.as_array().ok_or_else(|| EngineError::Params("a script is a JSON array of steps".into()))?;
for (i, step) in steps.iter().enumerate() {
let id = step.get("command").and_then(Value::as_str).ok_or_else(|| EngineError::Params(format!("step {}: no `command`", i + 1)))?;
let params = step.get("params").cloned().unwrap_or(Value::Object(Default::default()));
self.execute(id, &params).map_err(|e| EngineError::Params(format!("step {} ({id}): {e}", i + 1)))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn command_ids_are_unique_and_well_formed() {
let mut ids: Vec<&str> = commands().iter().map(|c| c.id).collect();
for id in &ids {
assert!(id.split('.').count() >= 2 && id.chars().all(|c| c.is_ascii_lowercase() || c == '.' || c == '_'), "{id}");
}
ids.sort_unstable();
let n = ids.len();
ids.dedup();
assert_eq!(ids.len(), n);
}
#[test]
fn script_builds_a_hex_and_failures_leave_the_model_alone() {
let mut s = Session::default();
let mut script = Vec::new();
for (i, c) in [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]].iter().enumerate() {
script.push(json!({"command": "mesh.node.add", "params": {"label": i + 1, "xyz": c}}));
}
script.push(json!({"command": "mesh.element.add", "params": {"label": 1, "kind": "hex8", "nodes": [1,2,3,4,5,6,7,8]}}));
s.run_script(&Value::Array(script)).unwrap();
assert_eq!(s.journal.len(), 9);
let info = s.execute("model.info", &json!({})).unwrap();
assert_eq!(info["nodes"], 8);
assert_eq!(info["elements"]["hex8"], 1);
let model = s.model.clone();
assert!(s.execute("mesh.element.add", &json!({"label": 2, "kind": "tet4", "nodes": [1, 2, 3, 99]})).is_err());
assert!(s.execute("mesh.node.remove", &json!({"label": 1})).is_err());
assert_eq!(s.model, model);
assert_eq!(s.journal.len(), 9);
}
#[test]
fn hostile_params_never_panic() {
let hostile = [
json!(null),
json!([]),
json!("x"),
json!({}),
json!({"label": -1}),
json!({"label": 1e300}),
json!({"label": u64::MAX}),
json!({"label": 1, "xyz": [1, 2]}),
json!({"label": 1, "xyz": ["a", null, {}]}),
json!({"label": 1, "xyz": [1e308, -1e308, 0]}),
json!({"label": 1, "kind": "hex8", "nodes": null}),
json!({"label": 1, "kind": "nope", "nodes": [1]}),
json!({"label": 1, "kind": "tet4", "nodes": [1, 1, 1, 1]}),
json!({"label": 1, "kind": "hex20", "nodes": vec![u32::MAX; 20]}),
];
for spec in commands() {
for p in &hostile {
let mut s = Session::default();
let _ = s.execute(spec.id, &p.clone());
}
}
assert!(Session::default().execute("no.such", &json!({})).is_err());
}
}