2D elements: plane stress, plane strain and axisymmetric sections on the DOF framework; mesh.planar (M4) (#39)

Model: planar section kinds with Extent, element edges in sets, edge-weighted 2D loads,
centrifugal load, 2D model checks. fem: Plane kernels on Basis::{Tri3,Tri6,Quad4,Quad8}
declaring ElementDofs::new(DofSet::PLANE); QM6 incompatible modes with the
Wilson-Ibrahimbegovic correction. Solver: Kernel enum on the DofLayout; 3D results
bit-identical (hash-checked). Decks: Abaqus CPS/CPE/CAX, Nastran membranes and RFORCE,
LS-DYNA ELFORM 12/13. Mesh: free triangle mesher, planar region meshing, face boundaries.
Engine: mesh.planar with tagged edges. Analytic verification suite in solver/tests/plane.rs.


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 20:24:56 -04:00
1 parent 4e4d19b88c
commit 4c0fcc80ad
42 files changed
+3270 -128

No files matched your search

+88 -10
View File
@@ -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 (&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()),
@@ -209,7 +240,7 @@ fn check_expressible(model: &Model) -> Result<()> {
}
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 {
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<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 taken: BTreeSet<String> =
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<Centrifugal<'a>>,
/// 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<u32>)) -> 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<u32>) = if whole_model_gravity {
(model.sections().values().map(|s| s.set.as_str()).collect(), model.elements().keys().copied().collect())
} else {
+24
View File
@@ -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<Item = &'a str>) -> Result<BTreeMap<(u32, u8), f64>> {
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)
}
+15 -1
View File
@@ -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<Elform, String> {
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"),
}
}
}
}
+30 -1
View File
@@ -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<Plan<'a>> {
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<Plan<'a>> {
}
}
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"]);
+97 -7
View File
@@ -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<f64>, mcid: Option<u32> },
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<f64>,
mcid: Option<u32>,
},
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<u32>,
},
}
/// 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<u32, [f64; 3]> = 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 { .. } => {}
}
}