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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "modelcraft-geometry"
|
||||
description = "ModelCraft's geometry boundary: CAD import, measures and tessellation, implemented with SolidCraft's B-rep kernel"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,141 @@
|
||||
//! The geometry boundary.
|
||||
//!
|
||||
//! Meshing in ModelCraft starts from a triangulated boundary surface. This crate produces it:
|
||||
//! B-rep bodies come from SolidCraft's kernel (`solidcraft-kernel`, a pinned git dependency;
|
||||
//! plan/adr/0001-libraries.md) and leave it as a [`SurfaceMesh`] from SolidCraft's tessellator,
|
||||
//! with each triangle tagged by the B-rep face it lies on (for feature edges and for loads and
|
||||
//! boundary conditions on faces). No SolidCraft type appears in the public API, so the kernel can
|
||||
//! be replaced without touching the rest of the workspace. Only this crate may depend on
|
||||
//! `solidcraft-*` (`cargo xtask layers` enforces it).
|
||||
//!
|
||||
//! Lengths are millimetres.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use solidcraft_kernel::{Body, KernelError, guard};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
#[error("the geometry operation failed: {0}")]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
impl From<KernelError> for Error {
|
||||
fn from(e: KernelError) -> Self {
|
||||
match e {
|
||||
KernelError::Invalid(m) => Error::Invalid(m),
|
||||
other => Error::Failed(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A solid body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Shape {
|
||||
pub name: String,
|
||||
body: Body,
|
||||
}
|
||||
|
||||
/// Mass properties and extent of a [`Shape`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Measure {
|
||||
pub volume: f64,
|
||||
pub area: f64,
|
||||
pub centroid: [f64; 3],
|
||||
pub bbox_min: [f64; 3],
|
||||
pub bbox_max: [f64; 3],
|
||||
pub faces: usize,
|
||||
}
|
||||
|
||||
/// A triangulated boundary surface: the input to volume meshing.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SurfaceMesh {
|
||||
pub positions: Vec<[f64; 3]>,
|
||||
/// Counter-clockwise seen from outside.
|
||||
pub triangles: Vec<[u32; 3]>,
|
||||
/// The B-rep face each triangle lies on.
|
||||
pub tri_face: Vec<u32>,
|
||||
}
|
||||
|
||||
fn xyz(v: solidcraft_geom::Vec3) -> [f64; 3] {
|
||||
[v.x, v.y, v.z]
|
||||
}
|
||||
|
||||
/// A box between two corners.
|
||||
pub fn block(a: [f64; 3], b: [f64; 3]) -> Result<Shape> {
|
||||
let v = |p: [f64; 3]| solidcraft_geom::Vec3 { x: p[0], y: p[1], z: p[2] };
|
||||
let body = guard("block", || solidcraft_kernel::box_solid(v(a), v(b)))?;
|
||||
Ok(Shape { name: "Block".into(), body })
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
pub fn measure(&self) -> Result<Measure> {
|
||||
let m = guard("measure", || solidcraft_kernel::measure(&self.body))?;
|
||||
Ok(Measure {
|
||||
volume: m.volume,
|
||||
area: m.area,
|
||||
centroid: xyz(m.centroid),
|
||||
bbox_min: xyz(m.bbox.min),
|
||||
bbox_max: xyz(m.bbox.max),
|
||||
faces: m.faces,
|
||||
})
|
||||
}
|
||||
|
||||
/// Triangulate the boundary within `tolerance` (chordal deviation, mm).
|
||||
pub fn tessellate(&self, tolerance: f64) -> Result<SurfaceMesh> {
|
||||
if !(tolerance > 0.0 && tolerance.is_finite()) {
|
||||
return Err(Error::Invalid(format!("tessellation tolerance must be a positive number, got {tolerance}")));
|
||||
}
|
||||
let m = guard("tessellate", || self.body.tessellate(tolerance))?;
|
||||
Ok(SurfaceMesh { positions: m.positions.into_iter().map(xyz).collect(), triangles: m.triangles, tri_face: m.tri_face })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn block_measures() {
|
||||
let m = block([0., 0., 0.], [10., 20., 30.]).unwrap().measure().unwrap();
|
||||
assert!((m.volume - 6000.0).abs() < 1e-6, "{}", m.volume);
|
||||
assert!((m.area - 2200.0).abs() < 1e-6, "{}", m.area);
|
||||
assert_eq!(m.faces, 6);
|
||||
assert_eq!(m.bbox_max, [10., 20., 30.]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tessellation_covers_every_face_and_encloses_the_volume() {
|
||||
let mesh = block([0., 0., 0.], [2., 3., 4.]).unwrap().tessellate(0.01).unwrap();
|
||||
assert_eq!(mesh.triangles.len(), mesh.tri_face.len());
|
||||
let mut faces: Vec<u32> = mesh.tri_face.clone();
|
||||
faces.sort_unstable();
|
||||
faces.dedup();
|
||||
assert_eq!(faces.len(), 6);
|
||||
// Divergence theorem: the outward-oriented triangles enclose the block's volume.
|
||||
let p = |i: u32| mesh.positions[i as usize];
|
||||
let six_v: f64 = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let (a, b, c) = (p(t[0]), p(t[1]), p(t[2]));
|
||||
a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])
|
||||
})
|
||||
.sum();
|
||||
assert!((six_v / 6.0 - 24.0).abs() < 1e-9, "{}", six_v / 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_an_error() {
|
||||
assert!(block([0.; 3], [0.; 3]).is_err());
|
||||
assert!(block([0.; 3], [f64::NAN, 1., 1.]).is_err());
|
||||
let b = block([0.; 3], [1.; 3]).unwrap();
|
||||
for t in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(b.tessellate(t).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "modelcraft-model"
|
||||
description = "ModelCraft's solver-neutral finite element model: nodes, elements and their validation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The solver-neutral finite element model.
|
||||
//!
|
||||
//! A [`Model`] holds what every solver deck describes: nodes and elements addressed by the
|
||||
//! positive integer labels the decks use. Materials, sections, sets, boundary conditions, loads
|
||||
//! and steps join it as their tasks land (plan/architecture.md §4). Solver formats (Abaqus input,
|
||||
//! Nastran bulk data, LS-DYNA keyword) translate to and from this model; nothing here knows any
|
||||
//! of them.
|
||||
//!
|
||||
//! The model is unit-consistent: it stores numbers in whatever consistent system the user chose
|
||||
//! (for example mm, tonne, s, N, MPa) and never converts them.
|
||||
//!
|
||||
//! Every mutation validates its input, because labels and coordinates come from files, scripts
|
||||
//! and the control channel: a bad value is an [`Error`], never a panic.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The largest node or element label. Nastran's GRID and element ids stop at 99 999 999 (eight
|
||||
/// digits in a small field), the tightest of the three formats.
|
||||
pub const MAX_LABEL: u32 = 99_999_999;
|
||||
|
||||
/// Caps on model size, so hostile input cannot exhaust memory.
|
||||
pub const MAX_NODES: usize = 50_000_000;
|
||||
pub const MAX_ELEMENTS: usize = 50_000_000;
|
||||
|
||||
/// Coordinates beyond this magnitude are rejected (1e9 model units).
|
||||
pub const MAX_COORD: f64 = 1e9;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("limit reached: {0}")]
|
||||
Limit(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// A node: a point in model space.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Node {
|
||||
pub xyz: [f64; 3],
|
||||
}
|
||||
|
||||
/// Element topology. Node order follows each family's usual corner-first convention (corners,
|
||||
/// then mid-side nodes), which all three target formats share for these shapes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ElementKind {
|
||||
/// Two-node axial member.
|
||||
Rod2,
|
||||
/// Two-node beam.
|
||||
Beam2,
|
||||
/// Three-node triangle (shell or membrane).
|
||||
Tri3,
|
||||
/// Six-node triangle.
|
||||
Tri6,
|
||||
/// Four-node quadrilateral.
|
||||
Quad4,
|
||||
/// Eight-node quadrilateral.
|
||||
Quad8,
|
||||
/// Four-node tetrahedron.
|
||||
Tet4,
|
||||
/// Ten-node tetrahedron.
|
||||
Tet10,
|
||||
/// Six-node wedge (pentahedron).
|
||||
Wedge6,
|
||||
/// Eight-node hexahedron.
|
||||
Hex8,
|
||||
/// Twenty-node hexahedron.
|
||||
Hex20,
|
||||
}
|
||||
|
||||
impl ElementKind {
|
||||
pub const ALL: [ElementKind; 11] = [
|
||||
ElementKind::Rod2,
|
||||
ElementKind::Beam2,
|
||||
ElementKind::Tri3,
|
||||
ElementKind::Tri6,
|
||||
ElementKind::Quad4,
|
||||
ElementKind::Quad8,
|
||||
ElementKind::Tet4,
|
||||
ElementKind::Tet10,
|
||||
ElementKind::Wedge6,
|
||||
ElementKind::Hex8,
|
||||
ElementKind::Hex20,
|
||||
];
|
||||
|
||||
pub fn node_count(self) -> usize {
|
||||
match self {
|
||||
ElementKind::Rod2 | ElementKind::Beam2 => 2,
|
||||
ElementKind::Tri3 => 3,
|
||||
ElementKind::Quad4 | ElementKind::Tet4 => 4,
|
||||
ElementKind::Tri6 | ElementKind::Wedge6 => 6,
|
||||
ElementKind::Quad8 | ElementKind::Hex8 => 8,
|
||||
ElementKind::Tet10 => 10,
|
||||
ElementKind::Hex20 => 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Topological dimension: 1 for line elements, 2 for shells, 3 for solids.
|
||||
pub fn dimension(self) -> u8 {
|
||||
match self {
|
||||
ElementKind::Rod2 | ElementKind::Beam2 => 1,
|
||||
ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => 2,
|
||||
ElementKind::Tet4 | ElementKind::Tet10 | ElementKind::Wedge6 | ElementKind::Hex8 | ElementKind::Hex20 => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// The name used in commands and `.modelcraft` files (`hex8`, `tet10`, …).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ElementKind::Rod2 => "rod2",
|
||||
ElementKind::Beam2 => "beam2",
|
||||
ElementKind::Tri3 => "tri3",
|
||||
ElementKind::Tri6 => "tri6",
|
||||
ElementKind::Quad4 => "quad4",
|
||||
ElementKind::Quad8 => "quad8",
|
||||
ElementKind::Tet4 => "tet4",
|
||||
ElementKind::Tet10 => "tet10",
|
||||
ElementKind::Wedge6 => "wedge6",
|
||||
ElementKind::Hex8 => "hex8",
|
||||
ElementKind::Hex20 => "hex20",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<ElementKind> {
|
||||
ElementKind::ALL.into_iter().find(|k| k.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// An element: a topology and its node labels.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Element {
|
||||
pub kind: ElementKind,
|
||||
pub nodes: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Nodes and elements by label.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Model {
|
||||
nodes: BTreeMap<u32, Node>,
|
||||
elements: BTreeMap<u32, Element>,
|
||||
}
|
||||
|
||||
fn check_label(what: &str, label: u32) -> Result<()> {
|
||||
if label == 0 || label > MAX_LABEL {
|
||||
return Err(Error::Invalid(format!("{what} label {label} is outside 1..={MAX_LABEL}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn nodes(&self) -> &BTreeMap<u32, Node> {
|
||||
&self.nodes
|
||||
}
|
||||
|
||||
pub fn elements(&self) -> &BTreeMap<u32, Element> {
|
||||
&self.elements
|
||||
}
|
||||
|
||||
pub fn node(&self, label: u32) -> Option<&Node> {
|
||||
self.nodes.get(&label)
|
||||
}
|
||||
|
||||
pub fn element(&self, label: u32) -> Option<&Element> {
|
||||
self.elements.get(&label)
|
||||
}
|
||||
|
||||
/// Add a node, or move an existing one (decks redefine nodes the same way).
|
||||
pub fn set_node(&mut self, label: u32, xyz: [f64; 3]) -> Result<()> {
|
||||
check_label("node", label)?;
|
||||
if let Some(c) = xyz.iter().find(|c| !(c.abs() <= MAX_COORD)) {
|
||||
return Err(Error::Invalid(format!("node {label}: coordinate {c} is not a finite number within ±{MAX_COORD}")));
|
||||
}
|
||||
if !self.nodes.contains_key(&label) && self.nodes.len() >= MAX_NODES {
|
||||
return Err(Error::Limit(format!("a model holds at most {MAX_NODES} nodes")));
|
||||
}
|
||||
self.nodes.insert(label, Node { xyz });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an element, or replace an existing one. Its nodes must exist and be distinct.
|
||||
pub fn set_element(&mut self, label: u32, kind: ElementKind, nodes: Vec<u32>) -> Result<()> {
|
||||
check_label("element", label)?;
|
||||
if nodes.len() != kind.node_count() {
|
||||
return Err(Error::Invalid(format!("element {label}: {} needs {} nodes, got {}", kind.name(), kind.node_count(), nodes.len())));
|
||||
}
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
if !self.nodes.contains_key(n) {
|
||||
return Err(Error::NotFound(format!("element {label}: node {n}")));
|
||||
}
|
||||
if nodes.get(..i).is_some_and(|before| before.contains(n)) {
|
||||
return Err(Error::Invalid(format!("element {label}: node {n} appears twice")));
|
||||
}
|
||||
}
|
||||
if !self.elements.contains_key(&label) && self.elements.len() >= MAX_ELEMENTS {
|
||||
return Err(Error::Limit(format!("a model holds at most {MAX_ELEMENTS} elements")));
|
||||
}
|
||||
self.elements.insert(label, Element { kind, nodes });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a node that no element uses.
|
||||
pub fn remove_node(&mut self, label: u32) -> Result<()> {
|
||||
if let Some((e, _)) = self.elements.iter().find(|(_, e)| e.nodes.contains(&label)) {
|
||||
return Err(Error::Invalid(format!("node {label} is used by element {e}")));
|
||||
}
|
||||
self.nodes.remove(&label).map(|_| ()).ok_or_else(|| Error::NotFound(format!("node {label}")))
|
||||
}
|
||||
|
||||
pub fn remove_element(&mut self, label: u32) -> Result<()> {
|
||||
self.elements.remove(&label).map(|_| ()).ok_or_else(|| Error::NotFound(format!("element {label}")))
|
||||
}
|
||||
|
||||
/// Element counts by kind, in [`ElementKind::ALL`] order, kinds with none left out.
|
||||
pub fn element_counts(&self) -> Vec<(ElementKind, usize)> {
|
||||
ElementKind::ALL.into_iter().map(|k| (k, self.elements.values().filter(|e| e.kind == k).count())).filter(|(_, n)| *n > 0).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cube() -> Model {
|
||||
let mut m = Model::default();
|
||||
let corners = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]];
|
||||
for (i, c) in corners.into_iter().enumerate() {
|
||||
m.set_node(i as u32 + 1, c).unwrap();
|
||||
}
|
||||
m.set_element(1, ElementKind::Hex8, (1..=8).collect()).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_a_hex() {
|
||||
let m = cube();
|
||||
assert_eq!(m.nodes().len(), 8);
|
||||
assert_eq!(m.element_counts(), vec![(ElementKind::Hex8, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_labels_and_coordinates() {
|
||||
let mut m = Model::default();
|
||||
assert!(m.set_node(0, [0.; 3]).is_err());
|
||||
assert!(m.set_node(MAX_LABEL + 1, [0.; 3]).is_err());
|
||||
assert!(m.set_node(1, [f64::NAN, 0., 0.]).is_err());
|
||||
assert!(m.set_node(1, [f64::INFINITY, 0., 0.]).is_err());
|
||||
assert!(m.set_node(1, [2e9, 0., 0.]).is_err());
|
||||
assert!(m.nodes().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_connectivity() {
|
||||
let mut m = cube();
|
||||
assert!(matches!(m.set_element(2, ElementKind::Tet4, vec![1, 2, 3]), Err(Error::Invalid(_))));
|
||||
assert!(matches!(m.set_element(2, ElementKind::Tet4, vec![1, 2, 3, 99]), Err(Error::NotFound(_))));
|
||||
assert!(matches!(m.set_element(2, ElementKind::Tet4, vec![1, 2, 3, 3]), Err(Error::Invalid(_))));
|
||||
assert_eq!(m.elements().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn used_nodes_cannot_be_removed() {
|
||||
let mut m = cube();
|
||||
assert!(m.remove_node(1).is_err());
|
||||
m.remove_element(1).unwrap();
|
||||
m.remove_node(1).unwrap();
|
||||
assert!(m.remove_node(1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_names_round_trip() {
|
||||
for k in ElementKind::ALL {
|
||||
assert_eq!(ElementKind::from_name(k.name()), Some(k));
|
||||
assert_eq!(serde_json::to_value(k).unwrap(), serde_json::json!(k.name()));
|
||||
}
|
||||
assert_eq!(ElementKind::from_name("C3D8"), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user