Model definition: sets by geometry name, materials, sections, couplings, BCs, loads, steps, check (#16)

* model: split nodes and elements into a mesh module (no behaviour change)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* model: sets by geometry name, materials and units, sections, couplings, BCs, loads, steps and the check

Sets bind to persistent geometry names through the mesh's geometry tags
(element, element face/edge and node classification) and resolve against
the current mesh, so they survive a remesh. Steps list their active loads
and boundary conditions explicitly. Model::check names every missing or
bad item; Model::resolve gives the writers' view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: model definition commands (sets, materials, sections, RPs, couplings, BCs, loads, steps, tags, check)

Each definition has create/edit/delete with its model JSON form as
parameters; model.step.activate and model.activity drive the explicit step
table; mesh.tag records where the mesh lies on the geometry; model.check
and model.set.resolve report against the current mesh. Hostile params run
against an empty and a fully defined session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: model definition in architecture and roadmap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* model, engine: review cleanups

Validated deserialization (a file replays through the checked methods),
interned geometry tags by index, set resolution with tag bitsets and sorted
vectors, one delete policy (a load or BC listed by a step is in use),
shared helpers (context, remove_existing, step_mut, opt/req/name/listed),
dead code removed, Resolved without a per-element section map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* model: mesh-independent force distribution, tractions, stable reference point labels

Force and moment on a set default to a total, split by consistent nodal
weights over the set's faces (or equally over a node set); per_node is an
explicit opt-in. Traction is force per area. resolve() expands them to
nodal values for every writer and the solver. Reference points keep a
stored node label taken from the top of the label range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 16:19:54 -04:00
1 parent 6c36f5b2ad
commit 57b4b2f4d2
23 files changed
+4296 -268

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
//! Materials: linear elasticity and mass density.
use serde::{Deserialize, Serialize};
use crate::{Error, Model, Result, context, insert_new, not_in_use, positive, remove_existing, replace_existing};
/// Names of the engineering constants' shear moduli, in order.
pub(crate) const SHEAR_MODULI: [&str; 3] = ["G12", "G13", "G23"];
/// Linear elastic behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Elastic {
/// Young's modulus and Poisson's ratio.
Isotropic { young: f64, poisson: f64 },
/// Orthotropic, by engineering constants in the material axes 1, 2, 3 (the global X, Y, Z
/// until material orientations land): Young's moduli `[E1, E2, E3]`, Poisson's ratios
/// `[ν12, ν13, ν23]` (ν_ij is the contraction in j under stress in i) and shear moduli
/// `[G12, G13, G23]`.
EngineeringConstants { young: [f64; 3], poisson: [f64; 3], shear: [f64; 3] },
}
impl Elastic {
/// Check the constants describe a stable material (positive-definite stiffness).
pub fn validate(&self) -> Result<()> {
match *self {
Elastic::Isotropic { young, poisson } => {
positive("Young's modulus", young)?;
if !(poisson > -1.0 && poisson < 0.5) {
return Err(Error::Invalid(format!("Poisson's ratio must be above -1 and below 0.5, got {poisson}")));
}
}
Elastic::EngineeringConstants { young, poisson, shear } => {
for (i, e) in young.iter().enumerate() {
positive(&format!("E{}", i + 1), *e)?;
}
for (what, g) in SHEAR_MODULI.iter().zip(shear) {
positive(what, g)?;
}
let [e1, e2, e3] = young;
let [n12, n13, n23] = poisson;
if poisson.iter().any(|n| !n.is_finite()) {
return Err(Error::Invalid("Poisson's ratios must be finite numbers".into()));
}
// Reciprocal ratios ν_ji = ν_ij E_j / E_i; stability needs |ν_ij| < sqrt(E_i/E_j)
// and a positive determinant of the compliance's normal block.
let (n21, n31, n32) = (n12 * e2 / e1, n13 * e3 / e1, n23 * e3 / e2);
for (name, nij, ei, ej) in [("ν12", n12, e1, e2), ("ν13", n13, e1, e3), ("ν23", n23, e2, e3)] {
if !(nij.abs() < (ei / ej).sqrt()) {
return Err(Error::Invalid(format!("{name} = {nij} is too large for the moduli (must be below {})", (ei / ej).sqrt())));
}
}
let det = 1.0 - n12 * n21 - n23 * n32 - n31 * n13 - 2.0 * n21 * n32 * n13;
if !(det > 0.0) {
return Err(Error::Invalid("the Poisson's ratios make the material unstable (stiffness not positive definite)".into()));
}
}
}
Ok(())
}
}
/// A material.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Material {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub elastic: Option<Elastic>,
/// Mass per volume, needed by frequency steps and gravity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub density: Option<f64>,
}
impl Material {
pub fn validate(&self) -> Result<()> {
if self.elastic.is_none() && self.density.is_none() {
return Err(Error::Invalid("a material needs elastic constants or a density".into()));
}
if let Some(e) = &self.elastic {
e.validate()?;
}
if let Some(d) = self.density {
positive("density", d)?;
}
Ok(())
}
}
impl Model {
pub fn materials(&self) -> &std::collections::BTreeMap<String, Material> {
&self.materials
}
pub fn material(&self, name: &str) -> Option<&Material> {
self.materials.get(name)
}
pub fn add_material(&mut self, name: &str, material: Material) -> Result<()> {
material.validate().map_err(|e| context("material", name, e))?;
insert_new(&mut self.materials, "material", name, material)
}
pub fn replace_material(&mut self, name: &str, material: Material) -> Result<()> {
material.validate().map_err(|e| context("material", name, e))?;
replace_existing(&mut self.materials, "material", name, material)
}
pub fn remove_material(&mut self, name: &str) -> Result<()> {
let users = self.sections.iter().filter(|(_, s)| s.material == name).map(|(n, _)| format!("section `{n}`")).collect();
not_in_use("material", name, users)?;
remove_existing(&mut self.materials, "material", name).map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn iso(young: f64, poisson: f64) -> Material {
Material { elastic: Some(Elastic::Isotropic { young, poisson }), density: None }
}
#[test]
fn materials_validate_and_name_the_bad_constant() {
let mut m = Model::default();
m.add_material("Steel", Material { density: Some(7.85e-9), ..iso(2.1e5, 0.3) }).unwrap();
assert!(m.add_material("Steel", iso(1.0, 0.3)).is_err(), "duplicate");
for bad in [iso(0.0, 0.3), iso(-1.0, 0.3), iso(f64::NAN, 0.3), iso(1.0, 0.5), iso(1.0, -1.0), iso(1.0, f64::INFINITY), Material::default()] {
let e = m.add_material("Bad", bad).unwrap_err();
assert!(e.to_string().contains("material `Bad`"), "{e}");
}
assert!(m.add_material("Light", Material { elastic: None, density: Some(-1.0) }).is_err());
assert!(m.add_material("1st", iso(1.0, 0.3)).is_err(), "names start with a letter");
assert!(m.add_material("has space", iso(1.0, 0.3)).is_err());
m.replace_material("Steel", iso(2.0e5, 0.29)).unwrap();
assert!(m.replace_material("Nope", iso(2.0e5, 0.29)).is_err());
assert_eq!(m.materials().len(), 1);
}
#[test]
fn orthotropic_stability() {
let ortho = |poisson| Elastic::EngineeringConstants { young: [140e3, 10e3, 10e3], poisson, shear: [5e3, 5e3, 3.8e3] };
ortho([0.3, 0.3, 0.4]).validate().unwrap();
// ν21 = ν12 E2/E1 is small; ν12 itself may exceed 0.5 in a stiff direction.
ortho([0.9, 0.3, 0.4]).validate().unwrap();
assert!(ortho([4.0, 0.3, 0.4]).validate().is_err(), "|ν12| < sqrt(E1/E2) = 3.74");
assert!(ortho([0.3, 0.3, 1.0]).validate().is_err());
assert!(ortho([3.5, 3.5, 0.9]).validate().is_err(), "determinant");
assert!(ortho([0.3, f64::NAN, 0.4]).validate().is_err());
let zero_shear = Elastic::EngineeringConstants { young: [1.0; 3], poisson: [0.0; 3], shear: [1.0, 0.0, 1.0] };
assert!(zero_shear.validate().unwrap_err().to_string().contains("G13"));
}
}