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

+16
View File
@@ -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 }
+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());
}
}
+14
View File
@@ -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 }
+141
View File
@@ -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());
}
}
}
+16
View File
@@ -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 }
+285
View File
@@ -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);
}
}