Abaqus input writer and deck.write (M3) (#22)

* decks: Abaqus input writer with golden decks, read-back, Jacobian and unsupported tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: deck.write command (format dispatch, atomic file write), docs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* decks: format table, one resolve per write, direct buffer writes; engine: shared path parameter

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 17:27:44 -04:00
1 parent 2d99a8571c
commit a71f85eb58
15 files changed
+1922 -3

No files matched your search

Generated
+2
View File
@@ -1846,6 +1846,7 @@ name = "modelcraft-decks"
version = "0.1.0"
dependencies = [
"modelcraft-model",
"serde_json",
"thiserror 2.0.21",
]
@@ -1854,6 +1855,7 @@ name = "modelcraft-engine"
version = "0.1.0"
dependencies = [
"log",
"modelcraft-decks",
"modelcraft-geometry",
"modelcraft-io",
"modelcraft-mesh",
+1 -1
View File
@@ -21,7 +21,7 @@ hex meshes today and it gives the best elements where stresses are read.
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`modelcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `modelcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `modelcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023); Delaunay refinement, ODT smoothing, sliver removal; tet4 and tet10 with mid-side nodes on the B-rep; mesh verify per set | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input, Nastran SOL 101/103 and LS-DYNA implicit; the `.modelcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 and LS-DYNA implicit; the `.modelcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Linear static and modal solver (tet4/10, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions; deformed shape, contours, mode shapes, probes, reaction sums | planned |
| M5 Structured hex | 2D quad meshing of faces (`modelcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions; sweep meshing; conforming interfaces and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M6 Automatic hex | Octree/grid-dual all-hex for any part; hex-dominant by tet recombination; polycube for blocky parts; smoothing, untangling, boundary padding, layers through thin walls; mesh editing and orphan-mesh import | planned |
+3
View File
@@ -11,3 +11,6 @@ workspace = true
[dependencies]
modelcraft-model = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+796
View File
@@ -0,0 +1,796 @@
//! Abaqus input (`.inp`) writer.
//!
//! The deck is written from the public Abaqus keyword and element references, in our own words;
//! nothing here comes from a deck produced by the product.
//!
//! # Layout
//!
//! The deck is *flat*: nodes, elements, sets, materials, sections and couplings sit at model level,
//! with no parts, assembly or instances. ModelCraft has one mesh with model-wide labels and no
//! instance transforms, so a part/assembly wrapper would only prefix every set name with an
//! instance name (`Part-1-1.root`) and bring the 80-character limit on the joined name closer. When
//! the model gains several parts or instances the writer switches to the part/assembly form.
//!
//! In order: heading, nodes (mesh nodes, then reference points), elements grouped by element type,
//! sets (a node set, an element set and an element-face surface per model set, each written when
//! the set resolves to some), materials, sections, couplings, the initial state's boundary
//! conditions, then the steps.
//!
//! # Steps and the activity table
//!
//! The model lists, for every step, exactly the boundary conditions and loads active in it; Abaqus
//! carries them from step to step. The writer bridges the two:
//! - a boundary condition active in every step that holds its degrees of freedom at zero is written
//! once, in the initial state (model data);
//! - a general (static) step whose active boundary conditions equal the previous general step's
//! writes none; one that only adds writes the additions (`OP=MOD`); otherwise it writes
//! `*BOUNDARY, OP=NEW` and restates every active one;
//! - loads in a general step are restated, keyword by keyword, with `OP=NEW` whenever the active set
//! changes, so a load not listed is removed;
//! - a frequency step is a perturbation of the last general step: its boundary conditions are
//! written against that base state and do not carry on to later steps; loads have no effect on
//! natural frequencies and are not written in it;
//! - every step restates its output request (`*OUTPUT, FIELD, OP=NEW`).
//!
//! # Loads
//!
//! - Forces, moments and tractions are written as concentrated loads from the model's resolved
//! nodal values (the same distribution every writer and the solver use), summed per node and
//! degree of freedom so loads sharing a node add up.
//! - Pressure is a surface load on the set's element-face surface, summed per set.
//! - Gravity and body forces are element loads on element sets (the section sets when gravity acts
//! on the whole model); when two active loads of one kind share elements they are summed and
//! written per element.
//!
//! # Numbers and labels
//!
//! Real numbers are written in the shortest form that reads back to the same `f64` (exponent marker
//! `E`). Lines stay within 256 characters: element connectivity puts at most 15 nodes after the
//! label on the first line and 16 per continuation line; sets list 16 labels per line. Labels are
//! the model's names (a letter, then letters, digits, `_` or `-`, at most 64 characters), which
//! Abaqus reads unquoted. Abaqus ignores case in names, so two names that differ only in case are
//! reported as [`Error::Unsupported`], as is everything else the format cannot express.
//!
//! # Element types
//!
//! | ModelCraft | Solid section | Shell section | Beam | Rod |
//! |---|---|---|---|---|
//! | tet4 | C3D4 (H) | | | |
//! | tet10 | C3D10 (H) | | | |
//! | wedge6 | C3D6 (H) | | | |
//! | hex8 | C3D8, C3D8R, C3D8I (H) | | | |
//! | hex20 | C3D20, C3D20R (H) | | | |
//! | tri3, quad4 | | S3 / S3R, S4 / S4R | | |
//! | quad8 | | S8R (reduced only) | | |
//! | beam2, rod2 | | | B31 | T3D2 |
//!
//! `H` marks the hybrid variant. A four-node tetrahedron integrates at one point either way, so
//! reduced integration writes C3D4. Not expressible: reduced integration of ten-node tetrahedra and
//! six-node wedges, full integration of eight-node shells, six-node triangular shells, orthotropic
//! materials outside solid sections (shell and beam material axes need orientations), and
//! distributing couplings that leave a translation free.
//!
//! Node order: the model stores elements in Abaqus order, so connectivity passes through; the
//! model's face numbers are Abaqus's S1–S6 for solids, and SPOS/SNEG for shell faces 1/2.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use modelcraft_model::{
BeamProfile, BoundaryCondition, CouplingKind, Elastic, Element, ElementKind, Integration, Load, Model, OutputVariable, Procedure, Resolved,
Section, SectionKind, Step, Symmetry, Target,
};
use crate::{Error, Result, unsupported};
const FORMAT: &str = "Abaqus";
/// Longest line Abaqus reads.
pub const MAX_LINE: usize = 256;
/// Labels per set data line.
const SET_LABELS_PER_LINE: usize = 16;
/// Nodes on an element's first data line (after its label), and on each continuation line.
const FIRST_LINE_NODES: usize = 15;
const CONTINUATION_NODES: usize = 16;
/// Write the model as an Abaqus input deck. Fails when the model does not resolve (its check
/// finds an error) or has something the format cannot express ([`Error::Unsupported`], every such
/// item named).
pub fn write(model: &Model) -> Result<String> {
write_resolved(model, &model.resolve()?)
}
/// [`write`] for a model already resolved.
pub fn write_resolved(model: &Model, r: &Resolved) -> Result<String> {
let by_type = elements_by_type(model, r)?;
check_expressible(model)?;
let mut d = Deck::default();
d.heading(model);
d.nodes(model)?;
d.elements(&by_type);
d.sets(model, r)?;
d.materials(model)?;
d.sections(model)?;
d.couplings(model, r);
d.steps(model, r)?;
Ok(d.out)
}
/// The Abaqus element type of an element of `kind` in `section`, or why there is none.
pub fn element_type(kind: ElementKind, section: &Section) -> std::result::Result<String, String> {
use ElementKind as K;
use SectionKind as S;
let f = section.formulation;
let reduced = f.integration == Integration::Reduced;
if (f.hybrid || f.incompatible_modes) && section.kind.dimension() != 3 {
return Err(format!("hybrid or incompatible-mode {} sections", section.kind.name()));
}
let plain = match (&section.kind, kind, reduced, f.incompatible_modes) {
(S::Solid {}, K::Hex8, false, true) => "C3D8I",
(S::Solid {}, _, true, true) => return Err("incompatible modes with reduced integration".into()),
(S::Solid {}, _, _, true) => return Err(format!("incompatible modes on {} elements (hex8 only)", kind.name())),
(S::Solid {}, K::Tet4, ..) => "C3D4",
(S::Solid {}, K::Tet10 | K::Wedge6, true, _) => {
return Err(format!("reduced integration of {} elements (Abaqus has no reduced-integration variant)", kind.name()));
}
(S::Solid {}, K::Tet10, ..) => "C3D10",
(S::Solid {}, K::Wedge6, ..) => "C3D6",
(S::Solid {}, K::Hex8, r, _) => ["C3D8", "C3D8R"][usize::from(r)],
(S::Solid {}, K::Hex20, r, _) => ["C3D20", "C3D20R"][usize::from(r)],
(S::Shell { .. }, K::Tri3, r, _) => ["S3", "S3R"][usize::from(r)],
(S::Shell { .. }, K::Quad4, r, _) => ["S4", "S4R"][usize::from(r)],
(S::Shell { .. }, K::Quad8, true, _) => "S8R",
(S::Shell { .. }, K::Quad8, false, _) => {
return Err("full integration of quad8 shells (Abaqus's eight-node shell, S8R, uses reduced integration)".into());
}
(S::Shell { .. }, K::Tri6, ..) => return Err("tri6 shells (Abaqus has no general-purpose six-node triangular shell)".into()),
(S::Beam { .. }, K::Beam2, ..) => "B31",
(S::Rod { .. }, K::Rod2, ..) => "T3D2",
(s, ..) => return Err(format!("{} elements in a {} section", kind.name(), s.name())),
};
Ok(if f.hybrid { format!("{plain}H") } else { plain.to_string() })
}
/// Every element, grouped by element type (from its section) and sorted by label.
fn elements_by_type<'m>(model: &'m Model, r: &Resolved) -> Result<BTreeMap<String, Vec<(u32, &'m Element)>>> {
let mut by_type: BTreeMap<String, Vec<(u32, &Element)>> = BTreeMap::new();
let mut bad = BTreeSet::new();
let mut count = 0;
for (name, section) in model.sections() {
let mut by_kind: BTreeMap<ElementKind, Vec<(u32, &Element)>> = BTreeMap::new();
for &e in r.sets.get(&section.set).map(|s| s.elements.as_slice()).unwrap_or_default() {
if let Some(element) = model.element(e) {
by_kind.entry(element.kind).or_default().push((e, element));
}
}
for (kind, elements) in by_kind {
match element_type(kind, section) {
Ok(t) => {
count += elements.len();
by_type.entry(t).or_default().extend(elements);
}
Err(why) => {
bad.insert(format!("section `{name}`: {why}"));
}
}
}
}
unsupported(FORMAT, bad)?;
// A resolved model puts every element in exactly one section; this guards the writer.
if count != model.elements().len() {
return Err(Error::Unsupported { format: FORMAT, what: "elements without exactly one section".into() });
}
for elements in by_type.values_mut() {
elements.sort_unstable_by_key(|(e, _)| *e);
}
Ok(by_type)
}
/// Everything else the format cannot express: case-only name clashes, orthotropic materials
/// outside solids, distributing couplings with a free translation.
fn check_expressible(model: &Model) -> Result<()> {
let mut bad = BTreeSet::new();
let namespaces: [(&str, Vec<&str>); 4] = [
("sets", model.sets().keys().map(String::as_str).collect()),
("materials", model.materials().keys().map(String::as_str).collect()),
("couplings", model.couplings().keys().map(String::as_str).collect()),
("steps", model.steps().iter().map(|s| s.name.as_str()).collect()),
];
for (what, names) in namespaces {
let mut seen: BTreeMap<String, &str> = BTreeMap::new();
for n in names {
if let Some(other) = seen.insert(n.to_ascii_uppercase(), n) {
bad.insert(format!("{what} `{other}` and `{n}` differ only in case, and Abaqus ignores case in names"));
}
}
}
for (name, section) in model.sections() {
let ortho = matches!(model.material(&section.material).and_then(|m| m.elastic), Some(Elastic::EngineeringConstants { .. }));
if ortho && section.kind.dimension() != 3 {
bad.insert(format!(
"section `{name}`: orthotropic material `{}` in a {} section (its material axes need an orientation, not supported yet)",
section.material,
section.kind.name()
));
}
}
for (name, c) in model.couplings() {
if c.kind == CouplingKind::Distributing && !(1..=3).all(|d| c.dofs.contains(d)) {
bad.insert(format!("coupling `{name}`: an Abaqus distributing coupling always couples translations 1-3"));
}
}
unsupported(FORMAT, bad)
}
/// Append a real number in the shortest form that reads back to the same value: `0.1`,
/// `210000.0`, `7.85E-9`, `1.0E300`.
fn push_number(out: &mut String, v: f64) -> Result<()> {
if !v.is_finite() {
return Err(Error::Unsupported { format: FORMAT, what: format!("the value {v} (not a finite number)") });
}
// Rust's `Debug` for f64 is the shortest round-trip form, switching to an exponent for very
// large and small magnitudes; Abaqus wants the marker `E`, and a mantissa with a point reads
// the same everywhere.
let start = out.len();
let _ = write!(out, "{v:?}");
if let Some(at) = out.get(start..).and_then(|s| s.find('e')).map(|i| start + i) {
let point = out.get(start..at).is_some_and(|m| m.contains('.'));
out.replace_range(at..at + 1, if point { "E" } else { ".0E" });
}
Ok(())
}
/// [`push_number`] into a new string.
pub fn number(v: f64) -> Result<String> {
let mut s = String::new();
push_number(&mut s, v)?;
Ok(s)
}
/// The surface face identifier of face `face` (1-based) of an element of `kind`.
pub fn face_id(kind: ElementKind, face: u8) -> Option<&'static str> {
const SOLID: [&str; 6] = ["S1", "S2", "S3", "S4", "S5", "S6"];
const SHELL: [&str; 2] = ["SPOS", "SNEG"];
let i = usize::from(face).checked_sub(1)?;
if i >= kind.faces().len() {
return None;
}
match kind.dimension() {
3 => SOLID.get(i).copied(),
2 => SHELL.get(i).copied(),
_ => None,
}
}
/// The node surface each coupling ties its reference point to: a fresh name per coupling that
/// clashes with no set (sets with faces own surfaces of their name) and no other coupling surface.
fn coupling_surfaces(model: &Model, r: &Resolved) -> BTreeMap<String, String> {
let mut taken: BTreeSet<String> = r.sets.iter().filter(|(_, s)| !s.faces.is_empty()).map(|(n, _)| n.to_ascii_uppercase()).collect();
let mut out = BTreeMap::new();
for name in model.couplings().keys() {
let base = format!("{name}-nodes");
let mut candidate = base.clone();
let mut k = 2u32;
while taken.contains(&candidate.to_ascii_uppercase()) {
candidate = format!("{base}-{k}");
k = k.saturating_add(1);
}
taken.insert(candidate.to_ascii_uppercase());
out.insert(name.clone(), candidate);
}
out
}
/// Consecutive runs of degrees of freedom: `[1, 2, 3, 5]` → `[(1, 3), (5, 5)]`.
fn dof_runs(dofs: &[u8]) -> Vec<(u8, u8)> {
let mut runs: Vec<(u8, u8)> = Vec::new();
for &d in dofs {
match runs.last_mut() {
Some((_, last)) if *last + 1 == d => *last = d,
_ => runs.push((d, d)),
}
}
runs
}
/// Whether a boundary condition holds every degree of freedom it constrains at zero.
fn holds_at_zero(bc: &BoundaryCondition) -> bool {
(1..=6).all(|d| bc.value(d).is_none_or(|v| v == 0.0))
}
/// Where an element load acts: an element set, or one element.
#[derive(Clone, Copy)]
enum ElementTarget<'a> {
Set(&'a str),
Element(u32),
}
/// Gravity (an acceleration) or a body force (a force per volume).
#[derive(Clone, Copy, PartialEq)]
enum BodyKind {
Gravity,
Force,
}
/// A gravity or body force on some elements, before it is written.
struct ElementLoad<'a> {
/// The element sets that hold exactly its elements.
elsets: Vec<&'a str>,
elements: &'a [u32],
vector: [f64; 3],
}
impl<'a> ElementLoad<'a> {
fn on_set(r: &'a Resolved, set: &'a str, vector: [f64; 3]) -> ElementLoad<'a> {
ElementLoad { elsets: vec![set], elements: r.sets.get(set).map(|s| s.elements.as_slice()).unwrap_or_default(), vector }
}
}
/// Whether no element is in two of the loads.
fn disjoint(loads: &[ElementLoad]) -> bool {
if loads.len() < 2 {
return true;
}
let mut all: Vec<u32> = loads.iter().flat_map(|l| l.elements.iter().copied()).collect();
let n = all.len();
all.sort_unstable();
all.dedup();
all.len() == n
}
/// Element loads of one kind: by element set when the loads share no element, otherwise summed
/// per element. Zero vectors are left out.
fn element_loads<'a>(loads: &[ElementLoad<'a>], kind: BodyKind, out: &mut Vec<(ElementTarget<'a>, BodyKind, [f64; 3])>) {
if disjoint(loads) {
for l in loads {
out.extend(l.elsets.iter().map(|s| (ElementTarget::Set(s), kind, l.vector)));
}
} else {
let mut summed: BTreeMap<u32, [f64; 3]> = BTreeMap::new();
for l in loads {
for &e in l.elements {
for (c, add) in summed.entry(e).or_default().iter_mut().zip(l.vector) {
*c += add;
}
}
}
out.extend(summed.into_iter().map(|(e, v)| (ElementTarget::Element(e), kind, v)));
}
out.retain(|(_, _, v)| v.iter().any(|c| *c != 0.0));
}
/// The loads of one general step, as the deck writes them.
#[derive(Default)]
struct StepLoads<'a> {
/// (node, degree of freedom) → summed value.
concentrated: BTreeMap<(u32, u8), f64>,
/// Surface (set name) → summed pressure.
pressure: BTreeMap<&'a str, f64>,
element: Vec<(ElementTarget<'a>, BodyKind, [f64; 3])>,
}
/// Which load keywords a step wrote entries for, so the next step knows what to remove.
#[derive(Default, Clone, Copy)]
struct Written {
concentrated: bool,
pressure: bool,
element: bool,
}
impl<'a> StepLoads<'a> {
/// `everything` is every element and the section sets that hold them (gravity on the whole
/// model).
fn new(model: &'a Model, r: &'a Resolved, step: &'a Step, everything: &'a (Vec<&'a str>, Vec<u32>)) -> StepLoads<'a> {
let mut s = StepLoads { concentrated: crate::concentrated_loads(model, r, &step.loads), ..StepLoads::default() };
let (mut gravity, mut body) = (Vec::new(), Vec::new());
for name in &step.loads {
match model.load(name) {
Some(Load::Pressure { set, magnitude }) => *s.pressure.entry(set.as_str()).or_default() += magnitude,
Some(Load::Gravity { acceleration, set: Some(set) }) => gravity.push(ElementLoad::on_set(r, set, *acceleration)),
Some(Load::Gravity { acceleration, set: None }) => {
gravity.push(ElementLoad { elsets: everything.0.clone(), elements: &everything.1, vector: *acceleration })
}
Some(Load::BodyForce { set, vector }) => body.push(ElementLoad::on_set(r, set, *vector)),
_ => {}
}
}
s.pressure.retain(|_, p| *p != 0.0);
element_loads(&gravity, BodyKind::Gravity, &mut s.element);
element_loads(&body, BodyKind::Force, &mut s.element);
s
}
fn written(&self) -> Written {
Written { concentrated: !self.concentrated.is_empty(), pressure: !self.pressure.is_empty(), element: !self.element.is_empty() }
}
}
#[derive(Default)]
struct Deck {
out: String,
}
impl Deck {
fn line(&mut self, s: &str) {
self.out.push_str(s);
self.out.push('\n');
}
fn comment(&mut self, s: &str) {
self.out.push_str("** ");
self.line(s);
}
/// `, value` for each value, then the end of the line.
fn numbers_line(&mut self, head: &str, values: &[f64]) -> Result<()> {
self.out.push_str(head);
for (i, v) in values.iter().enumerate() {
if i > 0 || !head.is_empty() {
self.out.push_str(", ");
}
push_number(&mut self.out, *v)?;
}
self.out.push('\n');
Ok(())
}
/// Labels, [`SET_LABELS_PER_LINE`] per line.
fn labels(&mut self, labels: &[u32]) {
for chunk in labels.chunks(SET_LABELS_PER_LINE) {
for (i, l) in chunk.iter().enumerate() {
let _ = write!(self.out, "{}{l}", if i > 0 { ", " } else { "" });
}
self.out.push('\n');
}
}
fn heading(&mut self, model: &Model) {
let units = match model.units().length() {
Some(_) => format!("units {}", model.units().label()),
None => "units not specified".to_string(),
};
self.line("*HEADING");
self.line(&format!("ModelCraft model, {units}"));
self.comment("Written by ModelCraft. The numbers are in one consistent unit system and are not converted.");
}
fn nodes(&mut self, model: &Model) -> Result<()> {
self.comment("Nodes");
self.line("*NODE");
for (label, node) in model.nodes() {
self.numbers_line(&label.to_string(), &node.xyz)?;
}
if !model.reference_points().is_empty() {
self.comment("Reference points");
let mut points: Vec<_> = model.reference_points().iter().collect();
points.sort_by_key(|(_, p)| p.label);
self.line("*NODE");
for (name, p) in points {
self.comment(&format!("reference point {name}"));
self.numbers_line(&p.label.to_string(), &p.xyz)?;
}
}
Ok(())
}
fn elements(&mut self, by_type: &BTreeMap<String, Vec<(u32, &Element)>>) {
self.comment("Elements");
for (t, elements) in by_type {
self.line(&format!("*ELEMENT, TYPE={t}"));
for (e, element) in elements {
let _ = write!(self.out, "{e}");
for (i, n) in element.nodes.iter().enumerate() {
// A trailing comma continues the data line.
let continues = i >= FIRST_LINE_NODES && (i - FIRST_LINE_NODES).is_multiple_of(CONTINUATION_NODES);
let _ = write!(self.out, "{}{n}", if continues { ",\n" } else { ", " });
}
self.out.push('\n');
}
}
}
fn sets(&mut self, model: &Model, r: &Resolved) -> Result<()> {
if r.sets.is_empty() {
return Ok(());
}
self.comment("Sets");
for (name, s) in &r.sets {
if s.is_empty() {
self.comment(&format!("set {name} resolves to nothing on this mesh"));
continue;
}
if !s.nodes.is_empty() {
self.line(&format!("*NSET, NSET={name}"));
self.labels(&s.nodes);
}
if !s.elements.is_empty() {
self.line(&format!("*ELSET, ELSET={name}"));
self.labels(&s.elements);
}
if !s.faces.is_empty() {
self.line(&format!("*SURFACE, TYPE=ELEMENT, NAME={name}"));
for f in &s.faces {
let id = model.element(f.element).and_then(|e| face_id(e.kind, f.face)).ok_or_else(|| Error::Unsupported {
format: FORMAT,
what: format!("set `{name}`: element {} has no face {}", f.element, f.face),
})?;
let _ = writeln!(self.out, "{}, {id}", f.element);
}
}
}
Ok(())
}
fn materials(&mut self, model: &Model) -> Result<()> {
if model.materials().is_empty() {
return Ok(());
}
self.comment("Materials");
for (name, m) in model.materials() {
self.line(&format!("*MATERIAL, NAME={name}"));
match m.elastic {
Some(Elastic::Isotropic { young, poisson }) => {
self.line("*ELASTIC, TYPE=ISOTROPIC");
self.numbers_line("", &[young, poisson])?;
}
Some(Elastic::EngineeringConstants { young, poisson, shear }) => {
// E1, E2, E3, ν12, ν13, ν23, G12, G13 on the first line, G23 on the second.
self.line("*ELASTIC, TYPE=ENGINEERING CONSTANTS");
let [g12, g13, g23] = shear;
self.numbers_line("", &[young[0], young[1], young[2], poisson[0], poisson[1], poisson[2], g12, g13])?;
self.numbers_line("", &[g23])?;
}
None => {}
}
if let Some(rho) = m.density {
self.line("*DENSITY");
self.numbers_line("", &[rho])?;
}
}
Ok(())
}
fn sections(&mut self, model: &Model) -> Result<()> {
if model.sections().is_empty() {
return Ok(());
}
self.comment("Sections");
for (name, s) in model.sections() {
self.comment(&format!("section {name}"));
let (set, material) = (&s.set, &s.material);
match s.kind {
SectionKind::Solid {} => {
self.line(&format!("*SOLID SECTION, ELSET={set}, MATERIAL={material}"));
self.line(",");
}
SectionKind::Rod { area } => {
self.line(&format!("*SOLID SECTION, ELSET={set}, MATERIAL={material}"));
self.numbers_line("", &[area])?;
}
SectionKind::Shell { thickness, offset, points } => {
let offset = if offset == 0.0 { String::new() } else { format!(", OFFSET={}", number(offset)?) };
self.line(&format!("*SHELL SECTION, ELSET={set}, MATERIAL={material}{offset}"));
push_number(&mut self.out, thickness)?;
let _ = writeln!(self.out, ", {points}");
}
SectionKind::Beam { profile, orientation } => {
// Abaqus profiles: RECT a, b; CIRC r; PIPE r, t; I l, h, b1, b2, t1, t2, t3
// (l: from the section origin to the bottom; centred here); BOX a, b, t1–t4.
let (shape, dims) = match profile {
BeamProfile::Rectangle { width, height } => ("RECT", vec![width, height]),
BeamProfile::Circle { radius } => ("CIRC", vec![radius]),
BeamProfile::Pipe { radius, thickness } => ("PIPE", vec![radius, thickness]),
BeamProfile::I { height, width, web, flange } => ("I", vec![height / 2.0, height, width, width, flange, flange, web]),
BeamProfile::Box { width, height, thickness } => ("BOX", vec![width, height, thickness, thickness, thickness, thickness]),
};
self.line(&format!("*BEAM SECTION, SECTION={shape}, ELSET={set}, MATERIAL={material}"));
self.numbers_line("", &dims)?;
self.numbers_line("", &orientation)?;
}
}
}
Ok(())
}
fn couplings(&mut self, model: &Model, r: &Resolved) {
if model.couplings().is_empty() {
return;
}
let surfaces = coupling_surfaces(model, r);
self.comment("Couplings: every node of the set weighted equally");
for (name, c) in model.couplings() {
let (Some(surface), Some(node)) = (surfaces.get(name), r.reference_point_nodes.get(&c.reference_point)) else { continue };
self.line(&format!("*SURFACE, TYPE=NODE, NAME={surface}"));
self.line(&format!("{}, 1.0", c.set));
self.line(&format!("*COUPLING, CONSTRAINT NAME={name}, REF NODE={node}, SURFACE={surface}"));
self.line(match c.kind {
CouplingKind::Kinematic => "*KINEMATIC",
CouplingKind::Distributing => "*DISTRIBUTING, WEIGHTING METHOD=UNIFORM",
});
for (a, b) in dof_runs(&c.dofs.list()) {
self.line(&format!("{a}, {b}"));
}
}
}
fn boundary_lines<'n>(&mut self, model: &Model, r: &Resolved, names: impl IntoIterator<Item = &'n String>) -> Result<()> {
for name in names {
let Some(bc) = model.boundary_condition(name) else { continue };
let target = match bc.target() {
Target::Set(s) => s.clone(),
Target::ReferencePoint(p) => match r.reference_point_nodes.get(p) {
Some(n) => n.to_string(),
None => continue,
},
};
self.comment(&format!("boundary condition {name}"));
match bc {
BoundaryCondition::Fixed { .. } => self.line(&format!("{target}, ENCASTRE")),
BoundaryCondition::Symmetry { plane, .. } => {
let s = match plane {
Symmetry::X => "XSYMM",
Symmetry::Y => "YSYMM",
Symmetry::Z => "ZSYMM",
};
self.line(&format!("{target}, {s}"));
}
BoundaryCondition::Displacement { values, .. } => {
for (dof, v) in (1u8..).zip(values) {
if let Some(v) = v {
self.numbers_line(&format!("{target}, {dof}, {dof}"), &[*v])?;
}
}
}
}
}
Ok(())
}
/// Boundary conditions of a step against the state it starts from.
fn step_boundaries(&mut self, model: &Model, r: &Resolved, base: &BTreeSet<String>, active: &BTreeSet<String>) -> Result<()> {
if active == base {
self.comment("boundary conditions: as in the base state");
Ok(())
} else if active.is_superset(base) {
self.line("*BOUNDARY");
self.boundary_lines(model, r, active.difference(base))
} else {
self.line("*BOUNDARY, OP=NEW");
self.boundary_lines(model, r, active)
}
}
/// The keyword of one load family with `OP=NEW`, when the step has entries for it or the
/// previous one had some to remove.
fn op_new(&mut self, keyword: &str, now: bool, before: bool) -> bool {
if now || before {
self.line(&format!("{keyword}, OP=NEW"));
}
now
}
fn step_loads(&mut self, loads: &StepLoads, before: Written) -> Result<()> {
if self.op_new("*CLOAD", !loads.concentrated.is_empty(), before.concentrated) {
for ((node, dof), v) in &loads.concentrated {
self.numbers_line(&format!("{node}, {dof}"), &[*v])?;
}
}
if self.op_new("*DSLOAD", !loads.pressure.is_empty(), before.pressure) {
for (surface, p) in &loads.pressure {
self.numbers_line(&format!("{surface}, P"), &[*p])?;
}
}
if self.op_new("*DLOAD", !loads.element.is_empty(), before.element) {
for (target, kind, v) in &loads.element {
let target = match target {
ElementTarget::Set(s) => s.to_string(),
ElementTarget::Element(e) => e.to_string(),
};
match kind {
BodyKind::Gravity => {
let g = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
self.numbers_line(&format!("{target}, GRAV"), &[g, v[0] / g, v[1] / g, v[2] / g])?;
}
BodyKind::Force => {
for (label, c) in ["BX", "BY", "BZ"].into_iter().zip(v) {
if *c != 0.0 {
self.numbers_line(&format!("{target}, {label}"), &[*c])?;
}
}
}
}
}
}
Ok(())
}
fn output(&mut self, step: &Step) {
let pick = |names: [(OutputVariable, &'static str); 2]| -> Vec<&'static str> {
names.into_iter().filter(|(v, _)| step.output.contains(v)).map(|(_, n)| n).collect()
};
let node = pick([(OutputVariable::Displacement, "U"), (OutputVariable::ReactionForce, "RF")]);
let element = pick([(OutputVariable::Stress, "S"), (OutputVariable::Strain, "E")]);
if node.is_empty() && element.is_empty() {
self.line("*OUTPUT, FIELD, OP=NEW, FREQUENCY=0");
return;
}
self.line("*OUTPUT, FIELD, OP=NEW");
if !node.is_empty() {
self.line("*NODE OUTPUT");
self.line(&node.join(", "));
}
if !element.is_empty() {
self.line("*ELEMENT OUTPUT");
self.line(&element.join(", "));
}
}
fn steps(&mut self, model: &Model, r: &Resolved) -> Result<()> {
let steps = model.steps();
// The initial state: zero-valued boundary conditions active in every step.
let initial: BTreeSet<String> = if steps.is_empty() {
BTreeSet::new()
} else {
let in_every_step = |n: &String| steps.iter().all(|s| s.boundary_conditions.contains(n));
model.boundary_conditions().iter().filter(|(n, bc)| holds_at_zero(bc) && in_every_step(n)).map(|(n, _)| n.clone()).collect()
};
if !initial.is_empty() {
self.comment("Initial state: boundary conditions active in every step");
self.line("*BOUNDARY");
self.boundary_lines(model, r, &initial)?;
}
// Gravity on the whole model acts on every element, through the section sets.
let whole_model_gravity = model.loads().values().any(|l| matches!(l, Load::Gravity { set: None, .. }));
let everything: (Vec<&str>, Vec<u32>) = if whole_model_gravity {
(model.sections().values().map(|s| s.set.as_str()).collect(), model.elements().keys().copied().collect())
} else {
Default::default()
};
let mut base_bcs = initial;
let mut base_loads: BTreeSet<String> = BTreeSet::new();
let mut written = Written::default();
for step in steps {
self.comment(&format!("Step {}", step.name));
match step.procedure {
Procedure::Static {} => {
self.line(&format!("*STEP, NAME={}, NLGEOM=NO", step.name));
self.line("*STATIC");
self.line("1.0, 1.0");
self.step_boundaries(model, r, &base_bcs, &step.boundary_conditions)?;
if step.loads == base_loads {
self.comment("loads: as in the previous general step");
} else {
let loads = StepLoads::new(model, r, step, &everything);
self.step_loads(&loads, written)?;
written = loads.written();
}
base_bcs.clone_from(&step.boundary_conditions);
base_loads.clone_from(&step.loads);
}
Procedure::Frequency { modes, min_hz, max_hz } => {
self.line(&format!("*STEP, NAME={}", step.name));
self.line("*FREQUENCY, EIGENSOLVER=LANCZOS");
// Modes, then the frequency range; empty trailing fields are left off.
let _ = write!(self.out, "{modes}");
let range: Vec<Option<f64>> = [min_hz, max_hz].into_iter().rev().skip_while(Option::is_none).collect();
for f in range.into_iter().rev() {
self.out.push_str(", ");
if let Some(f) = f {
push_number(&mut self.out, f)?;
}
}
self.out.push('\n');
self.step_boundaries(model, r, &base_bcs, &step.boundary_conditions)?;
if !step.loads.is_empty() {
self.comment("loads have no effect on natural frequencies and are not written");
}
}
}
self.output(step);
self.line("*END STEP");
}
Ok(())
}
}
+70
View File
@@ -2,9 +2,19 @@
//! keyword files. Each format is its own module (`abaqus`, `nastran`, `lsdyna`), written from the
//! vendor's public reference manual in our own words; examples and expected files are our own.
//! Writers read [`modelcraft_model::Model`] and its `resolve()` result, never another format.
//!
//! Writers produce text; saving it is the caller's business (the engine's `deck.write`), so the
//! crate stays free of the file system. [`FORMATS`] lists every writer by the name `deck.write`
//! takes.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod abaqus;
use std::collections::{BTreeMap, BTreeSet};
use modelcraft_model::{Diagnostic, Load, Model, Resolved};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum Error {
/// The model has something this format cannot express (named, with the format).
@@ -18,3 +28,63 @@ pub enum Error {
}
pub type Result<T> = std::result::Result<T, Error>;
/// A deck format.
pub struct Format {
/// The name commands use (`abaqus`, …).
pub name: &'static str,
/// The usual file extension, without the dot.
pub extension: &'static str,
writer: fn(&Model, &Resolved) -> Result<String>,
}
/// A written deck: its text and the model check's warnings.
#[derive(Debug, Clone, PartialEq)]
pub struct Deck {
pub text: String,
pub warnings: Vec<Diagnostic>,
}
impl Format {
/// Resolve the model once and write it in this format.
pub fn write(&self, model: &Model) -> Result<Deck> {
let resolved = model.resolve()?;
let text = (self.writer)(model, &resolved)?;
Ok(Deck { text, warnings: resolved.warnings })
}
}
/// Every deck format, one row per writer.
pub const FORMATS: &[Format] = &[Format { name: "abaqus", extension: "inp", writer: abaqus::write_resolved }];
/// The format called `name`.
pub fn format(name: &str) -> Option<&'static Format> {
FORMATS.iter().find(|f| f.name == name)
}
/// Fail with [`Error::Unsupported`] naming every item, or succeed when there are none. Writers
/// collect everything their format cannot express and report it at once.
pub(crate) fn unsupported(format: &'static str, items: BTreeSet<String>) -> Result<()> {
if items.is_empty() { Ok(()) } else { Err(Error::Unsupported { format, what: items.into_iter().collect::<Vec<_>>().join("; ") }) }
}
/// The concentrated loads of a set of active loads: the resolved nodal values of every force,
/// traction (degrees of freedom 1–3) and moment (4–6), summed per node and degree of freedom so
/// loads sharing a node add up; zero sums are left out.
pub fn concentrated_loads<'a>(model: &Model, r: &Resolved, active: impl IntoIterator<Item = &'a String>) -> BTreeMap<(u32, u8), f64> {
let mut out: BTreeMap<(u32, u8), f64> = BTreeMap::new();
for name in active {
let first_dof = match model.load(name) {
Some(Load::Moment { .. }) => 4,
Some(Load::Force { .. } | Load::Traction { .. }) => 1,
_ => continue,
};
for (node, v) in r.nodal_loads.get(name).map(Vec::as_slice).unwrap_or_default() {
for (dof, c) in (first_dof..).zip(v) {
*out.entry((*node, dof)).or_default() += c;
}
}
}
out.retain(|_, v| *v != 0.0);
out
}
+700
View File
@@ -0,0 +1,700 @@
//! Abaqus writer tests: golden decks of our own small models, a re-read of the written mesh and
//! sets by a minimal test-only reader, element orientation (Jacobian sign) from the written
//! connectivity, number round trips, line limits and everything reported as unsupported.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use modelcraft_decks::{Error, abaqus};
use modelcraft_model::{
BeamProfile, BoundaryCondition, Coupling, CouplingKind, Distribution, Dofs, Elastic, ElementFace, ElementKind, Formulation, Integration, Load,
Material, Model, OutputVariable, Procedure, ReferencePoint, Section, SectionKind, Set, Step, Symmetry, Target, UnitSystem,
};
// ---------------------------------------------------------------------------------------------
// Models
fn steel() -> Material {
Material { elastic: Some(Elastic::Isotropic { young: 210_000.0, poisson: 0.3 }), density: Some(7.85e-9) }
}
fn faces(list: &[(u32, u8)]) -> Set {
Set { faces: list.iter().map(|&(element, face)| ElementFace { element, face }).collect(), ..Set::default() }
}
fn elements(list: impl IntoIterator<Item = u32>) -> Set {
Set { elements: list.into_iter().collect(), ..Set::default() }
}
fn step(name: &str, procedure: Procedure, bcs: &[&str], loads: &[&str]) -> Step {
let mut s = Step::new(name, procedure);
s.boundary_conditions = bcs.iter().map(|s| s.to_string()).collect();
s.loads = loads.iter().map(|s| s.to_string()).collect();
s
}
/// A 100 × 10 × 10 cantilever of four hex8 elements along X: node (i, j, k) at
/// (25 i, 10 j, 10 k) is labelled 1 + 4 i + 2 j + k.
fn cantilever_mesh() -> Model {
let mut m = Model::default();
m.set_units(UnitSystem::MmTonneS);
let n = |i: u32, j: u32, k: u32| 1 + 4 * i + 2 * j + k;
for i in 0..=4 {
for j in 0..=1 {
for k in 0..=1 {
m.set_node(n(i, j, k), [25.0 * f64::from(i), 10.0 * f64::from(j), 10.0 * f64::from(k)]).unwrap();
}
}
}
for i in 0..4 {
let nodes = vec![n(i, 0, 0), n(i + 1, 0, 0), n(i + 1, 1, 0), n(i, 1, 0), n(i, 0, 1), n(i + 1, 0, 1), n(i + 1, 1, 1), n(i, 1, 1)];
m.set_element(i + 1, ElementKind::Hex8, nodes).unwrap();
}
m.add_set("all", elements(1..=4)).unwrap();
// Face 6 of element 1 lies on x = 0, face 4 of element 4 on x = 100.
m.add_set("root", faces(&[(1, 6)])).unwrap();
m.add_set("tip", faces(&[(4, 4)])).unwrap();
m.add_material("Steel", steel()).unwrap();
let formulation = Formulation { incompatible_modes: true, ..Formulation::default() };
m.add_section("beam", Section { set: "all".into(), material: "Steel".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("root".into()) }).unwrap();
m
}
/// The cantilever with a total end force of 100 down.
fn cantilever() -> Model {
let mut m = cantilever_mesh();
m.add_load("end", Load::Force { target: Target::Set("tip".into()), vector: [0.0, 0.0, -100.0], distribution: Distribution::Total }).unwrap();
m.add_step(step("Bend", Procedure::Static {}, &["clamp"], &["end"]), None).unwrap();
m
}
/// A 200 × 200 plate of 2 × 2 quad4 shells in z = 0, clamped along x = 0, under pressure on its
/// top face and its own weight, with a symmetry plane along x = 100.
fn plate() -> Model {
let mut m = Model::default();
m.set_units(UnitSystem::MmTonneS);
let n = |i: u32, j: u32| 1 + i + 3 * j;
for j in 0..=2 {
for i in 0..=2 {
m.set_node(n(i, j), [100.0 * f64::from(i), 100.0 * f64::from(j), 0.0]).unwrap();
}
}
let mut e = 1;
for j in 0..2 {
for i in 0..2 {
m.set_element(e, ElementKind::Quad4, vec![n(i, j), n(i + 1, j), n(i + 1, j + 1), n(i, j + 1)]).unwrap();
e += 1;
}
}
m.add_set("skin", elements(1..=4)).unwrap();
m.add_set("top", faces(&[(1, 1), (2, 1), (3, 1), (4, 1)])).unwrap();
m.add_set("edge", Set { nodes: vec![1, 4, 7], ..Set::default() }).unwrap();
m.add_set("mid", Set { nodes: vec![2, 5, 8], ..Set::default() }).unwrap();
let aluminium = Material { elastic: Some(Elastic::Isotropic { young: 70_000.0, poisson: 0.33 }), density: Some(2.7e-9) };
m.add_material("Aluminium", aluminium).unwrap();
let kind = SectionKind::Shell { thickness: 2.0, offset: 0.5, points: 5 };
let formulation = Formulation { integration: Integration::Reduced, ..Formulation::default() };
m.add_section("plate", Section { set: "skin".into(), material: "Aluminium".into(), kind, formulation }).unwrap();
m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("edge".into()) }).unwrap();
m.add_boundary_condition("sym", BoundaryCondition::Symmetry { target: Target::Set("mid".into()), plane: Symmetry::X }).unwrap();
m.add_load("wind", Load::Pressure { set: "top".into(), magnitude: 0.01 }).unwrap();
m.add_load("weight", Load::Gravity { acceleration: [0.0, 0.0, -9810.0], set: None }).unwrap();
m.add_step(step("Load", Procedure::Static {}, &["clamp", "sym"], &["wind", "weight"]), None).unwrap();
m
}
/// The cantilever with a reference point coupled to its tip, three static steps that change the
/// active boundary conditions and loads, and a frequency step in between.
fn frequency() -> Model {
let mut m = cantilever_mesh();
m.add_reference_point("Tip", ReferencePoint { xyz: [110.0, 5.0, 5.0], label: 0 }).unwrap();
m.add_coupling("tie", Coupling { reference_point: "Tip".into(), set: "tip".into(), kind: CouplingKind::Kinematic, dofs: Dofs::all() }).unwrap();
let rp = || Target::ReferencePoint("Tip".into());
m.add_load("twist", Load::Moment { target: rp(), vector: [5000.0, 0.0, 0.0], distribution: Distribution::Total }).unwrap();
m.add_load("swell", Load::BodyForce { set: "all".into(), vector: [0.0, 1e-3, 0.0] }).unwrap();
m.add_boundary_condition("lift", BoundaryCondition::Displacement { target: rp(), values: [None, None, Some(0.5), None, None, None] }).unwrap();
m.add_step(step("Twist", Procedure::Static {}, &["clamp"], &["twist"]), None).unwrap();
m.add_step(step("Modes", Procedure::Frequency { modes: 6, min_hz: Some(1.0), max_hz: None }, &["clamp", "lift"], &[]), None).unwrap();
m.add_step(step("Lift", Procedure::Static {}, &["clamp", "lift"], &["twist", "swell"]), None).unwrap();
let mut release = step("Release", Procedure::Static {}, &["clamp"], &[]);
release.output = [OutputVariable::Displacement].into_iter().collect();
m.add_step(release, None).unwrap();
m
}
// ---------------------------------------------------------------------------------------------
// A minimal reader of what the writer emits (full deck readers are a later milestone)
#[derive(Default, Debug)]
struct Parsed {
nodes: BTreeMap<u32, [f64; 3]>,
elements: BTreeMap<u32, (String, Vec<u32>)>,
nsets: BTreeMap<String, Vec<u32>>,
elsets: BTreeMap<String, Vec<u32>>,
surfaces: BTreeMap<String, Vec<(u32, String)>>,
}
fn param(keyword: &str, name: &str) -> Option<String> {
keyword.split(',').skip(1).find_map(|p| {
let (k, v) = p.split_once('=')?;
(k.trim().eq_ignore_ascii_case(name)).then(|| v.trim().to_string())
})
}
fn read(deck: &str) -> Parsed {
let mut p = Parsed::default();
let mut keyword = String::new();
let mut pending: Vec<String> = Vec::new();
for line in deck.lines() {
if line.starts_with("**") {
continue;
}
if line.starts_with('*') {
keyword = line.to_string();
continue;
}
let head = keyword.split(',').next().unwrap_or_default().trim().to_ascii_uppercase();
let fields: Vec<String> = line.split(',').map(|f| f.trim().to_string()).collect();
let label = |s: &str| s.parse::<u32>().unwrap();
match head.as_str() {
"*NODE" => {
let xyz = [1, 2, 3].map(|i| fields[i].parse::<f64>().unwrap());
p.nodes.insert(label(&fields[0]), xyz);
}
"*ELEMENT" => {
pending.extend(fields.iter().filter(|f| !f.is_empty()).cloned());
if !line.trim_end().ends_with(',') {
let ids: Vec<u32> = pending.drain(..).map(|f| label(&f)).collect();
p.elements.insert(ids[0], (param(&keyword, "TYPE").unwrap(), ids[1..].to_vec()));
}
}
"*NSET" => p.nsets.entry(param(&keyword, "NSET").unwrap()).or_default().extend(fields.iter().map(|f| label(f))),
"*ELSET" => p.elsets.entry(param(&keyword, "ELSET").unwrap()).or_default().extend(fields.iter().map(|f| label(f))),
"*SURFACE" if param(&keyword, "TYPE").as_deref() == Some("ELEMENT") => {
p.surfaces.entry(param(&keyword, "NAME").unwrap()).or_default().push((label(&fields[0]), fields[1].clone()));
}
_ => {}
}
}
p
}
// ---------------------------------------------------------------------------------------------
// Golden decks
fn golden(name: &str, deck: &str) {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/abaqus").join(format!("{name}.inp"));
if std::env::var_os("MODELCRAFT_BLESS").is_some() {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, deck).unwrap();
}
let expected = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e} (run with MODELCRAFT_BLESS=1 to write it)", path.display()));
assert!(expected == deck, "{name}.inp differs from the written deck (MODELCRAFT_BLESS=1 rewrites it):\n{deck}");
}
fn assert_lines_fit(deck: &str) {
for (i, line) in deck.lines().enumerate() {
assert!(line.len() <= abaqus::MAX_LINE, "line {} has {} characters", i + 1, line.len());
assert!(line.is_ascii(), "line {} is not ASCII", i + 1);
}
}
#[test]
fn golden_decks() {
for (name, model) in [("cantilever", cantilever()), ("plate", plate()), ("frequency", frequency())] {
let deck = abaqus::write(&model).unwrap();
assert_lines_fit(&deck);
golden(name, &deck);
// Deterministic: the same model, and the model after a file round trip, give the same bytes.
assert_eq!(abaqus::write(&model).unwrap(), deck);
let reread: Model = serde_json::from_str(&serde_json::to_string(&model).unwrap()).unwrap();
assert_eq!(abaqus::write(&reread).unwrap(), deck, "{name}");
}
}
#[test]
fn the_end_force_is_shared_by_area_and_keeps_its_total() {
let deck = abaqus::write(&cantilever()).unwrap();
let cload: Vec<&str> = deck.lines().skip_while(|l| !l.starts_with("*CLOAD")).skip(1).take_while(|l| !l.starts_with('*')).collect();
// Four equal corners of a flat square face.
assert_eq!(cload, ["17, 3, -25.0", "18, 3, -25.0", "19, 3, -25.0", "20, 3, -25.0"]);
let total: f64 = cload.iter().map(|l| l.rsplit(", ").next().unwrap().parse::<f64>().unwrap()).sum();
assert_eq!(total, -100.0);
}
#[test]
fn the_written_mesh_and_sets_read_back() {
for model in [cantilever(), plate(), frequency()] {
let deck = abaqus::write(&model).unwrap();
let p = read(&deck);
let rps: BTreeMap<u32, [f64; 3]> = model.reference_points().values().map(|r| (r.label, r.xyz)).collect();
let mut nodes: BTreeMap<u32, [f64; 3]> = model.nodes().iter().map(|(l, n)| (*l, n.xyz)).collect();
nodes.extend(rps);
assert_eq!(p.nodes, nodes);
let connectivity: BTreeMap<u32, Vec<u32>> = p.elements.iter().map(|(l, (_, n))| (*l, n.clone())).collect();
assert_eq!(connectivity, model.elements().iter().map(|(l, e)| (*l, e.nodes.clone())).collect());
let r = model.resolve().unwrap();
for (name, s) in &r.sets {
assert_eq!(p.nsets.get(name).cloned().unwrap_or_default(), s.nodes, "nset {name}");
assert_eq!(p.elsets.get(name).cloned().unwrap_or_default(), s.elements, "elset {name}");
let faces: Vec<(u32, String)> =
s.faces.iter().map(|f| (f.element, abaqus::face_id(model.element(f.element).unwrap().kind, f.face).unwrap().to_string())).collect();
assert_eq!(p.surfaces.get(name).cloned().unwrap_or_default(), faces, "surface {name}");
}
}
}
// ---------------------------------------------------------------------------------------------
// Node order: the element Jacobian of the written connectivity, in Abaqus's own conventions
/// Natural coordinates of each node, by Abaqus element family.
fn natural(kind: ElementKind) -> Vec<[f64; 3]> {
let hex = [[-1., -1., -1.], [1., -1., -1.], [1., 1., -1.], [-1., 1., -1.], [-1., -1., 1.], [1., -1., 1.], [1., 1., 1.], [-1., 1., 1.]];
match kind {
ElementKind::Hex8 => hex.to_vec(),
ElementKind::Hex20 => {
let mut v = hex.to_vec();
// Mid-sides: the bottom face's edges, the top face's, then the vertical ones.
v.extend([[0., -1., -1.], [1., 0., -1.], [0., 1., -1.], [-1., 0., -1.]]);
v.extend([[0., -1., 1.], [1., 0., 1.], [0., 1., 1.], [-1., 0., 1.]]);
v.extend([[-1., -1., 0.], [1., -1., 0.], [1., 1., 0.], [-1., 1., 0.]]);
v
}
ElementKind::Tet4 | ElementKind::Tet10 => {
let mut v = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]];
if kind == ElementKind::Tet10 {
v.extend([[0.5, 0., 0.], [0.5, 0.5, 0.], [0., 0.5, 0.], [0., 0., 0.5], [0.5, 0., 0.5], [0., 0.5, 0.5]]);
}
v
}
ElementKind::Wedge6 => vec![[0., 0., -1.], [1., 0., -1.], [0., 1., -1.], [0., 0., 1.], [1., 0., 1.], [0., 1., 1.]],
ElementKind::Quad4 => vec![[-1., -1., 0.], [1., -1., 0.], [1., 1., 0.], [-1., 1., 0.]],
ElementKind::Quad8 => {
vec![[-1., -1., 0.], [1., -1., 0.], [1., 1., 0.], [-1., 1., 0.], [0., -1., 0.], [1., 0., 0.], [0., 1., 0.], [-1., 0., 0.]]
}
ElementKind::Tri3 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
_ => Vec::new(),
}
}
/// Abaqus's shape functions of an element family at natural point `q`.
fn shape(kind: ElementKind, q: [f64; 3]) -> Vec<f64> {
let nodes = natural(kind);
let [x, y, z] = q;
match kind {
ElementKind::Hex8 => nodes.iter().map(|p| (1. + x * p[0]) * (1. + y * p[1]) * (1. + z * p[2]) / 8.).collect(),
ElementKind::Hex20 => nodes
.iter()
.map(|p| {
let (a, b, c) = (1. + x * p[0], 1. + y * p[1], 1. + z * p[2]);
if p[0] == 0. {
(1. - x * x) * b * c / 4.
} else if p[1] == 0. {
a * (1. - y * y) * c / 4.
} else if p[2] == 0. {
a * b * (1. - z * z) / 4.
} else {
a * b * c * (x * p[0] + y * p[1] + z * p[2] - 2.) / 8.
}
})
.collect(),
ElementKind::Tet4 => vec![1. - x - y - z, x, y, z],
ElementKind::Tet10 => {
let l = [1. - x - y - z, x, y, z];
let mut n: Vec<f64> = l.iter().map(|li| li * (2. * li - 1.)).collect();
for (a, b) in [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)] {
n.push(4. * l[a] * l[b]);
}
n
}
ElementKind::Wedge6 => {
let t = [1. - x - y, x, y];
t.iter().map(|ti| ti * (1. - z) / 2.).chain(t.iter().map(|ti| ti * (1. + z) / 2.)).collect()
}
ElementKind::Quad4 => nodes.iter().map(|p| (1. + x * p[0]) * (1. + y * p[1]) / 4.).collect(),
ElementKind::Quad8 => nodes
.iter()
.map(|p| {
if p[0] == 0. {
(1. - x * x) * (1. + y * p[1]) / 2.
} else if p[1] == 0. {
(1. + x * p[0]) * (1. - y * y) / 2.
} else {
(1. + x * p[0]) * (1. + y * p[1]) * (x * p[0] + y * p[1] - 1.) / 4.
}
})
.collect(),
ElementKind::Tri3 => vec![1. - x - y, x, y],
_ => Vec::new(),
}
}
/// Columns ∂X/∂ξ, ∂X/∂η, ∂X/∂ζ of the mapping at `q` (central differences).
fn jacobian(kind: ElementKind, xyz: &[[f64; 3]], q: [f64; 3]) -> [[f64; 3]; 3] {
let map = |q: [f64; 3]| {
let n = shape(kind, q);
let mut p = [0.0; 3];
for (ni, x) in n.iter().zip(xyz) {
for k in 0..3 {
p[k] += ni * x[k];
}
}
p
};
let h = 1e-6;
[0, 1, 2].map(|d| {
let (mut a, mut b) = (q, q);
a[d] += h;
b[d] -= h;
let (pa, pb) = (map(a), map(b));
[0, 1, 2].map(|k| (pa[k] - pb[k]) / (2. * h))
})
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// The smallest Jacobian determinant over the nodes and the centroid (solids), or the smallest
/// normal component along `up` (shells).
fn min_jacobian(kind: ElementKind, xyz: &[[f64; 3]], up: [f64; 3]) -> f64 {
let nodes = natural(kind);
let centroid = [0, 1, 2].map(|k| nodes.iter().map(|p| p[k]).sum::<f64>() / nodes.len() as f64);
nodes
.iter()
.chain([&centroid])
.map(|q| {
let [a, b, c] = jacobian(kind, xyz, *q);
if kind.dimension() == 3 { dot(cross(a, b), c) } else { dot(cross(a, b), up) }
})
.fold(f64::INFINITY, f64::min)
}
/// A one-element model of `kind` with a slanted, stretched, positive-volume placement of the
/// model's reference element (mid-side nodes at edge midpoints).
fn one_element(kind: ElementKind) -> Model {
let corners: Vec<[f64; 3]> = match kind {
ElementKind::Tri3 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
ElementKind::Quad4 | ElementKind::Quad8 => vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]],
ElementKind::Tet4 | ElementKind::Tet10 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]],
ElementKind::Wedge6 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [0., 1., 1.]],
_ => vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]],
};
let mut xyz = corners.clone();
xyz.resize(kind.node_count(), [0.0; 3]);
for e in kind.edges() {
if let [a, b, m] = **e {
xyz[m] = [0, 1, 2].map(|i| (corners[a][i] + corners[b][i]) / 2.0);
}
}
// A shear and stretch with a positive determinant, then a shift.
let place = |p: [f64; 3]| [3. * p[0] + 0.5 * p[1] + 10., 2. * p[1] + 0.3 * p[2] - 4., 1.5 * p[2] + 0.2 * p[0] + 7.];
let mut m = Model::default();
for (i, p) in xyz.iter().enumerate() {
m.set_node(i as u32 + 1, place(*p)).unwrap();
}
m.set_element(1, kind, (1..=kind.node_count() as u32).collect()).unwrap();
m.add_set("all", elements([1])).unwrap();
m.add_set("base", Set { nodes: vec![1], ..Set::default() }).unwrap();
m.add_material("Steel", steel()).unwrap();
let (section, formulation) = if kind.dimension() == 3 {
(SectionKind::Solid {}, Formulation::default())
} else {
(SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 }, Formulation { integration: Integration::Reduced, ..Formulation::default() })
};
m.add_section("s", Section { set: "all".into(), material: "Steel".into(), kind: section, formulation }).unwrap();
m.add_boundary_condition("fix", BoundaryCondition::Fixed { target: Target::Set("base".into()) }).unwrap();
m.add_step(step("Modes", Procedure::Frequency { modes: 1, min_hz: None, max_hz: None }, &["fix"], &[]), None).unwrap();
m
}
#[test]
fn written_connectivity_has_a_positive_jacobian_in_abaqus_conventions() {
let kinds = [ElementKind::Tet4, ElementKind::Tet10, ElementKind::Wedge6, ElementKind::Hex8, ElementKind::Hex20];
let shells = [ElementKind::Tri3, ElementKind::Quad4, ElementKind::Quad8];
for kind in kinds.into_iter().chain(shells) {
let model = one_element(kind);
let p = read(&abaqus::write(&model).unwrap());
let (_, nodes) = &p.elements[&1];
let xyz: Vec<[f64; 3]> = nodes.iter().map(|n| p.nodes[n]).collect();
// A shell's positive normal is the side its face 1 (SPOS) looks to: the placed +Z here.
let up = cross([3., 0., 0.2], [0.5, 2., 0.]);
let j = min_jacobian(kind, &xyz, up);
assert!(j > 0.0, "{kind:?}: smallest Jacobian {j}");
// The test has teeth: a mirrored linear element shows up (and a misplaced quadratic
// mid-side block, below).
if kind.node_count() == natural(kind).len()
&& matches!(kind, ElementKind::Tet4 | ElementKind::Wedge6 | ElementKind::Hex8 | ElementKind::Tri3 | ElementKind::Quad4)
{
let mut mirrored = xyz.clone();
match kind {
ElementKind::Tet4 | ElementKind::Tri3 => mirrored.swap(1, 2),
ElementKind::Wedge6 => {
mirrored.swap(1, 2);
mirrored.swap(4, 5);
}
ElementKind::Quad4 => mirrored.swap(1, 3),
_ => {
mirrored.swap(1, 3);
mirrored.swap(5, 7);
}
}
assert!(min_jacobian(kind, &mirrored, up) < 0.0, "{kind:?}: mirrored");
}
}
// Hex20 in the order with the vertical edges' mid-sides before the top face's (Nastran's).
let model = one_element(ElementKind::Hex20);
let p = read(&abaqus::write(&model).unwrap());
let mut xyz: Vec<[f64; 3]> = p.elements[&1].1.iter().map(|n| p.nodes[n]).collect();
let (top, vertical) = (xyz[12..16].to_vec(), xyz[16..20].to_vec());
xyz[12..16].copy_from_slice(&vertical);
xyz[16..20].copy_from_slice(&top);
assert!(min_jacobian(ElementKind::Hex20, &xyz, [0.; 3]) < 0.0, "a misplaced mid-side block is caught");
}
#[test]
fn model_faces_are_abaqus_faces() {
// Each face's node positions (1-based, Abaqus element library, our transcription).
let table: [(ElementKind, &[&[usize]]); 3] = [
(ElementKind::Hex8, &[&[1, 2, 3, 4], &[5, 8, 7, 6], &[1, 5, 6, 2], &[2, 6, 7, 3], &[3, 7, 8, 4], &[4, 8, 5, 1]]),
(ElementKind::Tet4, &[&[1, 2, 3], &[1, 4, 2], &[2, 4, 3], &[3, 4, 1]]),
(ElementKind::Wedge6, &[&[1, 2, 3], &[4, 6, 5], &[1, 4, 5, 2], &[2, 5, 6, 3], &[3, 6, 4, 1]]),
];
for (kind, faces) in table {
assert_eq!(kind.faces().len(), faces.len());
for (i, (ours, theirs)) in kind.faces().iter().zip(faces).enumerate() {
let ours: BTreeSet<usize> = ours.iter().map(|p| p + 1).collect();
let theirs: BTreeSet<usize> = theirs.iter().copied().collect();
assert_eq!(ours, theirs, "{kind:?} face {}", i + 1);
assert_eq!(abaqus::face_id(kind, i as u8 + 1), Some(format!("S{}", i + 1).as_str()));
}
assert_eq!(abaqus::face_id(kind, 0), None);
assert_eq!(abaqus::face_id(kind, faces.len() as u8 + 1), None);
}
assert_eq!(abaqus::face_id(ElementKind::Quad4, 1), Some("SPOS"));
assert_eq!(abaqus::face_id(ElementKind::Tri3, 2), Some("SNEG"));
assert_eq!(abaqus::face_id(ElementKind::Beam2, 1), None);
}
// ---------------------------------------------------------------------------------------------
// Numbers and limits
#[test]
fn numbers_read_back_exactly() {
for (v, s) in [(0.1, "0.1"), (210_000.0, "210000.0"), (7.85e-9, "7.85E-9"), (1e300, "1.0E300"), (-0.5, "-0.5"), (1e16, "1.0E16")] {
assert_eq!(abaqus::number(v).unwrap(), s);
}
let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
for _ in 0..100_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let v = f64::from_bits(x);
if !v.is_finite() {
assert!(abaqus::number(v).is_err());
continue;
}
let s = abaqus::number(v).unwrap();
assert_eq!(s.parse::<f64>().unwrap().to_bits(), v.to_bits(), "{v:e} → {s}");
assert!(s.len() <= 24, "{s}");
assert!(!s.contains('e'), "{s}");
}
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(matches!(abaqus::number(bad), Err(Error::Unsupported { .. })));
}
}
#[test]
fn largest_labels_longest_numbers_and_names_fit_a_line() {
let mut m = Model::default();
let base = modelcraft_model::MAX_LABEL - 30;
let ugly = -1.234_567_890_123_456_7e-300;
let mut xyz = one_element(ElementKind::Hex20).nodes().values().map(|n| n.xyz).collect::<Vec<_>>();
xyz[0] = [ugly, ugly, ugly];
for (i, p) in xyz.iter().enumerate() {
m.set_node(base + i as u32, *p).unwrap();
}
m.set_element(modelcraft_model::MAX_LABEL, ElementKind::Hex20, (0..20).map(|i| base + i).collect()).unwrap();
let long = format!("A{}", "x".repeat(modelcraft_model::MAX_NAME_LEN - 1));
m.add_set(&long, Set { elements: vec![modelcraft_model::MAX_LABEL], nodes: (0..20).map(|i| base + i).collect(), ..Set::default() }).unwrap();
m.add_material(&long, Material { elastic: Some(Elastic::Isotropic { young: ugly.abs(), poisson: 0.3 }), density: Some(1.0) }).unwrap();
m.add_section(&long, Section { set: long.clone(), material: long.clone(), kind: SectionKind::Solid {}, formulation: Formulation::default() })
.unwrap();
m.add_reference_point(&long, ReferencePoint { xyz: [ugly; 3], label: 0 }).unwrap();
m.add_coupling(&long, Coupling { reference_point: long.clone(), set: long.clone(), kind: CouplingKind::Kinematic, dofs: Dofs::all() }).unwrap();
m.add_boundary_condition(&long, BoundaryCondition::Fixed { target: Target::Set(long.clone()) }).unwrap();
m.add_step(step(&long, Procedure::Frequency { modes: 100_000, min_hz: Some(ugly.abs()), max_hz: Some(1e300) }, &[&long], &[]), None).unwrap();
let deck = abaqus::write(&m).unwrap();
assert_lines_fit(&deck);
let p = read(&deck);
assert_eq!(p.elements[&modelcraft_model::MAX_LABEL].1.len(), 20, "a hex20 continues onto a second line");
assert_eq!(p.nodes[&base], [ugly; 3]);
}
// ---------------------------------------------------------------------------------------------
// Step activity, load summing
fn block<'a>(deck: &'a str, step: &str) -> Vec<&'a str> {
let start = format!("*STEP, NAME={step}");
deck.lines().skip_while(|l| !l.starts_with(&start)).take_while(|l| *l != "*END STEP").filter(|l| !l.starts_with("**")).collect()
}
#[test]
fn steps_write_the_activity_table() {
let deck = abaqus::write(&frequency()).unwrap();
// `clamp` holds at zero in every step: the initial state.
let initial: Vec<&str> = deck.lines().skip_while(|l| !l.starts_with("** Initial state")).skip(1).take(3).collect();
assert_eq!(initial, ["*BOUNDARY", "** boundary condition clamp", "root, ENCASTRE"]);
let twist = block(&deck, "Twist");
assert!(twist.contains(&"*CLOAD, OP=NEW") && twist.contains(&"99999999, 4, 5000.0"), "{twist:?}");
assert!(!twist.iter().any(|l| l.starts_with("*BOUNDARY")), "{twist:?}");
// The frequency step adds `lift` to its base state and writes no loads.
let modes = block(&deck, "Modes");
assert!(modes.contains(&"*BOUNDARY") && modes.contains(&"99999999, 3, 3, 0.5"), "{modes:?}");
assert!(!modes.iter().any(|l| l.contains("LOAD")), "{modes:?}");
assert!(modes.contains(&"6, 1.0"), "{modes:?}");
// `Lift` adds `lift` again: the frequency step's change did not carry on.
let lift = block(&deck, "Lift");
assert!(lift.contains(&"*BOUNDARY") && lift.contains(&"99999999, 3, 3, 0.5"), "{lift:?}");
assert!(lift.contains(&"*DLOAD, OP=NEW") && lift.contains(&"all, BY, 0.001"), "{lift:?}");
// `Release` drops `lift` and every load.
let release = block(&deck, "Release");
assert!(release.contains(&"*BOUNDARY, OP=NEW") && release.contains(&"root, ENCASTRE"), "{release:?}");
for k in ["*CLOAD, OP=NEW", "*DLOAD, OP=NEW"] {
let at = release.iter().position(|l| *l == k).unwrap_or_else(|| panic!("{k} in {release:?}"));
assert!(release.get(at + 1).is_none_or(|l| l.starts_with('*')), "{k} restates nothing: {release:?}");
}
assert!(!release.contains(&"*DSLOAD, OP=NEW"), "no pressure before, none now");
assert!(release.contains(&"U") && !release.contains(&"*ELEMENT OUTPUT"), "{release:?}");
}
#[test]
fn loads_sharing_nodes_elements_or_surfaces_add_up() {
let mut m = plate();
m.add_load("gust", Load::Pressure { set: "top".into(), magnitude: 0.02 }).unwrap();
m.add_set("half", elements([1, 2])).unwrap();
m.add_load("extra", Load::Gravity { acceleration: [0.0, 0.0, -9810.0], set: Some("half".into()) }).unwrap();
m.add_load("p1", Load::Force { target: Target::Set("mid".into()), vector: [3.0, 0.0, 0.0], distribution: Distribution::PerNode }).unwrap();
m.add_load("p2", Load::Force { target: Target::Set("mid".into()), vector: [1.0, 0.0, 0.0], distribution: Distribution::PerNode }).unwrap();
for l in ["gust", "extra", "p1", "p2"] {
m.set_load_active("Load", l, true).unwrap();
}
let deck = abaqus::write(&m).unwrap();
let lines = block(&deck, "Load");
assert!(lines.contains(&"top, P, 0.03"), "{lines:?}");
assert!(lines.contains(&"5, 1, 4.0"), "{lines:?}");
// Two gravity loads on elements 1 and 2: summed per element.
assert!(lines.contains(&"1, GRAV, 19620.0, 0.0, 0.0, -1.0"), "{lines:?}");
assert!(lines.contains(&"3, GRAV, 9810.0, 0.0, 0.0, -1.0"), "{lines:?}");
assert!(!lines.iter().any(|l| l.starts_with("skin, GRAV")), "{lines:?}");
}
// ---------------------------------------------------------------------------------------------
// What the format cannot express, and models that are not ready
fn unsupported(m: &Model) -> String {
match abaqus::write(m) {
Err(Error::Unsupported { format: "Abaqus", what }) => what,
other => panic!("expected unsupported, got {other:?}"),
}
}
fn with_section(kind: ElementKind, section: SectionKind, integration: Integration) -> Model {
let mut m = one_element(kind);
let formulation = Formulation { integration, ..Formulation::default() };
m.replace_section("s", Section { set: "all".into(), material: "Steel".into(), kind: section, formulation }).unwrap();
m
}
#[test]
fn what_abaqus_cannot_express_is_named() {
let shell = SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 };
let mut tri6 = Model::default();
for (i, p) in [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.5, 0., 0.], [0.5, 0.5, 0.], [0., 0.5, 0.]].iter().enumerate() {
tri6.set_node(i as u32 + 1, *p).unwrap();
}
tri6.set_element(1, ElementKind::Tri6, (1..=6).collect()).unwrap();
tri6.add_set("all", elements([1])).unwrap();
tri6.add_material("Steel", steel()).unwrap();
tri6.add_section("s", Section { set: "all".into(), material: "Steel".into(), kind: shell, formulation: Formulation::default() }).unwrap();
tri6.add_step(step("Modes", Procedure::Frequency { modes: 1, min_hz: None, max_hz: None }, &[], &[]), None).unwrap();
assert!(unsupported(&tri6).contains("tri6 shells"));
assert!(unsupported(&with_section(ElementKind::Quad8, shell, Integration::Full)).contains("full integration of quad8"));
assert!(unsupported(&with_section(ElementKind::Tet10, SectionKind::Solid {}, Integration::Reduced)).contains("reduced integration of tet10"));
assert!(unsupported(&with_section(ElementKind::Wedge6, SectionKind::Solid {}, Integration::Reduced)).contains("wedge6"));
// A linear tetrahedron integrates at one point either way.
assert!(abaqus::write(&with_section(ElementKind::Tet4, SectionKind::Solid {}, Integration::Reduced)).unwrap().contains("TYPE=C3D4\n"));
let mut case = cantilever();
case.add_set("ROOT", Set { nodes: vec![1], ..Set::default() }).unwrap();
let what = unsupported(&case);
assert!(what.contains("sets `ROOT` and `root`") || what.contains("sets `root` and `ROOT`"), "{what}");
let mut ortho = plate();
let carbon = Elastic::EngineeringConstants { young: [140e3, 10e3, 10e3], poisson: [0.3, 0.3, 0.4], shear: [5e3, 5e3, 3.8e3] };
ortho.replace_material("Aluminium", Material { elastic: Some(carbon), density: Some(1.6e-9) }).unwrap();
assert!(unsupported(&ortho).contains("orthotropic material `Aluminium` in a shell section"));
let mut loose = frequency();
let dofs = Dofs::try_from(vec![1, 2, 4, 5, 6]).unwrap();
loose.replace_coupling("tie", Coupling { reference_point: "Tip".into(), set: "tip".into(), kind: CouplingKind::Distributing, dofs }).unwrap();
assert!(unsupported(&loose).contains("coupling `tie`"));
// Several problems are named together.
let mut both = with_section(ElementKind::Quad8, shell, Integration::Full);
both.add_set("ALL", Set { nodes: vec![1], ..Set::default() }).unwrap();
assert!(abaqus::write(&both).unwrap_err().to_string().contains("full integration"));
}
#[test]
fn orthotropic_solids_beams_and_rods_are_written() {
let mut m = cantilever();
let carbon = Elastic::EngineeringConstants { young: [140e3, 10e3, 10e3], poisson: [0.3, 0.3, 0.4], shear: [5e3, 5e3, 3.8e3] };
m.replace_material("Steel", Material { elastic: Some(carbon), density: None }).unwrap();
let deck = abaqus::write(&m).unwrap();
assert!(deck.contains("*ELASTIC, TYPE=ENGINEERING CONSTANTS\n140000.0, 10000.0, 10000.0, 0.3, 0.3, 0.4, 5000.0, 5000.0\n3800.0\n"), "{deck}");
// A frame of one beam and one rod.
let mut f = Model::default();
for (i, p) in [[0., 0., 0.], [1000., 0., 0.], [1000., 0., 500.]].iter().enumerate() {
f.set_node(i as u32 + 1, *p).unwrap();
}
f.set_element(1, ElementKind::Beam2, vec![1, 2]).unwrap();
f.set_element(2, ElementKind::Rod2, vec![2, 3]).unwrap();
f.add_set("beam", elements([1])).unwrap();
f.add_set("rod", elements([2])).unwrap();
f.add_set("base", Set { nodes: vec![1, 3], ..Set::default() }).unwrap();
f.add_material("Steel", steel()).unwrap();
let i = BeamProfile::I { height: 100.0, width: 50.0, web: 5.0, flange: 8.0 };
let beam = SectionKind::Beam { profile: i, orientation: [0.0, 1.0, 0.0] };
f.add_section("b", Section { set: "beam".into(), material: "Steel".into(), kind: beam, formulation: Formulation::default() }).unwrap();
let rod = SectionKind::Rod { area: 50.0 };
f.add_section("r", Section { set: "rod".into(), material: "Steel".into(), kind: rod, formulation: Formulation::default() }).unwrap();
f.add_boundary_condition("pin", BoundaryCondition::Fixed { target: Target::Set("base".into()) }).unwrap();
f.add_step(step("Modes", Procedure::Frequency { modes: 3, min_hz: None, max_hz: Some(500.0) }, &["pin"], &[]), None).unwrap();
let deck = abaqus::write(&f).unwrap();
for expected in [
"*ELEMENT, TYPE=B31\n1, 1, 2\n",
"*ELEMENT, TYPE=T3D2\n2, 2, 3\n",
"*BEAM SECTION, SECTION=I, ELSET=beam, MATERIAL=Steel\n50.0, 100.0, 50.0, 50.0, 8.0, 8.0, 5.0\n0.0, 1.0, 0.0\n",
"*SOLID SECTION, ELSET=rod, MATERIAL=Steel\n50.0\n",
"3, , 500.0\n",
] {
assert!(deck.contains(expected), "{expected}\n{deck}");
}
}
#[test]
fn a_model_that_is_not_ready_is_refused() {
let mut m = cantilever();
m.remove_section("beam").unwrap();
assert!(matches!(abaqus::write(&m), Err(Error::Model(_))));
assert!(matches!(abaqus::write(&Model::default()), Err(Error::Model(_))));
}
+75
View File
@@ -0,0 +1,75 @@
*HEADING
ModelCraft model, units mm-t-s-N-MPa
** Written by ModelCraft. The numbers are in one consistent unit system and are not converted.
** Nodes
*NODE
1, 0.0, 0.0, 0.0
2, 0.0, 0.0, 10.0
3, 0.0, 10.0, 0.0
4, 0.0, 10.0, 10.0
5, 25.0, 0.0, 0.0
6, 25.0, 0.0, 10.0
7, 25.0, 10.0, 0.0
8, 25.0, 10.0, 10.0
9, 50.0, 0.0, 0.0
10, 50.0, 0.0, 10.0
11, 50.0, 10.0, 0.0
12, 50.0, 10.0, 10.0
13, 75.0, 0.0, 0.0
14, 75.0, 0.0, 10.0
15, 75.0, 10.0, 0.0
16, 75.0, 10.0, 10.0
17, 100.0, 0.0, 0.0
18, 100.0, 0.0, 10.0
19, 100.0, 10.0, 0.0
20, 100.0, 10.0, 10.0
** Elements
*ELEMENT, TYPE=C3D8I
1, 1, 5, 7, 3, 2, 6, 8, 4
2, 5, 9, 11, 7, 6, 10, 12, 8
3, 9, 13, 15, 11, 10, 14, 16, 12
4, 13, 17, 19, 15, 14, 18, 20, 16
** Sets
*NSET, NSET=all
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16
17, 18, 19, 20
*ELSET, ELSET=all
1, 2, 3, 4
*NSET, NSET=root
1, 2, 3, 4
*SURFACE, TYPE=ELEMENT, NAME=root
1, S6
*NSET, NSET=tip
17, 18, 19, 20
*SURFACE, TYPE=ELEMENT, NAME=tip
4, S4
** Materials
*MATERIAL, NAME=Steel
*ELASTIC, TYPE=ISOTROPIC
210000.0, 0.3
*DENSITY
7.85E-9
** Sections
** section beam
*SOLID SECTION, ELSET=all, MATERIAL=Steel
,
** Initial state: boundary conditions active in every step
*BOUNDARY
** boundary condition clamp
root, ENCASTRE
** Step Bend
*STEP, NAME=Bend, NLGEOM=NO
*STATIC
1.0, 1.0
** boundary conditions: as in the base state
*CLOAD, OP=NEW
17, 3, -25.0
18, 3, -25.0
19, 3, -25.0
20, 3, -25.0
*OUTPUT, FIELD, OP=NEW
*NODE OUTPUT
U, RF
*ELEMENT OUTPUT
S, E
*END STEP
+123
View File
@@ -0,0 +1,123 @@
*HEADING
ModelCraft model, units mm-t-s-N-MPa
** Written by ModelCraft. The numbers are in one consistent unit system and are not converted.
** Nodes
*NODE
1, 0.0, 0.0, 0.0
2, 0.0, 0.0, 10.0
3, 0.0, 10.0, 0.0
4, 0.0, 10.0, 10.0
5, 25.0, 0.0, 0.0
6, 25.0, 0.0, 10.0
7, 25.0, 10.0, 0.0
8, 25.0, 10.0, 10.0
9, 50.0, 0.0, 0.0
10, 50.0, 0.0, 10.0
11, 50.0, 10.0, 0.0
12, 50.0, 10.0, 10.0
13, 75.0, 0.0, 0.0
14, 75.0, 0.0, 10.0
15, 75.0, 10.0, 0.0
16, 75.0, 10.0, 10.0
17, 100.0, 0.0, 0.0
18, 100.0, 0.0, 10.0
19, 100.0, 10.0, 0.0
20, 100.0, 10.0, 10.0
** Reference points
*NODE
** reference point Tip
99999999, 110.0, 5.0, 5.0
** Elements
*ELEMENT, TYPE=C3D8I
1, 1, 5, 7, 3, 2, 6, 8, 4
2, 5, 9, 11, 7, 6, 10, 12, 8
3, 9, 13, 15, 11, 10, 14, 16, 12
4, 13, 17, 19, 15, 14, 18, 20, 16
** Sets
*NSET, NSET=all
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16
17, 18, 19, 20
*ELSET, ELSET=all
1, 2, 3, 4
*NSET, NSET=root
1, 2, 3, 4
*SURFACE, TYPE=ELEMENT, NAME=root
1, S6
*NSET, NSET=tip
17, 18, 19, 20
*SURFACE, TYPE=ELEMENT, NAME=tip
4, S4
** Materials
*MATERIAL, NAME=Steel
*ELASTIC, TYPE=ISOTROPIC
210000.0, 0.3
*DENSITY
7.85E-9
** Sections
** section beam
*SOLID SECTION, ELSET=all, MATERIAL=Steel
,
** Couplings: every node of the set weighted equally
*SURFACE, TYPE=NODE, NAME=tie-nodes
tip, 1.0
*COUPLING, CONSTRAINT NAME=tie, REF NODE=99999999, SURFACE=tie-nodes
*KINEMATIC
1, 6
** Initial state: boundary conditions active in every step
*BOUNDARY
** boundary condition clamp
root, ENCASTRE
** Step Twist
*STEP, NAME=Twist, NLGEOM=NO
*STATIC
1.0, 1.0
** boundary conditions: as in the base state
*CLOAD, OP=NEW
99999999, 4, 5000.0
*OUTPUT, FIELD, OP=NEW
*NODE OUTPUT
U, RF
*ELEMENT OUTPUT
S, E
*END STEP
** Step Modes
*STEP, NAME=Modes
*FREQUENCY, EIGENSOLVER=LANCZOS
6, 1.0
*BOUNDARY
** boundary condition lift
99999999, 3, 3, 0.5
*OUTPUT, FIELD, OP=NEW
*NODE OUTPUT
U
*END STEP
** Step Lift
*STEP, NAME=Lift, NLGEOM=NO
*STATIC
1.0, 1.0
*BOUNDARY
** boundary condition lift
99999999, 3, 3, 0.5
*CLOAD, OP=NEW
99999999, 4, 5000.0
*DLOAD, OP=NEW
all, BY, 0.001
*OUTPUT, FIELD, OP=NEW
*NODE OUTPUT
U, RF
*ELEMENT OUTPUT
S, E
*END STEP
** Step Release
*STEP, NAME=Release, NLGEOM=NO
*STATIC
1.0, 1.0
*BOUNDARY, OP=NEW
** boundary condition clamp
root, ENCASTRE
*CLOAD, OP=NEW
*DLOAD, OP=NEW
*OUTPUT, FIELD, OP=NEW
*NODE OUTPUT
U
*END STEP
+67
View File
@@ -0,0 +1,67 @@
*HEADING
ModelCraft model, units mm-t-s-N-MPa
** Written by ModelCraft. The numbers are in one consistent unit system and are not converted.
** Nodes
*NODE
1, 0.0, 0.0, 0.0
2, 100.0, 0.0, 0.0
3, 200.0, 0.0, 0.0
4, 0.0, 100.0, 0.0
5, 100.0, 100.0, 0.0
6, 200.0, 100.0, 0.0
7, 0.0, 200.0, 0.0
8, 100.0, 200.0, 0.0
9, 200.0, 200.0, 0.0
** Elements
*ELEMENT, TYPE=S4R
1, 1, 2, 5, 4
2, 2, 3, 6, 5
3, 4, 5, 8, 7
4, 5, 6, 9, 8
** Sets
*NSET, NSET=edge
1, 4, 7
*NSET, NSET=mid
2, 5, 8
*NSET, NSET=skin
1, 2, 3, 4, 5, 6, 7, 8, 9
*ELSET, ELSET=skin
1, 2, 3, 4
*NSET, NSET=top
1, 2, 3, 4, 5, 6, 7, 8, 9
*SURFACE, TYPE=ELEMENT, NAME=top
1, SPOS
2, SPOS
3, SPOS
4, SPOS
** Materials
*MATERIAL, NAME=Aluminium
*ELASTIC, TYPE=ISOTROPIC
70000.0, 0.33
*DENSITY
2.7E-9
** Sections
** section plate
*SHELL SECTION, ELSET=skin, MATERIAL=Aluminium, OFFSET=0.5
2.0, 5
** Initial state: boundary conditions active in every step
*BOUNDARY
** boundary condition clamp
edge, ENCASTRE
** boundary condition sym
mid, XSYMM
** Step Load
*STEP, NAME=Load, NLGEOM=NO
*STATIC
1.0, 1.0
** boundary conditions: as in the base state
*DSLOAD, OP=NEW
top, P, 0.01
*DLOAD, OP=NEW
skin, GRAV, 9810.0, 0.0, 0.0, -1.0
*OUTPUT, FIELD, OP=NEW
*NODE OUTPUT
U, RF
*ELEMENT OUTPUT
S, E
*END STEP
+1
View File
@@ -13,6 +13,7 @@ modelcraft-model = { workspace = true }
modelcraft-geometry = { workspace = true }
modelcraft-mesh = { workspace = true }
modelcraft-io = { workspace = true }
modelcraft-decks = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+65
View File
@@ -0,0 +1,65 @@
//! Solver decks: the model written in a solver's input format.
use std::path::Path;
use serde_json::{Value, json};
use super::req;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const WRITE: CommandSpec = CommandSpec {
id: "deck.write",
label: "Write Solver Deck",
params: r#"{"format": "abaqus", "path": "part.inp"} — writes the model as a solver deck (formats: modelcraft_decks::FORMATS); fails naming every error of the model check and everything the format cannot express"#,
kind: CommandKind::Session,
run: write,
};
fn write(s: &mut Session, p: &Value) -> Result<Value> {
let name: &str = req(p, "format")?;
let format = modelcraft_decks::format(name).ok_or_else(|| {
let shown: String = name.chars().take(32).collect();
let known: Vec<&str> = modelcraft_decks::FORMATS.iter().map(|f| f.name).collect();
EngineError::Params(format!("unknown deck format `{shown}`; known: {}", known.join(", ")))
})?;
let path = super::path_param(p, "path")?;
let deck = format.write(&s.model)?;
modelcraft_io::fs::write_atomic(Path::new(path), deck.text.as_bytes(), false)?;
Ok(json!({"format": format.name, "path": path, "bytes": deck.text.len(), "warnings": deck.warnings}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_the_defined_model_and_refuses_what_it_cannot() {
let dir = std::env::temp_dir().join(format!("modelcraft-deck-write-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bar.inp");
let path_str = path.to_string_lossy().into_owned();
let mut s = Session::default();
crate::cmd::define::tests::defined_bar(&mut s);
let journal = s.journal.len();
let out = s.execute("deck.write", &json!({"format": "abaqus", "path": path_str})).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, modelcraft_decks::abaqus::write(&s.model).unwrap());
assert_eq!(out["bytes"], written.len());
assert_eq!(out["warnings"].as_array().map(Vec::len), Some(1), "`press` is in no step: {out}");
assert!(written.contains("*ELEMENT, TYPE=C3D8I"), "{written}");
assert_eq!(s.journal.len(), journal, "writing a deck does not change the model");
assert!(std::fs::read_dir(&dir).unwrap().count() == 1, "no partial file is left behind");
// Unknown formats, missing parameters and models that are not ready fail without writing.
let other = dir.join("other.inp").to_string_lossy().into_owned();
assert!(s.execute("deck.write", &json!({"format": "nope", "path": other})).unwrap_err().to_string().contains("known: abaqus"));
assert!(s.execute("deck.write", &json!({"path": other})).is_err());
assert!(s.execute("deck.write", &json!({"format": "abaqus"})).is_err());
let mut empty = Session::default();
assert!(matches!(empty.execute("deck.write", &json!({"format": "abaqus", "path": other})), Err(EngineError::Deck(_))));
assert!(!dir.join("other.inp").exists());
assert!(matches!(s.execute("deck.write", &json!({"format": "abaqus", "path": dir.to_string_lossy()})), Err(EngineError::File(_))));
let _ = std::fs::remove_dir_all(&dir);
}
}
+2
View File
@@ -1,5 +1,6 @@
//! The command registry. Each area has its own module; [`ALL`] lists every command.
pub(crate) mod deck;
pub(crate) mod define;
mod edit;
pub(crate) mod file;
@@ -66,6 +67,7 @@ pub static ALL: &[CommandSpec] = &[
file::RECOVERY_DISCARD,
edit::UNDO,
edit::REDO,
deck::WRITE,
];
/// A required label parameter (1..=u32::MAX; the model checks its own range).
+10
View File
@@ -40,6 +40,8 @@ pub enum EngineError {
Mesh(#[from] modelcraft_mesh::Error),
#[error(transparent)]
File(#[from] modelcraft_io::Error),
#[error(transparent)]
Deck(#[from] modelcraft_decks::Error),
/// A command panicked; the model is unchanged.
#[error("internal error in {command}: {message}")]
Internal { command: String, message: String },
@@ -229,6 +231,14 @@ mod tests {
json!({"path": "/dev/zero"}),
json!({"path": "/no/such/design.solidcraft"}),
json!({"path": "x".repeat(100_000)}),
json!({"format": "abaqus"}),
json!({"format": "abaqus", "path": ""}),
json!({"format": "abaqus", "path": "/"}),
json!({"format": "abaqus", "path": "/no/such/dir/part.inp"}),
json!({"format": "abaqus", "path": format!("/no/such/{}", "x".repeat(100_000))}),
json!({"format": "abaqus", "path": 7}),
json!({"format": "x".repeat(100_000), "path": "/no/such/part.inp"}),
json!({"format": null, "path": "/no/such/part.inp"}),
json!({"path": 7}),
json!({"body": "Body1", "tolerance": 0.05}),
json!({"body": null, "tolerance": "fine"}),
+1 -1
View File
@@ -22,7 +22,7 @@ crates in strictly lower layers.
| L1 | `modelcraft-geometry` | The only door to SolidCraft: opens `.solidcraft` designs (rebuilt from their timeline, bodies with persistent face names), B-rep bodies, measures, tessellation into a face-tagged `SurfaceMesh`; face-tagged 3MF files read into the same type without the kernel (docs/formats/tagged-mesh.md). No SolidCraft type in its API. | started |
| L1 | `modelcraft-mesh` | Surface preparation (weld, watertightness, feature edges), robust predicates, Delaunay tetrahedralization and refinement, Voronoi-based optimization, hex meshing, quality metrics. | started |
| L1 | `modelcraft-solver` | Element formulations, assembly, sparse linear static and modal solution. | planned |
| L1 | `modelcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. | planned |
| L1 | `modelcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. Text out, no file system; `deck.write` saves it. | started |
| L1 | `modelcraft-render` | Display data without a GPU dependency: the visible faces, element edges and nodes of a model, the orbit camera, a CPU rasterizer for headless snapshots; contour maps later. | started |
| L2 | `modelcraft-doc` | The document: geometry, mesh, model, results, history. | planned |
| L3 | `modelcraft-io` | `.modelcraft` files (docs/formats/modelcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery. Bytes in, bytes out; the file system only in `fs` and `recovery`. Result files later. | started |
+6 -1
View File
@@ -56,7 +56,7 @@ products are run, no documentation text is copied). Status: **done**, **started*
| Interaction | Tie between non-matching meshes | M7 | | planned |
| Interaction | Contact (general and pairs), friction, automatic pair detection | M7 | | planned |
| Interaction | Bolt pretension, springs, point masses, connectors | M7 | | planned |
| Decks | Abaqus input writer | M3 | | planned |
| Decks | Abaqus input writer (`deck.write`, flat deck; reports what Abaqus cannot express) | M3 | | done |
| Decks | Nastran bulk data writer (SOL 101, 103) | M3 | | planned |
| Decks | LS-DYNA keyword writer (implicit; explicit in M8) | M3 | | planned |
| Decks | Readers keeping unknown keywords | M9 | | planned |
@@ -76,3 +76,8 @@ products are run, no documentation text is copied). Status: **done**, **started*
as degenerate quads. Every mapping is tested by the sign of the element Jacobian after writing.
- Each format's field widths, continuation rules, number forms and label limits are respected by
the writer and checked by golden-file tests on our own examples.
- Abaqus: the model's node order and face numbers are Abaqus's, so connectivity passes through;
element types come from the element kind, the section and its formulation (table in
`crates/decks/src/abaqus.rs`). The step table is bridged to Abaqus's propagation: zero-valued
boundary conditions active in every step go to the initial state, and steps restate their
boundary conditions and loads with `OP=NEW` when the active set changes.