forked from 1b-agent/fieldcraft-upstream
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:
commit
b64ec3f698
38 files changed
+3931
No files matched your search
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "modelcraft-engine"
|
||||
description = "ModelCraft's command engine: every user-visible action as a command with a JSON form"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
modelcraft-model = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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, ¶ms).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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user