diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 83eb313..6912350 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -66,6 +66,7 @@ is committed and listed here with its exports.
| assets/icons/mesh.surface.check.svg, assets/icons/mesh.surface.check.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.surface.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.tag.svg, assets/icons/mesh.tag.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tag.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.tet.generate.svg, assets/icons/mesh.tet.generate.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tet.generate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+| assets/icons/mesh.planar.svg, assets/icons/mesh.planar.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.planar.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.transition.svg, assets/icons/mesh.transition.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.transition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.untangle.svg, assets/icons/mesh.untangle.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.untangle.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.verify.svg, assets/icons/mesh.verify.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.verify.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
diff --git a/ROADMAP.md b/ROADMAP.md
index ef21b64..cc908f2 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -22,7 +22,7 @@ hex meshes today and it gives the best elements where stresses are read.
| 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: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, few-ulp rounding repair checked by the validity gate; `modelcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); 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 (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); 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 | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `modelcraft-fem` kernels, `modelcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
+| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `modelcraft-fem` kernels, `modelcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
| 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 |
| M7 Assemblies and contact | Part instances from SolidCraft occurrences; tie between non-matching meshes; general and pairwise contact with automatic pair detection, friction; bolt pretension, springs, point masses; amplitudes, multiple steps; nonlinear static steps in decks | planned |
diff --git a/assets/icons/mesh.planar.svg b/assets/icons/mesh.planar.svg
new file mode 100644
index 0000000..74478ee
--- /dev/null
+++ b/assets/icons/mesh.planar.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/assets/icons/mesh.planar.vectorcraft b/assets/icons/mesh.planar.vectorcraft
new file mode 100644
index 0000000..5bde7ab
Binary files /dev/null and b/assets/icons/mesh.planar.vectorcraft differ
diff --git a/assets/icons/src/mesh.planar.json b/assets/icons/src/mesh.planar.json
new file mode 100644
index 0000000..a22d219
--- /dev/null
+++ b/assets/icons/src/mesh.planar.json
@@ -0,0 +1 @@
+{"shapes": [{"d": "M3 5 L21 5 L21 19 L3 19 Z", "fill": "soft", "stroke": "line"}, {"d": "M9 5 L9 19 M15 5 L15 19 M3 12 L21 12", "stroke": "accent", "width": 1.25}]}
diff --git a/crates/decks/src/abaqus.rs b/crates/decks/src/abaqus.rs
index 004173b..88a7252 100644
--- a/crates/decks/src/abaqus.rs
+++ b/crates/decks/src/abaqus.rs
@@ -64,6 +64,18 @@
//! | quad8 | | S8R (reduced only) | | |
//! | beam2, rod2 | | | B31 | T3D2 |
//!
+//! | ModelCraft | Plane stress | Plane strain | Axisymmetric |
+//! |---|---|---|---|
+//! | tri3 | CPS3 | CPE3 | CAX3 |
+//! | tri6 | CPS6 | CPE6 | CAX6 |
+//! | quad4 | CPS4, CPS4R, CPS4I | CPE4, CPE4R, CPE4I | CAX4, CAX4R, CAX4I |
+//! | quad8 | CPS8, CPS8R | CPE8, CPE8R | CAX8, CAX8R |
+//!
+//! 2D models: Abaqus's axisymmetric elements, like the model, take r = x and the axis along Y,
+//! and their concentrated loads are totals over the circumference; a 2D section's thickness is
+//! the `*SOLID SECTION` data line. Surfaces of 2D elements are their edges, `S1`… in the edge
+//! order of the model; body forces of axisymmetric elements are `BR`, `BZ`.
+//!
//! `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
@@ -125,9 +137,28 @@ pub fn element_type(kind: ElementKind, section: &Section) -> std::result::Result
use SectionKind as S;
let f = section.formulation;
let reduced = f.integration == Integration::Reduced;
- if (f.hybrid || f.incompatible_modes) && section.kind.dimension() != 3 {
+ if (f.hybrid && section.kind.dimension() != 3) || (f.incompatible_modes && !section.kind.is_continuum()) {
return Err(format!("hybrid or incompatible-mode {} sections", section.kind.name()));
}
+ if let Some(prefix) = match section.kind {
+ S::PlaneStress { .. } => Some("CPS"),
+ S::PlaneStrain { .. } => Some("CPE"),
+ S::Axisymmetric {} => Some("CAX"),
+ _ => None,
+ } {
+ let suffix = match (kind, reduced, f.incompatible_modes) {
+ (K::Quad4, false, true) => "4I",
+ (_, true, true) => return Err("incompatible modes with reduced integration".into()),
+ (_, _, true) => return Err(format!("incompatible modes on {} elements (quad4 only)", kind.name())),
+ (K::Tri3, ..) => "3",
+ (K::Tri6, true, _) => return Err("reduced integration of tri6 elements (Abaqus has no reduced-integration variant)".into()),
+ (K::Tri6, ..) => "6",
+ (K::Quad4, r, _) => ["4", "4R"][usize::from(r)],
+ (K::Quad8, r, _) => ["8", "8R"][usize::from(r)],
+ _ => return Err(format!("{} elements in a {} section", kind.name(), section.kind.name())),
+ };
+ return Ok(format!("{prefix}{suffix}"));
+ }
let plain = match (§ion.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()),
@@ -209,7 +240,7 @@ fn check_expressible(model: &Model) -> Result<()> {
}
for (name, section) in model.sections() {
let ortho = matches!(model.material(§ion.material).and_then(|m| m.elastic), Some(Elastic::EngineeringConstants { .. }));
- if ortho && section.kind.dimension() != 3 {
+ if ortho && !section.kind.is_continuum() {
bad.insert(format!(
"section `{name}`: orthotropic material `{}` in a {} section (its material axes need an orientation, not supported yet)",
section.material,
@@ -268,10 +299,22 @@ pub fn face_id(kind: ElementKind, face: u8) -> Option<&'static str> {
}
}
+/// The surface identifier of edge `edge` (1-based) of a 2D element: `S1`… in the model's edge
+/// order (which is Abaqus's face order for plane and axisymmetric elements).
+pub fn edge_id(kind: ElementKind, edge: u8) -> Option<&'static str> {
+ const EDGES: [&str; 4] = ["S1", "S2", "S3", "S4"];
+ let i = usize::from(edge).checked_sub(1)?;
+ if kind.dimension() != 2 || i >= kind.edges().len() {
+ return None;
+ }
+ EDGES.get(i).copied()
+}
+
/// 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 {
- let mut taken: BTreeSet = r.sets.iter().filter(|(_, s)| !s.faces.is_empty()).map(|(n, _)| n.to_ascii_uppercase()).collect();
+ let mut taken: BTreeSet =
+ r.sets.iter().filter(|(n, s)| !s.faces.is_empty() || !r.planar_edges(n).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");
@@ -365,6 +408,9 @@ fn element_loads<'a>(loads: &[ElementLoad<'a>], kind: BodyKind, out: &mut Vec<(E
out.retain(|(_, _, v)| v.iter().any(|c| *c != 0.0));
}
+/// A centrifugal load: the element set, ω², a point on the axis and its direction.
+type Centrifugal<'a> = (&'a str, f64, [f64; 3], [f64; 3]);
+
/// The loads of one general step, as the deck writes them.
#[derive(Default)]
struct StepLoads<'a> {
@@ -373,6 +419,9 @@ struct StepLoads<'a> {
/// Surface (set name) → summed pressure.
pressure: BTreeMap<&'a str, f64>,
element: Vec<(ElementTarget<'a>, BodyKind, [f64; 3])>,
+ centrifugal: Vec>,
+ /// Body forces of axisymmetric elements are radial and axial (`BR`, `BZ`).
+ axisymmetric: bool,
}
/// Which load keywords a step wrote entries for, so the next step knows what to remove.
@@ -387,7 +436,8 @@ 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)) -> StepLoads<'a> {
- let mut s = StepLoads { concentrated: crate::concentrated_loads(model, r, &step.loads), ..StepLoads::default() };
+ let axisymmetric = r.extent() == Some(modelcraft_model::Extent::Revolved);
+ let mut s = StepLoads { concentrated: crate::concentrated_loads(model, r, &step.loads), axisymmetric, ..StepLoads::default() };
let (mut gravity, mut body) = (Vec::new(), Vec::new());
for name in &step.loads {
match model.load(name) {
@@ -397,6 +447,13 @@ impl<'a> StepLoads<'a> {
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)),
+ Some(Load::Centrifugal { angular_velocity, point, axis, set }) => {
+ let w2 = angular_velocity * angular_velocity;
+ match set {
+ Some(set) => s.centrifugal.push((set, w2, *point, *axis)),
+ None => s.centrifugal.extend(everything.0.iter().map(|set| (*set, w2, *point, *axis))),
+ }
+ }
_ => {}
}
}
@@ -407,7 +464,11 @@ impl<'a> StepLoads<'a> {
}
fn written(&self) -> Written {
- Written { concentrated: !self.concentrated.is_empty(), pressure: !self.pressure.is_empty(), element: !self.element.is_empty() }
+ Written {
+ concentrated: !self.concentrated.is_empty(),
+ pressure: !self.pressure.is_empty(),
+ element: !self.element.is_empty() || !self.centrifugal.is_empty(),
+ }
}
}
@@ -513,7 +574,8 @@ impl Deck {
self.line(&format!("*ELSET, ELSET={name}"));
self.labels(&s.elements);
}
- if !s.faces.is_empty() {
+ let edges = r.planar_edges(name);
+ if !s.faces.is_empty() || !edges.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 {
@@ -522,6 +584,13 @@ impl Deck {
})?;
let _ = writeln!(self.out, "{}, {id}", f.element);
}
+ for g in &edges {
+ let id = model.element(g.element).and_then(|e| edge_id(e.kind, g.edge)).ok_or_else(|| Error::Unsupported {
+ format: FORMAT,
+ what: format!("set `{name}`: element {} has no edge {}", g.element, g.edge),
+ })?;
+ let _ = writeln!(self.out, "{}, {id}", g.element);
+ }
}
}
Ok(())
@@ -565,10 +634,14 @@ impl Deck {
self.comment(&format!("section {name}"));
let (set, material) = (&s.set, &s.material);
match s.kind {
- SectionKind::Solid {} => {
+ SectionKind::Solid {} | SectionKind::Axisymmetric {} => {
self.line(&format!("*SOLID SECTION, ELSET={set}, MATERIAL={material}"));
self.line(",");
}
+ SectionKind::PlaneStress { thickness } | SectionKind::PlaneStrain { thickness } => {
+ self.line(&format!("*SOLID SECTION, ELSET={set}, MATERIAL={material}"));
+ self.numbers_line("", &[thickness])?;
+ }
SectionKind::Rod { area } => {
self.line(&format!("*SOLID SECTION, ELSET={set}, MATERIAL={material}"));
self.numbers_line("", &[area])?;
@@ -686,7 +759,7 @@ impl Deck {
self.numbers_line(&format!("{surface}, P"), &[*p])?;
}
}
- if self.op_new("*DLOAD", !loads.element.is_empty(), before.element) {
+ if self.op_new("*DLOAD", !loads.element.is_empty() || !loads.centrifugal.is_empty(), before.element) {
for (target, kind, v) in &loads.element {
let target = match target {
ElementTarget::Set(s) => s.to_string(),
@@ -698,7 +771,8 @@ impl Deck {
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) {
+ let labels: &[&str] = if loads.axisymmetric { &["BR", "BZ"] } else { &["BX", "BY", "BZ"] };
+ for (label, c) in labels.iter().zip(v) {
if *c != 0.0 {
self.numbers_line(&format!("{target}, {label}"), &[*c])?;
}
@@ -706,6 +780,10 @@ impl Deck {
}
}
}
+ for (set, w2, point, axis) in &loads.centrifugal {
+ // CENTRIF: ω², a point on the axis and the axis direction.
+ self.numbers_line(&format!("{set}, CENTRIF"), &[*w2, point[0], point[1], point[2], axis[0], axis[1], axis[2]])?;
+ }
}
Ok(())
}
@@ -746,7 +824,7 @@ impl Deck {
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 whole_model_gravity = model.loads().values().any(|l| matches!(l, Load::Gravity { set: None, .. } | Load::Centrifugal { set: None, .. }));
let everything: (Vec<&str>, Vec) = if whole_model_gravity {
(model.sections().values().map(|s| s.set.as_str()).collect(), model.elements().keys().copied().collect())
} else {
diff --git a/crates/decks/src/lib.rs b/crates/decks/src/lib.rs
index bbad58e..076d236 100644
--- a/crates/decks/src/lib.rs
+++ b/crates/decks/src/lib.rs
@@ -15,6 +15,7 @@ pub mod nastran;
use std::collections::{BTreeMap, BTreeSet};
+use modelcraft_model::nodal::edge_pressure;
use modelcraft_model::{Diagnostic, Load, Model, Resolved};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
@@ -134,3 +135,26 @@ pub fn concentrated_loads<'a>(model: &Model, r: &Resolved, active: impl IntoIter
out.retain(|_, v| *v != 0.0);
out
}
+
+/// The consistent nodal forces (degrees of freedom 1 and 2) of the active pressures on a 2D
+/// model's element edges, for formats that write them as concentrated loads: the model's edge
+/// integration ([`modelcraft_model::nodal::edge_pressure`]), a positive pressure pushing
+/// into the element, summed per node and direction; zero sums are left out.
+pub fn edge_pressure_loads<'a>(model: &Model, r: &Resolved, active: impl IntoIterator- ) -> Result> {
+ let mut out: BTreeMap<(u32, u8), f64> = BTreeMap::new();
+ for name in active {
+ let Some(Load::Pressure { set, magnitude }) = model.load(name) else { continue };
+ for g in r.planar_edges(set) {
+ let Some(extent) = r.planar.get(&g.element).copied() else { continue };
+ let labels = model.edge_nodes(g).unwrap_or_default();
+ let xyz: Vec<[f64; 3]> = labels.iter().filter_map(|n| model.node(*n)).map(|n| n.xyz).collect();
+ for (node, f) in labels.iter().zip(edge_pressure(&xyz, extent, *magnitude)?) {
+ for (dof, c) in (1u8..).zip(&f[..2]) {
+ *out.entry((*node, dof)).or_default() += c;
+ }
+ }
+ }
+ }
+ out.retain(|_, v| *v != 0.0);
+ Ok(out)
+}
diff --git a/crates/decks/src/lsdyna/elements.rs b/crates/decks/src/lsdyna/elements.rs
index 11b10bd..980dbc6 100644
--- a/crates/decks/src/lsdyna/elements.rs
+++ b/crates/decks/src/lsdyna/elements.rs
@@ -97,7 +97,9 @@ pub(crate) struct Elform {
/// hexahedra), reduced 15 (two-point pentahedron); tet4 10 (one point, exact for a linear
/// tetrahedron), hybrid 13 (nodal pressure); tet10 16; hex20 23. Shells: quad4 16 (fully
/// integrated), reduced 2 (one-point) with hourglass control; tri3 4 (C0 triangle); quad8 23;
-/// tri6 24. Rods: 3 (truss).
+/// tri6 24. Rods: 3 (truss). 2D (in the XY plane): plane stress 12, plane strain 13, both one-point
+/// forms on quad4 (written for reduced integration, with hourglass control) and tri3 (exact at one
+/// point); quadratic 2D elements and axisymmetric sections have no form written here.
/// What it cannot express is the reason, naming the section.
pub(crate) fn elform(kind: ElementKind, section: &SectionKind, f: Formulation, section_name: &str) -> Result {
let plain = |elform| Ok(Elform { elform, hourglass: None });
@@ -142,6 +144,18 @@ pub(crate) fn elform(kind: ElementKind, section: &SectionKind, f: Formulation, s
_ => no("a rod section cannot hold"),
},
SectionKind::Beam { .. } => no("beam sections are not written to LS-DYNA decks yet (planned with beam profiles):"),
+ SectionKind::Axisymmetric {} => {
+ no("axisymmetric sections are not written to LS-DYNA decks yet (its axisymmetric forms count loads per radian; not verified):")
+ }
+ SectionKind::PlaneStress { .. } | SectionKind::PlaneStrain { .. } => {
+ let form = if matches!(section, SectionKind::PlaneStress { .. }) { 12 } else { 13 };
+ match kind {
+ ElementKind::Tri3 => plain(form),
+ ElementKind::Quad4 if reduced => Ok(Elform { elform: form, hourglass: Some(Hourglass::Shell) }),
+ ElementKind::Quad4 => no("LS-DYNA's 2D forms integrate at one point (set the section's integration to reduced) for"),
+ _ => no("LS-DYNA's 2D forms are linear; there is no form for"),
+ }
+ }
}
}
diff --git a/crates/decks/src/lsdyna/mod.rs b/crates/decks/src/lsdyna/mod.rs
index 5c27750..8721b34 100644
--- a/crates/decks/src/lsdyna/mod.rs
+++ b/crates/decks/src/lsdyna/mod.rs
@@ -30,6 +30,11 @@
//! order when LS-DYNA is started with `CASE` on its command line. A single step is written
//! without cases.
//!
+//! 2D models (plane stress and plane strain in the XY plane) are `*SECTION_SHELL` parts with
+//! ELFORM 12 and 13 ([`elements`]); plane strain is per unit thickness in LS-DYNA, so only a
+//! thickness of 1 is written; pressures on 2D element edges are the consistent nodal forces
+//! (`*LOAD_NODE_POINT`).
+//!
//! What LS-DYNA cannot take is an [`Error::Unsupported`] naming the item: beam sections (not yet
//! written), body forces per volume, kinematic couplings of some degrees of freedom only, boundary
//! conditions on a coupled reference point, gravity on different sets, hybrid formulations other
@@ -188,6 +193,11 @@ fn plan<'a>(model: &'a Model, r: &Resolved) -> Result> {
let el = model.element(*e).ok_or_else(|| broken(format!("element {e} of section `{name}`")))?;
by_kind.entry(el.kind).or_default().push((*e, el));
}
+ if let SectionKind::PlaneStrain { thickness } = sec.kind
+ && thickness != 1.0
+ {
+ refused.insert(format!("section `{name}`: a plane strain thickness of {thickness} (LS-DYNA's plane strain is per unit thickness)"));
+ }
if let SectionKind::Shell { points, .. } = sec.kind
&& points > MAX_SHELL_POINTS
{
@@ -270,9 +280,13 @@ fn plan<'a>(model: &'a Model, r: &Resolved) -> Result> {
}
}
Load::Traction { .. } => {}
+ Load::Pressure { .. } if r.is_planar() => {}
Load::Pressure { set, .. } => {
segment_sets.insert(set.as_str());
}
+ Load::Centrifugal { .. } => {
+ refused.insert(format!("load `{l}`: a centrifugal load (not written to LS-DYNA decks yet)"));
+ }
Load::Gravity { set, .. } => {
gravity_sets.insert(set.as_deref());
}
@@ -414,6 +428,14 @@ fn parts(d: &mut Deck, plan: &Plan) -> Result<()> {
d.card([Field::from(area)]);
}
SectionKind::Beam { .. } => return Err(broken(format!("section `{}`: beams have no LS-DYNA formulation", p.name))),
+ SectionKind::PlaneStress { thickness } | SectionKind::PlaneStrain { thickness } => {
+ d.line("*SECTION_SHELL");
+ d.header(&["secid", "elform"]);
+ d.card([Field::from(p.id), Field::from(p.elform)]);
+ d.header(&["t1", "t2", "t3", "t4"]);
+ d.card([Field::from(thickness), Field::from(thickness), Field::from(thickness), Field::from(thickness)]);
+ }
+ SectionKind::Axisymmetric {} => return Err(broken(format!("section `{}`: axisymmetric sections have no LS-DYNA formulation", p.name))),
}
}
for (h, hgid) in &plan.hourglasses {
@@ -621,12 +643,14 @@ fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Ste
if is_static {
let mut gravity = [0.0f64; 3];
let mut pointwise = Vec::new();
+ let mut edge_pressures = Vec::new();
for (name, load) in &loads {
match load {
Load::Force { .. } | Load::Moment { .. } if plan.uniform.contains_key(name.as_str()) => {
on_node_set(d, plan, name, matches!(load, Load::Moment { .. }), n);
}
Load::Force { .. } | Load::Moment { .. } | Load::Traction { .. } => pointwise.push(*name),
+ Load::Pressure { .. } if r.is_planar() => edge_pressures.push(name.as_str()),
Load::Pressure { set, magnitude } => {
let sid = plan.sets.get(set.as_str()).copied().unwrap_or_default();
d.comment(&format!("load `{name}`"));
@@ -640,10 +664,15 @@ fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Ste
}
}
Load::BodyForce { .. } => return Err(broken(format!("load `{name}`: a body force"))),
+ Load::Centrifugal { .. } => return Err(broken(format!("load `{name}`: a centrifugal load"))),
}
}
// The other concentrated loads node by node, summed where they share a node.
- let point = crate::concentrated_loads(model, r, pointwise);
+ let mut point = crate::concentrated_loads(model, r, pointwise);
+ for (key, v) in crate::edge_pressure_loads(model, r, edge_pressures)? {
+ *point.entry(key).or_default() += v;
+ }
+ point.retain(|_, v| *v != 0.0);
if !point.is_empty() {
d.line("*LOAD_NODE_POINT");
d.header(&["nid", "dof", "lcid", "sf"]);
diff --git a/crates/decks/src/nastran/mod.rs b/crates/decks/src/nastran/mod.rs
index 5b86fb9..be07584 100644
--- a/crates/decks/src/nastran/mod.rs
+++ b/crates/decks/src/nastran/mod.rs
@@ -24,7 +24,15 @@
//! gravity: `GRAV`; a step with several loads combines them with `LOAD`;
//! - kinematic coupling: `RBE2` (the reference point independent); distributing coupling: `RBE3`
//! (equal weights on the set's translations; the reference point dependent);
-//! - frequency steps: `EIGRL` (Lanczos) with the step's mode count and range.
+//! - frequency steps: `EIGRL` (Lanczos) with the step's mode count and range;
+//! - 2D models: plane stress as membranes, `CTRIA3`, `CQUAD4`, `CTRIA6`, `CQUAD8` with a
+//! `PSHELL` that has only a membrane material, every grid holding its out-of-plane degrees of
+//! freedom 3–6 (`GRID` field PS); edge pressures as the consistent `FORCE`s the model's edge
+//! integration gives. Plane strain and axisymmetric elements are not written: their cards
+//! (`PPLANE`/`PLPLANE` with `CPLSTN*`/`CQUADX`, `CTRIAX6`, `PAXI`) differ between Nastran
+//! dialects, so a deck could not be read the same everywhere;
+//! - centrifugal loads: `RFORCE` about an axis through the origin of the basic system (speed in
+//! revolutions per time unit).
//!
//! Field widths, continuations and number forms are in [`card`]. Model content Nastran cannot
//! express is reported as [`Error::Unsupported`], every item at once, and nothing is written.
@@ -188,10 +196,31 @@ struct MaterialUse {
/// One property entry, and what its elements' connection entries need besides their nodes.
enum Property {
- Solid { mid: u32, rule: (Option<&'static str>, Option<&'static str>) },
- Shell { mid: u32, thickness: f64, offset: Option, mcid: Option },
- Beam { mid: u32, profile: BeamProfile, orientation: [f64; 3] },
- Rod { mid: u32, area: f64 },
+ Solid {
+ mid: u32,
+ rule: (Option<&'static str>, Option<&'static str>),
+ },
+ Shell {
+ mid: u32,
+ thickness: f64,
+ offset: Option,
+ mcid: Option,
+ },
+ Beam {
+ mid: u32,
+ profile: BeamProfile,
+ orientation: [f64; 3],
+ },
+ Rod {
+ mid: u32,
+ area: f64,
+ },
+ /// Plane stress: a shell with only a membrane material.
+ Membrane {
+ mid: u32,
+ thickness: f64,
+ mcid: Option,
+ },
}
/// A constraint set: per node, the prescribed value of each degree of freedom.
@@ -299,7 +328,9 @@ impl<'m> Deck<'m> {
let u = uses.entry(s.material.as_str()).or_default();
match s.kind {
SectionKind::Solid {} => u.solid = true,
- SectionKind::Shell { .. } => u.shell = true,
+ SectionKind::Shell { .. } | SectionKind::PlaneStress { .. } | SectionKind::PlaneStrain { .. } | SectionKind::Axisymmetric {} => {
+ u.shell = true
+ }
SectionKind::Beam { .. } | SectionKind::Rod { .. } => u.line = true,
}
}
@@ -398,6 +429,25 @@ impl<'m> Deck<'m> {
let Some(mid) = ids.isotropic else { continue };
self.add_property(sname, Property::Rod { mid, area }, elements.iter().copied())?;
}
+ SectionKind::PlaneStress { thickness } => {
+ if sec.formulation.integration == Integration::Reduced || sec.formulation.incompatible_modes {
+ self.problems.insert(format!(
+ "section `{sname}`: reduced integration or incompatible modes of plane stress elements (Nastran's membranes have one formulation)"
+ ));
+ }
+ let (mid, mcid) = match ids {
+ MaterialIds { isotropic: Some(m), .. } => (m, None),
+ MaterialIds { shell: Some(m), .. } => (m, Some(*self.material_csys.get_or_insert(1))),
+ _ => continue,
+ };
+ self.add_property(sname, Property::Membrane { mid, thickness, mcid }, elements.iter().copied())?;
+ }
+ SectionKind::PlaneStrain { .. } | SectionKind::Axisymmetric {} => {
+ self.problems.insert(format!(
+ "section `{sname}`: {} elements (Nastran's plane strain and axisymmetric cards differ between dialects; not written)",
+ sec.kind.name()
+ ));
+ }
}
}
Ok(())
@@ -529,6 +579,22 @@ impl<'m> Deck<'m> {
self.problems.insert(format!("load `{name}`: gravity on part of the model (set `{s}`); Nastran's gravity acts on all of it"));
}
Load::Gravity { .. } => gravity += 1,
+ Load::Centrifugal { set: Some(s), .. } if r.sets.get(s).map(|s| s.elements.len()) != Some(model.elements().len()) => {
+ self.problems
+ .insert(format!("load `{name}`: a rotation of part of the model (set `{s}`); Nastran's RFORCE acts on all of it"));
+ }
+ Load::Centrifugal { point, axis, .. } => {
+ let c = [
+ point[1] * axis[2] - point[2] * axis[1],
+ point[2] * axis[0] - point[0] * axis[2],
+ point[0] * axis[1] - point[1] * axis[0],
+ ];
+ if c.iter().any(|v| *v != 0.0) {
+ self.problems.insert(format!(
+ "load `{name}`: a rotation about an axis that misses the origin (written as RFORCE about the origin)"
+ ));
+ }
+ }
_ => {}
}
let sid = next_id(&mut self.next_sid)?;
@@ -597,7 +663,10 @@ impl<'m> Deck<'m> {
fn emit_mesh(&self, out: &mut String) -> Result<()> {
comment(out, "nodes");
for (label, node) in self.model.nodes() {
- Card::new("GRID").int(*label).blank().reals(node.xyz).write(out)?;
+ let grid = Card::new("GRID").int(*label).blank().reals(node.xyz);
+ // A 2D model's grids hold their out-of-plane translation and rotations.
+ let grid = if self.r.is_planar() { grid.blank().int(3456) } else { grid };
+ grid.write(out)?;
}
if !self.model.reference_points().is_empty() {
comment(out, "reference points");
@@ -650,6 +719,7 @@ impl<'m> Deck<'m> {
Card::new("PBEAML").int(pid).int(*mid).blank().text(shape).end_line().reals(dims)
}
Property::Rod { mid, area } => Card::new("PROD").int(pid).int(*mid).real(*area),
+ Property::Membrane { mid, thickness, .. } => Card::new("PSHELL").int(pid).int(*mid).real(*thickness),
};
write_named(out, &format!("section {section}"), pid, card)?;
}
@@ -664,6 +734,8 @@ impl<'m> Deck<'m> {
// CQUAD8 has four corner thicknesses before the material axes and the offset.
Property::Shell { offset, mcid, .. } if el.kind == ElementKind::Quad8 => card.blank().blank().blank().blank().opt(*mcid).opt(*offset),
Property::Shell { offset, mcid, .. } => card.opt(*mcid).opt(*offset),
+ Property::Membrane { mcid, .. } if el.kind == ElementKind::Quad8 => card.blank().blank().blank().blank().opt(*mcid),
+ Property::Membrane { mcid, .. } => card.opt(*mcid),
Property::Solid { .. } | Property::Rod { .. } => card,
};
card.write(out)?;
@@ -748,6 +820,18 @@ impl<'m> Deck<'m> {
entry(Card::new(card).int(*sid).int(*node).blank().real(1.0).reals(*v), out, &mut first)?;
}
}
+ Load::Pressure { .. } if self.r.is_planar() => {
+ let forces = crate::edge_pressure_loads(self.model, self.r, [*name])?;
+ let mut by_node: BTreeMap = BTreeMap::new();
+ for ((node, dof), v) in forces {
+ if let Some(c) = by_node.entry(node).or_default().get_mut(usize::from(dof) - 1) {
+ *c = v;
+ }
+ }
+ for (node, v) in by_node {
+ entry(Card::new("FORCE").int(*sid).int(node).blank().real(1.0).reals(v), out, &mut first)?;
+ }
+ }
Load::Pressure { set, magnitude } => {
for f in self.r.sets.get(set).map(|s| s.faces.as_slice()).unwrap_or_default() {
if let Some(card) = self.pressure(*sid, *f, *magnitude) {
@@ -756,6 +840,12 @@ impl<'m> Deck<'m> {
}
}
Load::Gravity { acceleration, .. } => entry(Card::new("GRAV").int(*sid).blank().real(1.0).reals(*acceleration), out, &mut first)?,
+ Load::Centrifugal { angular_velocity, axis, .. } => {
+ // RFORCE SID G CID A R1 R2 R3: about the basic origin (G blank), A in
+ // revolutions per time unit along the direction R.
+ let a = angular_velocity / (2.0 * std::f64::consts::PI);
+ entry(Card::new("RFORCE").int(*sid).blank().blank().real(a).reals(*axis), out, &mut first)?
+ }
Load::BodyForce { .. } => {}
}
}
diff --git a/crates/decks/tests/planar.rs b/crates/decks/tests/planar.rs
new file mode 100644
index 0000000..01bd09f
--- /dev/null
+++ b/crates/decks/tests/planar.rs
@@ -0,0 +1,157 @@
+//! 2D models (plane stress, plane strain, axisymmetric) in every deck format: element names,
+//! section data, edge surfaces and pressures, centrifugal loads, and what a format cannot write.
+
+use modelcraft_decks::{Error, abaqus, edge_pressure_loads, lsdyna, nastran};
+use modelcraft_model::{
+ BoundaryCondition, Elastic, ElementEdge, ElementKind, Formulation, Integration, Load, Material, Model, Procedure, Section, SectionKind, Set,
+ Step, Target, UnitSystem,
+};
+
+/// Two quad4 over [1, 3] × [0, 1] (or the given kind on the same corners), the left edge held,
+/// a pressure on the right edge and a centrifugal load.
+fn plate(kind: SectionKind, element: ElementKind, formulation: Formulation) -> Model {
+ let mut m = Model::default();
+ m.set_units(UnitSystem::MmTonneS);
+ for (i, x) in [1.0, 2.0, 3.0].iter().enumerate() {
+ m.set_node(i as u32 + 1, [*x, 0.0, 0.0]).unwrap();
+ m.set_node(i as u32 + 4, [*x, 1.0, 0.0]).unwrap();
+ }
+ match element {
+ ElementKind::Tri3 => {
+ for (l, n) in [(1, [1, 2, 5]), (2, [1, 5, 4]), (3, [2, 3, 6]), (4, [2, 6, 5])] {
+ m.set_element(l, ElementKind::Tri3, n.to_vec()).unwrap();
+ }
+ }
+ _ => {
+ m.set_element(1, ElementKind::Quad4, vec![1, 2, 5, 4]).unwrap();
+ m.set_element(2, ElementKind::Quad4, vec![2, 3, 6, 5]).unwrap();
+ }
+ }
+ let right = if element == ElementKind::Tri3 { ElementEdge { element: 3, edge: 2 } } else { ElementEdge { element: 2, edge: 2 } };
+ let all: Vec = m.elements().keys().copied().collect();
+ m.add_set("all", Set { elements: all, ..Set::default() }).unwrap();
+ m.add_set("left", Set { nodes: vec![1, 4], ..Set::default() }).unwrap();
+ m.add_set("right", Set { edges: vec![right], ..Set::default() }).unwrap();
+ m.add_material("Steel", Material { elastic: Some(Elastic::Isotropic { young: 210_000.0, poisson: 0.3 }), density: Some(7.85e-9), fluid: None })
+ .unwrap();
+ m.add_section("plate", Section { set: "all".into(), material: "Steel".into(), kind, formulation }).unwrap();
+ m.add_boundary_condition("hold", BoundaryCondition::Fixed { target: Target::Set("left".into()) }).unwrap();
+ m.add_load("press", Load::Pressure { set: "right".into(), magnitude: 5.0 }).unwrap();
+ let axis = if kind == (SectionKind::Axisymmetric {}) { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] };
+ m.add_load("spin", Load::Centrifugal { angular_velocity: 100.0, point: [0.0; 3], axis, set: None }).unwrap();
+ let mut s = Step::new("Load", Procedure::Static {});
+ s.boundary_conditions.insert("hold".into());
+ s.loads.extend(["press".to_string(), "spin".to_string()]);
+ m.add_step(s, None).unwrap();
+ m
+}
+
+fn unsupported(r: Result) -> String {
+ match r {
+ Err(Error::Unsupported { what, .. }) => what,
+ other => panic!("expected unsupported, got {other:?}"),
+ }
+}
+
+#[test]
+fn abaqus_writes_plane_and_axisymmetric_elements() {
+ let full = Formulation::default();
+ let im = Formulation { incompatible_modes: true, ..Formulation::default() };
+ let red = Formulation { integration: Integration::Reduced, ..Formulation::default() };
+ for (kind, f, want) in [
+ (SectionKind::PlaneStress { thickness: 2.0 }, full, "CPS4"),
+ (SectionKind::PlaneStress { thickness: 2.0 }, im, "CPS4I"),
+ (SectionKind::PlaneStrain { thickness: 1.0 }, red, "CPE4R"),
+ (SectionKind::Axisymmetric {}, im, "CAX4I"),
+ ] {
+ let deck = abaqus::write(&plate(kind, ElementKind::Quad4, f)).unwrap();
+ assert!(deck.contains(&format!("*ELEMENT, TYPE={want}\n")), "{want}\n{deck}");
+ // The right edge is surface S2 of element 2; the pressure is a surface load on it.
+ assert!(deck.contains("*SURFACE, TYPE=ELEMENT, NAME=right\n2, S2\n"), "{deck}");
+ assert!(deck.contains("right, P, 5.0\n"), "{deck}");
+ assert!(deck.contains("all, CENTRIF, 10000.0, 0.0, 0.0, 0.0, "), "{deck}");
+ let section = match kind {
+ SectionKind::Axisymmetric {} => "*SOLID SECTION, ELSET=all, MATERIAL=Steel\n,\n",
+ SectionKind::PlaneStress { .. } => "*SOLID SECTION, ELSET=all, MATERIAL=Steel\n2.0\n",
+ _ => "*SOLID SECTION, ELSET=all, MATERIAL=Steel\n1.0\n",
+ };
+ assert!(deck.contains(section), "{deck}");
+ }
+ let deck = abaqus::write(&plate(SectionKind::PlaneStrain { thickness: 1.0 }, ElementKind::Tri3, full)).unwrap();
+ assert!(deck.contains("*ELEMENT, TYPE=CPE3\n") && deck.contains("3, S2\n"), "{deck}");
+ // Axisymmetric body forces are radial and axial.
+ let mut m = plate(SectionKind::Axisymmetric {}, ElementKind::Quad4, full);
+ m.add_load("swell", Load::BodyForce { set: "all".into(), vector: [1.0, 2.0, 0.0] }).unwrap();
+ m.set_load_active("Load", "swell", true).unwrap();
+ let deck = abaqus::write(&m).unwrap();
+ assert!(deck.contains("all, BR, 1.0\nall, BZ, 2.0\n"), "{deck}");
+ // Abaqus has no reduced six-node triangle.
+ let mut m = plate(SectionKind::PlaneStress { thickness: 1.0 }, ElementKind::Quad4, red);
+ for (l, n) in [(10, 10), (11, 11), (12, 12), (13, 13), (14, 14), (15, 15)] {
+ m.set_node(l, [1.0 + f64::from(n - 10) * 0.1, 2.0 + f64::from(n % 2), 0.0]).unwrap();
+ }
+ m.set_element(9, ElementKind::Tri6, vec![10, 12, 14, 11, 13, 15]).unwrap();
+ m.replace_set("all", Set { elements: vec![1, 2, 9], ..Set::default() }).unwrap();
+ assert!(abaqus::write(&m).is_err());
+}
+
+#[test]
+fn nastran_writes_plane_stress_membranes_and_refuses_the_rest() {
+ let m = plate(SectionKind::PlaneStress { thickness: 2.0 }, ElementKind::Quad4, Formulation::default());
+ let deck = nastran::write(&m).unwrap();
+ // A membrane: the material on MID1 only.
+ assert!(deck.contains("PSHELL* 1 1 2.\n$"), "{deck}");
+ assert!(deck.contains("CQUAD4 1 1 1 2 5 4"), "{deck}");
+ // Every grid holds its out-of-plane degrees of freedom (PS on the continuation).
+ let grids = deck.lines().filter(|l| l.starts_with("GRID")).count();
+ assert_eq!(grids, 6);
+ assert_eq!(deck.lines().filter(|l| l.starts_with('*') && l.trim_end().ends_with("3456")).count(), grids, "{deck}");
+ // The edge pressure as consistent forces, pushing in (−X on the right edge).
+ let forces: Vec<&str> = deck.lines().filter(|l| l.starts_with("FORCE")).collect();
+ assert_eq!(forces.len(), 2, "{deck}");
+ assert!(deck.contains("RFORCE"), "{deck}");
+ for kind in [SectionKind::PlaneStrain { thickness: 1.0 }, SectionKind::Axisymmetric {}] {
+ let why = unsupported(nastran::write(&plate(kind, ElementKind::Quad4, Formulation::default())));
+ assert!(why.contains("differ between dialects"), "{why}");
+ }
+ let mut m = plate(SectionKind::PlaneStress { thickness: 2.0 }, ElementKind::Quad4, Formulation::default());
+ m.replace_load("spin", Load::Centrifugal { angular_velocity: 1.0, point: [5.0, 0.0, 0.0], axis: [0.0, 0.0, 1.0], set: None }).unwrap();
+ assert!(unsupported(nastran::write(&m)).contains("misses the origin"));
+}
+
+#[test]
+fn lsdyna_writes_one_point_plane_forms_and_refuses_the_rest() {
+ let red = Formulation { integration: Integration::Reduced, ..Formulation::default() };
+ let mut m = plate(SectionKind::PlaneStress { thickness: 2.0 }, ElementKind::Quad4, red);
+ m.remove_step("Load").unwrap();
+ let mut s = Step::new("Load", Procedure::Static {});
+ s.boundary_conditions.insert("hold".into());
+ s.loads.insert("press".into());
+ m.add_step(s, None).unwrap();
+ let deck = lsdyna::write(&m).unwrap().text;
+ assert!(deck.contains("*SECTION_SHELL") && deck.contains("*HOURGLASS"), "{deck}");
+ assert!(deck.contains("*LOAD_NODE_POINT") && !deck.contains("*LOAD_SEGMENT"), "{deck}");
+ let why = |m: &Model| match lsdyna::write(m) {
+ Err(Error::Unsupported { what, .. }) => what,
+ other => panic!("{other:?}"),
+ };
+ assert!(why(&plate(SectionKind::PlaneStress { thickness: 2.0 }, ElementKind::Quad4, Formulation::default())).contains("one point"));
+ assert!(why(&plate(SectionKind::Axisymmetric {}, ElementKind::Tri3, red)).contains("axisymmetric"));
+ assert!(why(&plate(SectionKind::PlaneStrain { thickness: 2.0 }, ElementKind::Tri3, red)).contains("per unit thickness"));
+ assert!(why(&plate(SectionKind::PlaneStrain { thickness: 1.0 }, ElementKind::Tri3, red)).contains("centrifugal"));
+}
+
+#[test]
+fn edge_pressures_are_the_consistent_nodal_forces() {
+ // Pressure 5 on the right edge (x = 3, length 1): plane stress t = 2 gives −5 per node in X;
+ // axisymmetric, the ring 2π · 3 · 1 shares 5 · 6π equally on a straight edge at fixed r.
+ let m = plate(SectionKind::PlaneStress { thickness: 2.0 }, ElementKind::Quad4, Formulation::default());
+ let r = m.resolve().unwrap();
+ let f = edge_pressure_loads(&m, &r, ["press"]).unwrap();
+ assert_eq!(f.len(), 2);
+ assert!(f.values().all(|v| (v + 5.0).abs() < 1e-12), "{f:?}");
+ let m = plate(SectionKind::Axisymmetric {}, ElementKind::Quad4, Formulation::default());
+ let r = m.resolve().unwrap();
+ let total: f64 = edge_pressure_loads(&m, &r, ["press"]).unwrap().values().sum();
+ assert!((total + 5.0 * 6.0 * std::f64::consts::PI).abs() < 1e-10, "{total}");
+}
diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs
index c9fb222..b71ede9 100644
--- a/crates/engine/src/catalogue.rs
+++ b/crates/engine/src/catalogue.rs
@@ -168,6 +168,13 @@ pub static CATALOGUE: &[Capability] = &[
),
soon("mesh.order.quadratic", "Quadratic", Mesh, "Tet", "M2", "Second-order elements with mid-side nodes on the geometry"),
ready("mesh.delaunay.points", "Delaunay Points", Mesh, "Tet", "Delaunay tetrahedralization of a point list (debugging aid)"),
+ ready(
+ "mesh.planar",
+ "2D Mesh",
+ Mesh,
+ "2D",
+ "Quad or triangle mesh of a planar face or polygon in the XY plane for plane stress, plane strain and axisymmetric models; edges tagged",
+ ),
soon("mesh.quad.faces", "Quad Faces", Mesh, "Structured Hex", "M5", "All-quad meshes of faces on shared edge nodes"),
soon("mesh.hex.mapped", "Mapped Hex", Mesh, "Structured Hex", "M5", "Mapped hex mesh of six-sided regions"),
soon("mesh.hex.sweep", "Sweep Hex", Mesh, "Structured Hex", "M5", "Hex mesh swept along extrusions and revolutions"),
@@ -213,7 +220,7 @@ pub static CATALOGUE: &[Capability] = &[
"Section",
Properties,
"Sections",
- "Solid, shell, beam or rod section assigned to a set, with its element formulation",
+ "Solid, shell, beam, rod or 2D (plane stress, plane strain, axisymmetric) section assigned to a set, with its element formulation",
&["model.section.edit", "model.section.delete"],
),
soon("model.section.composite", "Composite", Properties, "Sections", "M8", "Composite layups and continuum shells"),
diff --git a/crates/engine/src/cmd/define.rs b/crates/engine/src/cmd/define.rs
index 8541962..4c6d09c 100644
--- a/crates/engine/src/cmd/define.rs
+++ b/crates/engine/src/cmd/define.rs
@@ -66,7 +66,7 @@ definition_commands!(
Set,
"set",
"Set",
- r#"{"name": "root", "geometry": [{"body": "Body1", "face": "F2:top"}, {"body": "Body1", "edge": "…"}, {"body": "Body1", "vertex": "…"}, {"body": "Body1"}], "nodes": [labels], "elements": [labels], "faces": [{"element": 1, "face": 3}]} — any of the member lists; geometry members resolve against the mesh's geometry tags after every remesh (a face name also selects its split pieces `name#0`, `name#1`, …)"#,
+ r#"{"name": "root", "geometry": [{"body": "Body1", "face": "F2:top"}, {"body": "Body1", "edge": "…"}, {"body": "Body1", "vertex": "…"}, {"body": "Body1"}], "nodes": [labels], "elements": [labels], "faces": [{"element": 1, "face": 3}], "edges": [{"element": 1, "edge": 2}]} — any of the member lists (element edges: where a 2D model's surface loads act); geometry members resolve against the mesh's geometry tags after every remesh (a face name also selects its split pieces `name#0`, `name#1`, …)"#,
add_set,
replace_set,
remove_set,
@@ -94,7 +94,7 @@ definition_commands!(
Section,
"section",
"Section",
- r#"{"name": "web", "set": "all", "material": "Steel", "kind": {"type": "solid"} | {"type": "shell", "thickness": 2, "offset": 0 (-0.5..=0.5 of the thickness), "points": 5} | {"type": "beam", "profile": {"type": "rectangle", "width", "height"} | {"type": "circle", "radius"} | {"type": "pipe", "radius", "thickness"} | {"type": "i", "height", "width", "web", "flange"} | {"type": "box", "width", "height", "thickness"}, "orientation": [x, y, z]} | {"type": "rod", "area": 10}, "formulation": {"integration": "full|reduced", "incompatible_modes": false, "hybrid": false}}"#,
+ r#"{"name": "web", "set": "all", "material": "Steel", "kind": {"type": "solid"} | {"type": "shell", "thickness": 2, "offset": 0 (-0.5..=0.5 of the thickness), "points": 5} | {"type": "beam", "profile": {"type": "rectangle", "width", "height"} | {"type": "circle", "radius"} | {"type": "pipe", "radius", "thickness"} | {"type": "i", "height", "width", "web", "flange"} | {"type": "box", "width", "height", "thickness"}, "orientation": [x, y, z]} | {"type": "rod", "area": 10} | {"type": "plane_stress", "thickness": 1} | {"type": "plane_strain", "thickness": 1 (default)} | {"type": "axisymmetric"} (2D: elements in the XY plane; axisymmetric about Y with x the radius, forces totals over the circumference), "formulation": {"integration": "full|reduced", "incompatible_modes": false, "hybrid": false}}"#,
add_section,
replace_section,
remove_section,
@@ -150,7 +150,7 @@ definition_commands!(
Load,
"load",
"Load",
- r#"{"name": "push", "type": "force|moment", "target": {"set": "…"} | {"reference_point": "…"}, "vector": [x, y, z], "distribution": "total" (default: the vector is the total, split by area over the set's faces, or equally over its nodes) | "per_node" (at every node: depends on the mesh)} | {"name", "type": "traction", "set", "vector" (per area, on the set's faces)} | {"name", "type": "pressure", "set", "magnitude" (positive pushes in)} | {"name", "type": "gravity", "acceleration": [x, y, z], "set" (optional, default the whole model)} | {"name", "type": "body_force", "set", "vector" (per volume)} — active in the steps that list it (model.step.activate)"#,
+ r#"{"name": "push", "type": "force|moment", "target": {"set": "…"} | {"reference_point": "…"}, "vector": [x, y, z], "distribution": "total" (default: the vector is the total, split by area over the set's faces, or equally over its nodes) | "per_node" (at every node: depends on the mesh)} | {"name", "type": "traction", "set", "vector" (per area, on the set's faces)} | {"name", "type": "pressure", "set", "magnitude" (positive pushes in)} | {"name", "type": "gravity", "acceleration": [x, y, z], "set" (optional, default the whole model)} | {"name", "type": "body_force", "set", "vector" (per volume)} | {"name", "type": "centrifugal", "angular_velocity" (rad per time unit), "point": [x, y, z] (on the axis), "axis": [x, y, z], "set" (optional)} — 2D models: in-plane vectors, surface loads on element edges; active in the steps that list it (model.step.activate)"#,
add_load,
replace_load,
remove_load,
diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs
index 8235a60..2bd8ef0 100644
--- a/crates/engine/src/cmd/mesh.rs
+++ b/crates/engine/src/cmd/mesh.rs
@@ -1,4 +1,5 @@
use std::borrow::Cow;
+use std::collections::BTreeMap;
use modelcraft_geometry::SurfaceMesh;
use modelcraft_mesh::cdt::{CdtOptions, tetrahedralize};
@@ -87,6 +88,17 @@ pub const TET_GENERATE: CommandSpec = CommandSpec {
run: tet_generate,
};
+/// The first node and element labels of a mesh of `nodes` nodes and `elements` elements added
+/// after the model's largest labels, or an error when the labels would pass [`MAX_LABEL`].
+fn next_labels(s: &Session, nodes: usize, elements: usize) -> Result<(u32, u32)> {
+ let next = |last: Option| last.map_or(Some(1), |l| l.checked_add(1));
+ let fits = |first: Option, count: usize| first.filter(|&f| u64::from(f) + count as u64 - 1 <= u64::from(MAX_LABEL));
+ match (fits(next(s.model.nodes().keys().next_back().copied()), nodes), fits(next(s.model.elements().keys().next_back().copied()), elements)) {
+ (Some(n), Some(e)) => Ok((n, e)),
+ _ => Err(EngineError::Params(format!("the mesh needs labels beyond {MAX_LABEL}"))),
+ }
+}
+
/// The most Steiner points and tetrahedra `mesh.tet.generate` may be asked for.
const TET_MAX_STEINER: u64 = 10_000_000;
const TET_MAX_TETS: u64 = 50_000_000;
@@ -127,13 +139,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result {
let cdt = tetrahedralize(&surface, &cdt_options)?;
let gate = cdt.gate(surface.volume())?.into_result()?;
- let next = |last: Option| last.map_or(Some(1), |l| l.checked_add(1));
- let first_node = next(s.model.nodes().keys().next_back().copied());
- let first_element = next(s.model.elements().keys().next_back().copied());
- let fits = |first: Option, count: usize| first.filter(|&f| u64::from(f) + count as u64 - 1 <= u64::from(MAX_LABEL));
- let (Some(first_node), Some(first_element)) = (fits(first_node, cdt.points.len()), fits(first_element, cdt.tets.len())) else {
- return Err(EngineError::Params(format!("the mesh needs labels beyond {MAX_LABEL}")));
- };
+ let (first_node, first_element) = next_labels(s, cdt.points.len(), cdt.tets.len())?;
for (i, xyz) in cdt.points.iter().enumerate() {
s.model.set_node(first_node + i as u32, *xyz)?;
}
@@ -175,6 +181,143 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result {
}))
}
+pub const PLANAR: CommandSpec = CommandSpec {
+ id: "mesh.planar",
+ label: "2D Mesh",
+ params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#,
+ kind: CommandKind::Model,
+ run: planar,
+};
+
+/// Most points of a `mesh.planar` polygon or hole, and most holes.
+const PLANAR_MAX_POINTS: usize = 100_000;
+const PLANAR_MAX_HOLES: usize = 10_000;
+const PLANAR_MAX_ELEMENTS: u64 = 4_000_000;
+
+/// A list of `[x, y]` points.
+fn polygon(v: &Value, what: &str) -> Result> {
+ let bad = || EngineError::Params(format!("`{what}` must be a list of 3 to {PLANAR_MAX_POINTS} [x, y] points"));
+ let raw = v.as_array().filter(|a| (3..=PLANAR_MAX_POINTS).contains(&a.len())).ok_or_else(bad)?;
+ raw.iter()
+ .map(|p| match p.as_array().map(Vec::as_slice) {
+ Some([x, y]) => x.as_f64().zip(y.as_f64()).map(|(x, y)| [x, y]).filter(|c| c.iter().all(|v| v.is_finite())),
+ _ => None,
+ })
+ .collect::