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 <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 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 { .. } => {}
}
}
+157
View File
@@ -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<u32> = 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, Error>) -> 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}");
}
+8 -1
View File
@@ -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"),
+3 -3
View File
@@ -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,
+226 -7
View File
@@ -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<u32>| last.map_or(Some(1), |l| l.checked_add(1));
let fits = |first: Option<u32>, 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<Value> {
let cdt = tetrahedralize(&surface, &cdt_options)?;
let gate = cdt.gate(surface.volume())?.into_result()?;
let next = |last: Option<u32>| 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<u32>, 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<Value> {
}))
}
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<Vec<[f64; 2]>> {
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::<Option<Vec<_>>>()
.ok_or_else(bad)
}
fn planar(s: &mut Session, p: &Value) -> Result<Value> {
use modelcraft_mesh::planar::{Curve, face_boundary, mesh_region};
use modelcraft_model::Entity;
let size = p
.get("size")
.and_then(Value::as_f64)
.filter(|v| *v > 0.0 && v.is_finite())
.ok_or_else(|| EngineError::Params("`size` must be a positive number (the element size)".into()))?;
let kind = match p.get("kind") {
None => ElementKind::Quad4,
Some(k) => k
.as_str()
.and_then(ElementKind::from_name)
.filter(|k| k.dimension() == 2)
.ok_or_else(|| EngineError::Params("`kind` must be quad4, quad8, tri3 or tri6".into()))?,
};
let opts = modelcraft_mesh::quad::QuadOptions {
max_elements: optional_count(p, "max_elements", 1_000_000, 1..=PLANAR_MAX_ELEMENTS)?,
..Default::default()
};
// The region's loops of curves, the body name, the face its elements fill, and each curve
// id's edge name.
let (loops, body, face, names): (Vec<Vec<Curve>>, String, String, BTreeMap<u32, String>) = match (p.get("polygon"), p.get("body")) {
(Some(outer), None) => {
let mut rings = vec![polygon(outer, "polygon")?];
if let Some(h) = p.get("holes") {
let list = h
.as_array()
.filter(|a| a.len() <= PLANAR_MAX_HOLES)
.ok_or_else(|| EngineError::Params(format!("`holes` must be a list of at most {PLANAR_MAX_HOLES} polygons")))?;
for hole in list {
rings.push(polygon(hole, "holes")?);
}
}
let name = match p.get("name") {
None => "Plane".to_string(),
Some(n) => n.as_str().map(str::to_string).ok_or_else(|| EngineError::Params("`name` must be a body name".into()))?,
};
GeometryRef::body(name.clone())?;
let mut id = 0u32;
let mut names = BTreeMap::new();
let loops = rings
.into_iter()
.map(|ring| {
ring.into_iter()
.map(|pt| {
id += 1;
names.insert(id, format!("e{id}"));
Curve { id: Some(id), points: vec![pt] }
})
.collect()
})
.collect();
(loops, name, "area".to_string(), names)
}
(None, Some(_)) => {
let face_name = p.get("face").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`face` must be a face name".into()))?;
let (shape, mesh) = super::geometry::body_mesh(s, p)?;
let face = shape.face_index(face_name).ok_or_else(|| EngineError::Params(format!("body `{}` has no face `{face_name}`", shape.name)))?;
let b = face_boundary(&mesh.positions, &mesh.triangles, &mesh.tri_face, face, mesh.weld_distance)?;
let names = shape.faces().iter().enumerate().map(|(i, f)| (i as u32, f.name.clone())).collect();
(b.loops, shape.name.clone(), face_name.to_string(), names)
}
_ => return Err(EngineError::Params("give either `polygon` (and `holes`) or `body` with `face` and `tolerance`".into())),
};
let m = mesh_region(&loops, kind, size, &opts)?;
let (first_node, first_element) = next_labels(s, m.nodes.len(), m.elements.len())?;
for (i, xy) in m.nodes.iter().enumerate() {
s.model.set_node(first_node + i as u32, [xy[0], xy[1], 0.0])?;
}
let region = s.model.geometry_tag(&GeometryRef::face(body.clone(), face.clone())?)?;
for (i, nodes) in m.elements.iter().enumerate() {
let label = first_element + i as u32;
s.model.set_element(label, kind, nodes.iter().map(|v| first_node + v).collect())?;
s.model.tag_element(label, region)?;
}
let mut tags: BTreeMap<u32, u32> = BTreeMap::new();
for (e, edge, id) in &m.edges {
let Some(name) = names.get(id) else { continue };
let tag = match tags.get(id) {
Some(t) => *t,
None => {
let t = s.model.geometry_tag(&GeometryRef::new(body.clone(), Entity::Edge(name.clone()))?)?;
tags.insert(*id, t);
t
}
};
s.model.tag_element_edge(first_element + *e as u32, *edge, tag)?;
}
let polygon_input = p.get("polygon").is_some();
if polygon_input {
for (node, id) in &m.corners {
let t = s.model.geometry_tag(&GeometryRef::new(body.clone(), Entity::Vertex(format!("v{id}")))?)?;
s.model.tag_node(first_node + node, t)?;
}
}
let edges: Vec<&String> = tags.keys().filter_map(|id| names.get(id)).collect();
Ok(json!({
"body": body,
"face": face,
"kind": kind.name(),
"nodes": m.nodes.len(),
"elements": m.elements.len(),
"first_node": first_node,
"first_element": first_element,
"edges": edges,
}))
}
fn node_add(s: &mut Session, p: &Value) -> Result<Value> {
let l = label(p, "label")?;
s.model.set_node(l, xyz(p, "xyz")?)?;
@@ -527,4 +670,80 @@ mod tests {
assert!(s.execute("mesh.tet.generate", &json!({"surface": 1})).is_err(), "an open surface bounds no volume");
assert_eq!(s.model.elements().len(), 1);
}
#[test]
fn planar_meshes_a_polygon_that_solves_as_a_2d_model() {
let mut s = Session::default();
let hole: Vec<[f64; 2]> = (0..12).map(|k| std::f64::consts::TAU * k as f64 / 12.0).map(|t| [5.0 + t.cos(), 2.0 + t.sin()]).collect();
let r = s
.execute(
"mesh.planar",
&json!({"polygon": [[0, 0], [10, 0], [10, 4], [0, 4]], "holes": [hole], "size": 0.5, "kind": "quad8", "name": "Strip"}),
)
.unwrap();
assert_eq!(r["kind"], "quad8");
let elements = r["elements"].as_u64().unwrap() as usize;
assert!(elements > 20, "{r}");
assert!(s.model.elements().values().all(|e| e.kind == modelcraft_model::ElementKind::Quad8));
assert!(s.model.nodes().values().all(|n| n.xyz[2] == 0.0));
// Sets on the tagged edges carry the boundary condition and the load.
for (cmd, params) in [
("model.set.create", json!({"name": "all", "geometry": [{"body": "Strip", "face": "area"}]})),
("model.set.create", json!({"name": "left", "geometry": [{"body": "Strip", "edge": "e4"}]})),
("model.set.create", json!({"name": "right", "geometry": [{"body": "Strip", "edge": "e2"}]})),
("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}})),
("model.section.create", json!({"name": "plate", "set": "all", "material": "Steel", "kind": {"type": "plane_stress", "thickness": 2.0}})),
("model.boundary_condition.create", json!({"name": "hold", "type": "fixed", "target": {"set": "left"}})),
("model.load.create", json!({"name": "pull", "type": "traction", "set": "right", "vector": [10.0, 0.0, 0.0]})),
("model.step.create", json!({"name": "Pull", "procedure": {"type": "static"}, "boundary_conditions": ["hold"], "loads": ["pull"]})),
] {
s.execute(cmd, &params).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
let r = s.execute("solve.static", &json!({})).unwrap();
let total: f64 = r["reaction_total"][0].as_f64().unwrap();
// The traction over the right edge: 10 × 4 × 2.
assert!((total + 80.0).abs() < 1e-9, "{r}");
// A second mesh appends; failures change nothing.
let journal = s.journal.len();
let model = s.model.clone();
for bad in [
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 1e-9}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0.1, "kind": "hex8"}),
json!({"polygon": [[0, 0], [1, 0]], "size": 0.1}),
json!({"polygon": [[0, 0], [1, 0], [1, 1], [0, 1]], "holes": [[[2, 2], [3, 2], [3, 3]]], "size": 0.1}),
json!({"polygon": [[0, 0], [1, 1], [1, 0], [0, 1]], "size": 0.1}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0.1, "max_elements": 0}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0.1, "name": ""}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 0.1, "body": "Body1", "face": "x"}),
json!({"body": "Body1", "face": "x", "tolerance": 0.1, "size": 1}),
json!({"size": 1}),
] {
assert!(s.execute("mesh.planar", &bad).is_err(), "{bad}");
}
assert_eq!((s.journal.len(), &s.model), (journal, &model), "failures change nothing");
}
#[test]
fn planar_meshes_a_flat_face_of_a_design_with_edges_named_by_their_neighbours() {
use std::sync::Arc;
use modelcraft_geometry::open_design;
use modelcraft_geometry::sample::{Plate, plate_design};
let design = open_design(&plate_design(&Plate::default()).unwrap()).unwrap();
let names: Vec<String> = design.bodies[0].faces().iter().map(|f| f.name.clone()).collect();
let mut s = Session { design: Some(Arc::new(design)), ..Session::default() };
let mut meshed = 0;
for face in &names {
let Ok(r) = s.execute("mesh.planar", &json!({"body": "Body1", "face": face, "tolerance": 0.2, "size": 4.0, "kind": "tri6"})) else {
continue;
};
meshed += 1;
let edges: Vec<&str> = r["edges"].as_array().unwrap().iter().map(|e| e.as_str().unwrap()).collect();
// The plate's top and bottom faces border its four sides and the hole.
assert!(edges.len() >= 5 && edges.iter().all(|e| names.iter().any(|n| n == e)), "{face}: {edges:?}");
}
assert_eq!(meshed, 2, "the top and bottom faces are flat and parallel to XY; the sides and hole are not");
}
}
+1
View File
@@ -30,6 +30,7 @@ pub static ALL: &[CommandSpec] = &[
mesh::QUALITY,
mesh::DELAUNAY_POINTS,
mesh::SURFACE_CHECK,
mesh::PLANAR,
mesh::TET_GENERATE,
tags::TAG,
tags::TAG_CLEAR,
+13
View File
@@ -330,6 +330,13 @@ mod tests {
json!({"surface": -1}),
json!({"surface": 1e300}),
json!({"surface": "0", "weld": -1, "feature_angle": 1e300}),
json!({"polygon": vec![[0.0, 0.0]; 200_000], "size": 1}),
json!({"polygon": [[0, 0], [1e308, 0], [0, 1e308]], "size": 1e-300}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "holes": vec![[[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]]; 20_000], "size": 0.5}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "holes": [[[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]], [[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]]], "size": 0.2}),
json!({"polygon": [["a", 0], [1, null], {}], "size": "big", "kind": 7}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 1e300, "kind": "tri6", "max_elements": 1e300}),
json!({"body": "Body1", "face": "", "tolerance": 0.05, "size": 1}),
// Model definitions.
json!({"name": "x".repeat(100_000)}),
json!({"name": "A\u{0}"}),
@@ -345,6 +352,12 @@ mod tests {
json!({"name": "A", "set": "all", "material": "Steel", "kind": {"type": "beam", "profile": {"type": "pipe", "radius": 1, "thickness": 2}, "orientation": [0, 0, 0]}}),
json!({"name": "A", "set": "all", "material": "Steel", "kind": {"type": "shell", "thickness": 1e308, "offset": -0.5, "points": 4294967295u64}}),
json!({"name": "A", "xyz": [1e308, 0, 0]}),
json!({"name": "A", "set": "all", "material": "Steel", "kind": {"type": "plane_stress", "thickness": -1}}),
json!({"name": "A", "set": "all", "material": "Steel", "kind": {"type": "plane_strain", "thickness": 1e308}}),
json!({"name": "A", "set": "all", "material": "Steel", "kind": {"type": "axisymmetric", "axis": "x"}}),
json!({"name": "A", "type": "centrifugal", "angular_velocity": 1e300, "point": [0, 0, 0], "axis": [0, 1, 0]}),
json!({"name": "A", "type": "centrifugal", "angular_velocity": 1, "point": [0, 0, 0], "axis": [0, 0, 0]}),
json!({"name": "A", "edges": [{"element": u32::MAX, "edge": 0}]}),
json!({"name": "A", "reference_point": "RP", "set": "end", "kind": "rigid", "dofs": [0, 7]}),
json!({"name": "A", "type": "fixed", "target": {"set": "nope"}}),
json!({"name": "A", "type": "displacement", "target": {"reference_point": "RP"}, "values": [null, null, null, null, null, null]}),
+11
View File
@@ -368,6 +368,17 @@ impl System {
}
});
}
Load::Centrifugal { angular_velocity, point, axis, set } => {
let chosen: Option<&[u32]> = set.as_ref().map(|s| resolved.sets.get(s).map(|r| r.elements.as_slice()).unwrap_or_default());
let xyz = &self.xyz;
self.each_element(chosen, |nodes, weights, rho| {
for (node, w) in nodes.iter().zip(weights) {
let x = xyz.get(*node).copied().unwrap_or_default();
let a = modelcraft_model::centrifugal_acceleration(*angular_velocity, *point, *axis, x);
add_at(&mut f, *node, &a.map(|c| rho * w * c));
}
});
}
}
}
self.external = f;
+33 -10
View File
@@ -34,6 +34,7 @@
use modelcraft_model::ElementKind;
use crate::basis::Basis;
use crate::dense::{Cholesky, Matrix, inverse3, positive_inverse};
use crate::dof::{DofSet, ElementDofs, Internal};
use crate::material::{Elasticity, stress};
@@ -47,6 +48,9 @@ pub enum Family {
Tetrahedron,
Wedge,
Hexahedron,
/// The 2D elements of plane and axisymmetric models ([`crate::plane`]).
Triangle,
Quadrilateral,
}
/// One row of the element table: everything the kernels need to know about an element kind.
@@ -386,15 +390,7 @@ impl<'a> Solid<'a> {
/// The lumped (diagonal) mass per node: the consistent diagonal scaled to the element's mass.
pub fn lumped_mass(&self, density: f64) -> Result<Vec<f64>> {
let m = self.mass(density)?;
// The consistent entries sum to the element's mass (the shape functions sum to one).
let total: f64 = m.data.iter().sum();
let diag: Vec<f64> = (0..m.n).map(|i| m.get(i, i)).collect();
let sum: f64 = diag.iter().sum();
if !(sum > 0.0) {
return Err(self.degenerate());
}
Ok(diag.iter().map(|d| d * total / sum).collect())
lump(&self.mass(density)?).ok_or_else(|| self.degenerate())
}
/// Consistent nodal forces of a uniform force per volume: fᵢ = ∫ Nᵢ b dV.
@@ -411,6 +407,22 @@ impl<'a> Solid<'a> {
Ok(f)
}
/// Consistent nodal forces of a force per volume that varies with position (such as a
/// centrifugal load): fᵢ = ∫ Nᵢ b(x) dV.
pub fn body_force_field(&self, b: impl Fn([f64; 3]) -> [f64; 3]) -> Result<Vec<[f64; 3]>> {
let mut f = vec![[0.0; 3]; self.ty.nodes];
for p in self.ty.mass.points() {
let g = self.gradients(p.xi)?;
let b = b(self.position(p.xi));
for (fi, n) in f.iter_mut().zip(g.shape.values()) {
for k in 0..3 {
fi[k] += n * b[k] * g.det * p.weight;
}
}
}
Ok(f)
}
/// Strain and stress at every integration point for the nodal displacements `u`.
pub fn stresses(&self, d: &Elasticity, u: &[[f64; 3]]) -> Result<Vec<PointStress>> {
if u.len() != self.ty.nodes {
@@ -475,7 +487,16 @@ impl<'a> Solid<'a> {
}
/// Make a matrix exactly symmetric (the mean of it and its transpose), so assembly can rely on it.
fn symmetrize(k: &mut Matrix) {
/// HRZ lumping of a consistent mass matrix: its diagonal scaled to the element's mass (the
/// entries sum to it, the shape functions summing to one); `None` when the diagonal has no mass.
pub(crate) fn lump(m: &Matrix) -> Option<Vec<f64>> {
let total: f64 = m.data.iter().sum();
let diag: Vec<f64> = (0..m.n).map(|i| m.get(i, i)).collect();
let sum: f64 = diag.iter().sum();
(sum > 0.0).then(|| diag.iter().map(|d| d * total / sum).collect())
}
pub(crate) fn symmetrize(k: &mut Matrix) {
let n = k.n;
for i in 0..n {
for j in i + 1..n {
@@ -589,6 +610,8 @@ pub fn extrapolation(ty: &ElementType, rule: Rule) -> Result<Extrapolation> {
Family::Tetrahedron => shape::tet4(xi),
Family::Wedge => shape::wedge6(xi),
Family::Hexahedron => shape::hex8(xi),
Family::Triangle => Basis::Tri3.eval(xi),
Family::Quadrilateral => Basis::Quad4.eval(xi),
};
let mut corner_rows = vec![0.0; c * g];
if g < c {
+3
View File
@@ -15,6 +15,8 @@
//! - [`material`]: isotropic and orthotropic elasticity matrices, von Mises stress;
//! - [`element`]: the element table, stiffness (full, reduced, incompatible modes), consistent
//! and lumped mass, body forces, integration-point stresses and their extrapolation to nodes;
//! - [`plane`]: 2D elements (plane stress, plane strain, axisymmetric) on tri3, tri6, quad4 and
//! quad8, with two displacement components per node;
//! - [`face`]: consistent nodal forces of pressure on element faces;
//! - [`uniform`]: uniform-strain (one-point) hexahedron and tetrahedron kinematics, hourglass
//! shape vectors and element frequency bounds for explicit dynamics;
@@ -35,6 +37,7 @@ pub mod element;
pub mod face;
pub mod jet;
pub mod material;
pub mod plane;
pub mod quadrature;
pub mod shape;
pub mod uniform;
+701
View File
@@ -0,0 +1,701 @@
//! 2D continuum elements: plane stress, plane strain and axisymmetric.
//!
//! The elements lie in the XY plane and have two displacement components per node, u (along X)
//! and v (along Y). The strain is the 4-vector (εxx, εyy, εzz, γxy), where the third component is
//! the out-of-plane one:
//! - **plane strain** (a slice of a long body): εzz = 0, and σzz = D₃₁εxx + D₃₂εyy + D₃₄γxy is
//! reported;
//! - **plane stress** (a thin plate loaded in its plane): σzz = 0, the elasticity is condensed to
//! D̃ᵢⱼ = Dᵢⱼ − Dᵢ₃D₃ⱼ/D₃₃ and εzz = −(D₃₁εxx + D₃₂εyy + D₃₄γxy)/D₃₃ is reported;
//! - **axisymmetric** (a body of revolution about the Y axis): x is the radius r, y the axial
//! coordinate, and εzz is the hoop strain εθθ = u/r; volumes are 2πr dA, so forces, reactions
//! and masses are totals over the full circumference.
//!
//! D is the leading 4 × 4 block (xx, yy, zz, xy) of the 3D elasticity, so orthotropic materials
//! with axes along the global axes work as they do in solids; stresses and strains are reported
//! as the solid elements' 6-vectors (xx, yy, zz, xy, xz, yz) with xz = yz = 0 (axisymmetric: rr,
//! axial, hoop, r-axial). Plane elements integrate over their thickness t, so stiffness, mass and
//! loads are those of the whole thickness. (O. C. Zienkiewicz, R. L. Taylor, J. Z. Zhu, *The
//! Finite Element Method: Its Basis and Fundamentals*, 7th ed., Butterworth-Heinemann 2013,
//! ch. 6 (plane stress and strain) and §6.6 (axisymmetric); T. J. R. Hughes, *The Finite Element
//! Method*, Dover 2000, §2.7 and §3.5.)
//!
//! Elements and rules: tri3 (one point), tri6 (three points, degree 2), quad4 (2 × 2) and quad8
//! (3 × 3, or 2 × 2 reduced). Reduced quad4 needs hourglass control and is not offered. Quad4 has
//! an **incompatible modes** formulation (QM6): the internal modes (1 − ξ²) and (1 − η²) in u
//! and v (E. L. Wilson, R. L. Taylor, W. P. Doherty, J. Ghaboussi, *Incompatible displacement
//! models*, 1973), their natural derivatives mapped with the centre Jacobian and scaled by
//! det J₀/det J (R. L. Taylor, P. J. Beresford, E. L. Wilson, *A non-conforming element for
//! stress analysis*, IJNME 10(6):1211–1219, 1976), condensed out of the element. In an
//! axisymmetric element the modes also strain the hoop direction (P/r), and Taylor's scaling no
//! longer makes their strains integrate to zero over the element's volume; their strain
//! operator is therefore shifted by its volume mean, the patch-test correction of E. L. Wilson
//! and A. Ibrahimbegovic (*Use of incompatible displacement modes for the calculation of element
//! stiffnesses or stresses*, Finite Elements in Analysis and Design 7(3):229–241, 1990), which
//! leaves the plane case unchanged (its mean is zero) and makes the axisymmetric element pass the
//! patch test.
//!
//! On the axis (r = 0) the hoop strain u/r is evaluated only at integration points, which lie
//! inside the element (r > 0) for every element with some node off the axis; the solver holds
//! the radial displacement of nodes on the axis at zero, as symmetry requires.
use modelcraft_model::{ElementKind, Extent, SectionKind};
use crate::basis::Basis;
use crate::dense::{Cholesky, Matrix};
use crate::dof::{DofSet, ElementDofs, Internal};
use crate::element::{ElementType, Family, Formulation, PointStress, extrapolation, lump, symmetrize};
use crate::material::Elasticity;
use crate::quadrature::{Point, Rule};
use crate::shape::{MAX_NODES, Shape};
use crate::{Error, Result};
/// Which 2D idealisation an element follows.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Theory {
PlaneStress {
thickness: f64,
},
PlaneStrain {
thickness: f64,
},
/// About the Y axis; x is the radius.
Axisymmetric,
}
/// The 4 × 4 elasticity of a 2D element, in the order (xx, yy, zz, xy).
pub type Elasticity2 = [[f64; 4]; 4];
impl Theory {
/// The theory of a 2D section (`None` for solid, shell, beam and rod sections).
pub fn from_section(kind: &SectionKind) -> Option<Theory> {
match *kind {
SectionKind::PlaneStress { thickness } => Some(Theory::PlaneStress { thickness }),
SectionKind::PlaneStrain { thickness } => Some(Theory::PlaneStrain { thickness }),
SectionKind::Axisymmetric {} => Some(Theory::Axisymmetric),
_ => None,
}
}
/// How the element extends out of its plane (thickness, or revolved).
pub fn extent(&self) -> Extent {
match *self {
Theory::PlaneStress { thickness } | Theory::PlaneStrain { thickness } => Extent::Thickness(thickness),
Theory::Axisymmetric => Extent::Revolved,
}
}
/// The 4 × 4 elasticity of this theory from the 3D one.
pub fn elasticity(&self, d: &Elasticity) -> Result<Elasticity2> {
let mut out = [[0.0; 4]; 4];
for i in 0..4 {
for j in 0..4 {
out[i][j] = d[i][j];
}
}
if let Theory::PlaneStress { .. } = self {
let d33 = d[2][2];
if !(d33 > 0.0) {
return Err(Error::Invalid("the material has no stiffness through the thickness".into()));
}
for i in 0..4 {
for j in 0..4 {
out[i][j] = if i == 2 || j == 2 { 0.0 } else { d[i][j] - d[i][2] * d[2][j] / d33 };
}
}
}
Ok(out)
}
}
/// The 2D element table: chosen by the section kind (a 2D section on tri3, tri6, quad4 or quad8),
/// separately from shells on the same element kinds.
pub static PLANE_TYPES: [ElementType; 4] = [
ElementType {
kind: ElementKind::Tri3,
family: Family::Triangle,
nodes: 3,
corners: 3,
shape: |xi| Basis::Tri3.eval(xi),
full: Rule::TriCentroid,
reduced: None,
mass: Rule::TriConical(3),
incompatible_modes: false,
},
ElementType {
kind: ElementKind::Tri6,
family: Family::Triangle,
nodes: 6,
corners: 3,
shape: |xi| Basis::Tri6.eval(xi),
full: Rule::Tri3,
reduced: None,
mass: Rule::TriConical(4),
incompatible_modes: false,
},
ElementType {
kind: ElementKind::Quad4,
family: Family::Quadrilateral,
nodes: 4,
corners: 4,
shape: |xi| Basis::Quad4.eval(xi),
full: Rule::Quad(2),
reduced: None,
mass: Rule::Quad(2),
incompatible_modes: true,
},
ElementType {
kind: ElementKind::Quad8,
family: Family::Quadrilateral,
nodes: 8,
corners: 4,
shape: |xi| Basis::Quad8.eval(xi),
full: Rule::Quad(3),
reduced: Some(Rule::Quad(2)),
mass: Rule::Quad(3),
incompatible_modes: false,
},
];
/// The 2D table row of an element kind.
pub fn plane_type(kind: ElementKind) -> Result<&'static ElementType> {
PLANE_TYPES
.iter()
.find(|t| t.kind == kind)
.ok_or_else(|| Error::NotSupported(format!("{} elements in a 2D section (2D sections take tri3, tri6, quad4 and quad8)", kind.name())))
}
/// The formulation a 2D section asks for, if this element type has it.
pub fn formulation(kind: ElementKind, f: &modelcraft_model::Formulation) -> Result<Formulation> {
let ty = plane_type(kind)?;
if f.hybrid {
return Err(Error::NotSupported("hybrid (mixed displacement–pressure) 2D elements".into()));
}
if f.incompatible_modes {
return if ty.incompatible_modes {
Ok(Formulation::IncompatibleModes)
} else {
Err(Error::Invalid(format!("incompatible modes apply to quad4 elements, not {}", kind.name())))
};
}
match f.integration {
modelcraft_model::Integration::Full => Ok(Formulation::Full),
modelcraft_model::Integration::Reduced if ty.reduced.is_some() => Ok(Formulation::Reduced),
modelcraft_model::Integration::Reduced => Err(Error::NotSupported(format!(
"reduced integration of 2D {} elements (quad4 needs hourglass control; use full integration or incompatible modes)",
kind.name()
))),
}
}
/// One column of the strain operator: (∂N/∂x, ∂N/∂y, hoop N/r), for a node or an internal mode.
type Column = [f64; 3];
/// What the kernels need at one integration point.
struct At {
shape: Shape,
/// Strain columns of the nodes.
g: [Column; MAX_NODES],
det: f64,
x: [f64; 3],
/// det J times the point's weight times the out-of-plane extent: the point's share of the
/// volume.
dv: f64,
}
/// The incompatible modes' count of internal degrees of freedom: two modes, two directions.
const INCOMPATIBLE_DOFS: usize = 4;
/// A 2D element: its type, node coordinates (z = 0), formulation and theory.
#[derive(Debug, Clone, Copy)]
pub struct Plane<'a> {
pub ty: &'static ElementType,
xyz: &'a [[f64; 3]],
formulation: Formulation,
theory: Theory,
}
/// out[2i + r][2j + c] += w (Bᵢᵀ D Bⱼ)[r][c] over every pair of columns (row-major, `cols` wide),
/// computed for j ≥ i and mirrored, so the result is exactly symmetric.
fn accumulate(out: &mut [f64], columns: &[Column], d: &Elasticity2, w: f64) {
let cols = 2 * columns.len();
let block = |g: &Column| -> [[f64; 2]; 4] { [[g[0], 0.0], [0.0, g[1]], [g[2], 0.0], [g[1], g[0]]] };
for (j, gj) in columns.iter().enumerate() {
let bj = block(gj);
let mut db = [[0.0; 2]; 4];
for s in 0..4 {
for c in 0..2 {
db[s][c] = (0..4).map(|t| d[s][t] * bj[t][c]).sum();
}
}
for (i, gi) in columns.iter().enumerate().take(j + 1) {
let bi = block(gi);
for r in 0..2 {
for c in if i == j { r } else { 0 }..2 {
let v = w * (0..4).map(|s| bi[s][r] * db[s][c]).sum::<f64>();
let (row, col) = (2 * i + r, 2 * j + c);
if let Some(x) = out.get_mut(row * cols + col) {
*x += v;
}
if row != col
&& let Some(x) = out.get_mut(col * cols + row)
{
*x += v;
}
}
}
}
}
}
impl<'a> Plane<'a> {
pub fn new(kind: ElementKind, xyz: &'a [[f64; 3]], formulation: Formulation, theory: Theory) -> Result<Plane<'a>> {
let ty = plane_type(kind)?;
if xyz.len() != ty.nodes {
return Err(Error::Invalid(format!("a {} element has {} nodes, got {}", kind.name(), ty.nodes, xyz.len())));
}
modelcraft_model::check_coordinates(xyz.as_flattened()).map_err(|e| Error::Invalid(e.to_string()))?;
if xyz.iter().any(|p| p[2] != 0.0) {
return Err(Error::Invalid(format!("a 2D {} element must lie in the XY plane (z = 0)", kind.name())));
}
if theory == Theory::Axisymmetric && xyz.iter().any(|p| p[0] < 0.0) {
return Err(Error::Invalid("an axisymmetric element must lie in x ≥ 0 (x is the radius)".into()));
}
if let Theory::PlaneStress { thickness } | Theory::PlaneStrain { thickness } = theory
&& !(thickness > 0.0 && thickness.is_finite())
{
return Err(Error::Invalid(format!("the thickness must be a positive number, got {thickness}")));
}
let ok = match formulation {
Formulation::Full => true,
Formulation::Reduced => ty.reduced.is_some(),
Formulation::IncompatibleModes => ty.incompatible_modes,
};
if !ok {
return Err(Error::NotSupported(format!("{formulation:?} formulation of 2D {} elements", kind.name())));
}
Ok(Plane { ty, xyz, formulation, theory })
}
/// The element's unknowns: u and v at every node ([`DofSet::PLANE`]), and the incompatible
/// modes it condenses out.
pub fn dofs(&self) -> ElementDofs {
let incompatible_modes = if self.formulation == Formulation::IncompatibleModes { INCOMPATIBLE_DOFS } else { 0 };
ElementDofs { node: DofSet::PLANE, internal: Internal { incompatible_modes, pressure: 0 } }
}
/// The stiffness integration rule.
pub fn rule(&self) -> Rule {
match (self.formulation, self.ty.reduced) {
(Formulation::Reduced, Some(r)) => r,
_ => self.ty.full,
}
}
fn degenerate(&self) -> Error {
Error::Degenerate(format!("a 2D {} element is inverted, clockwise or degenerate (its Jacobian is not positive)", self.ty.kind.name()))
}
/// The 2 × 2 Jacobian J[a][b] = ∂x_b/∂ξ_a and its determinant.
fn jacobian(&self, s: &Shape) -> ([[f64; 2]; 2], f64) {
let mut j = [[0.0; 2]; 2];
for (d, x) in s.derivatives().iter().zip(self.xyz) {
for a in 0..2 {
for b in 0..2 {
j[a][b] += d[a] * x[b];
}
}
}
(j, j[0][0] * j[1][1] - j[0][1] * j[1][0])
}
fn at(&self, p: &Point) -> Result<At> {
let basis = Basis::of(self.ty.kind);
let shape = basis.eval(p.xi);
let grads = basis.gradients(&shape, self.xyz).map_err(|_| self.degenerate())?;
let det = grads.det;
let mut x = [0.0; 3];
for (n, xi) in shape.values().iter().zip(self.xyz) {
for k in 0..2 {
x[k] += n * xi[k];
}
}
let axisymmetric = self.theory == Theory::Axisymmetric;
if axisymmetric && !(x[0] > 0.0) {
return Err(Error::Degenerate(format!("an axisymmetric {} element has an integration point on the axis", self.ty.kind.name())));
}
let mut g = [[0.0; 3]; MAX_NODES];
for ((gi, d), n) in g.iter_mut().zip(grads.gradients()).zip(shape.values()) {
*gi = [d[0], d[1], if axisymmetric { n / x[0] } else { 0.0 }];
}
let dv = det * p.weight * self.theory.extent().at(x);
Ok(At { shape, g, det, x, dv })
}
/// The element's volume (area times thickness, or the volume of the ring).
pub fn volume(&self) -> Result<f64> {
let mut v = 0.0;
for p in self.ty.mass.points() {
v += self.at(&p)?.dv;
}
Ok(v)
}
/// The incompatible modes' strain columns at every stiffness point: Taylor's mapped
/// derivatives (and the hoop value P/r when axisymmetric), shifted by their volume mean.
fn incompatible_columns(&self, points: &[(Point, At)]) -> Result<Vec<[Column; 2]>> {
let s0 = (self.ty.shape)([0.0; 3]);
let (j0, det0) = self.jacobian(&s0);
if !(det0 > 0.0 && det0.is_finite()) {
return Err(self.degenerate());
}
let axisymmetric = self.theory == Theory::Axisymmetric;
let mut out = Vec::with_capacity(points.len());
let mut mean = [[0.0; 3]; 2];
let mut volume = 0.0;
for (p, a) in points {
let mut cols = [[0.0; 3]; 2];
for (k, col) in cols.iter_mut().enumerate() {
// P_k = 1 − ξ_k²: ∂P/∂ξ_k = −2ξ_k, the other derivative zero.
let mut dp = [0.0; 2];
dp[k] = -2.0 * p.xi[k];
// (det J₀ / det J) J₀⁻¹ ∂P/∂ξ, with J₀⁻¹ = adj(J₀) / det J₀.
col[0] = (j0[1][1] * dp[0] - j0[0][1] * dp[1]) / a.det;
col[1] = (-j0[1][0] * dp[0] + j0[0][0] * dp[1]) / a.det;
col[2] = if axisymmetric { (1.0 - p.xi[k] * p.xi[k]) / a.x[0] } else { 0.0 };
for c in 0..3 {
mean[k][c] += col[c] * a.dv;
}
}
volume += a.dv;
out.push(cols);
}
if !(volume > 0.0) {
return Err(self.degenerate());
}
for cols in &mut out {
for k in 0..2 {
for c in 0..3 {
cols[k][c] -= mean[k][c] / volume;
}
}
}
Ok(out)
}
/// The integration points with their data.
fn points(&self) -> Result<Vec<(Point, At)>> {
self.rule().points().into_iter().map(|p| self.at(&p).map(|a| (p, a))).collect()
}
/// The stiffness over nodes and, with incompatible modes, the internal modes after them:
/// (2n + 4) × (2n + 4).
fn full_stiffness(&self, d: &Elasticity2) -> Result<(Matrix, Vec<(Point, At)>, Option<Vec<[Column; 2]>>)> {
let n = self.ty.nodes;
let points = self.points()?;
let modes = if self.formulation == Formulation::IncompatibleModes { Some(self.incompatible_columns(&points)?) } else { None };
let width = 2 * n + modes.as_ref().map_or(0, |_| INCOMPATIBLE_DOFS);
let mut k = Matrix::zeros(width);
let mut columns: Vec<Column> = Vec::with_capacity(n + 2);
for (i, (_, a)) in points.iter().enumerate() {
columns.clear();
columns.extend_from_slice(a.g.get(..n).unwrap_or_default());
if let Some(m) = modes.as_ref().and_then(|m| m.get(i)) {
columns.extend_from_slice(m);
}
accumulate(&mut k.data, &columns, d, a.dv);
}
Ok((k, points, modes))
}
/// The stiffness matrix, 2n × 2n, degrees of freedom ordered node by node (u, v).
pub fn stiffness(&self, d: &Elasticity) -> Result<Matrix> {
let d2 = self.theory.elasticity(d)?;
let (full, _, modes) = self.full_stiffness(&d2)?;
if modes.is_none() {
return Ok(full);
}
// K = K_uu − K_ua K_aa⁻¹ K_au.
let n = 2 * self.ty.nodes;
let kaa = sub_block(&full, n, n, INCOMPATIBLE_DOFS);
let chol = Cholesky::new(&kaa)?;
let mut k = sub_block(&full, 0, 0, n);
for i in 0..n {
let mut col: Vec<f64> = (0..INCOMPATIBLE_DOFS).map(|a| full.get(n + a, i)).collect();
chol.solve(&mut col);
for j in 0..n {
let v: f64 = (0..INCOMPATIBLE_DOFS).map(|a| full.get(j, n + a) * col.get(a).copied().unwrap_or(0.0)).sum();
k.add(j, i, -v);
}
}
symmetrize(&mut k);
Ok(k)
}
/// The consistent mass matrix per displacement component, n × n: m_ij = ∫ ρ Nᵢ Nⱼ dV.
pub fn mass(&self, density: f64) -> Result<Matrix> {
let n = self.ty.nodes;
let mut m = Matrix::zeros(n);
for p in self.ty.mass.points() {
let a = self.at(&p)?;
let w = density * a.dv;
let v = a.shape.values();
for (i, x) in v.iter().enumerate() {
for (j, y) in v.iter().enumerate() {
m.add(i, j, w * x * y);
}
}
}
Ok(m)
}
/// The lumped (diagonal) mass per node: the consistent diagonal scaled to the element's mass
/// (Hinton, Rock and Zienkiewicz 1976).
pub fn lumped_mass(&self, density: f64) -> Result<Vec<f64>> {
lump(&self.mass(density)?).ok_or_else(|| self.degenerate())
}
/// Consistent nodal forces of a force per volume b(x) in the plane: fᵢ = ∫ Nᵢ b(x) dV (the Z
/// component of `b` is ignored).
pub fn body_force_field(&self, b: impl Fn([f64; 3]) -> [f64; 3]) -> Result<Vec<[f64; 2]>> {
let mut f = vec![[0.0; 2]; self.ty.nodes];
for p in self.ty.mass.points() {
let a = self.at(&p)?;
let b = b(a.x);
for (fi, n) in f.iter_mut().zip(a.shape.values()) {
for k in 0..2 {
fi[k] += n * b[k] * a.dv;
}
}
}
Ok(f)
}
/// Strain and stress at every integration point for the nodal displacements `u` (u, v).
pub fn stresses(&self, d: &Elasticity, u: &[[f64; 2]]) -> Result<Vec<PointStress>> {
if u.len() != self.ty.nodes {
return Err(Error::Invalid(format!("{} displacements for a {}-node element", u.len(), self.ty.nodes)));
}
let d2 = self.theory.elasticity(d)?;
let (full, points, modes) =
if self.formulation == Formulation::IncompatibleModes { self.full_stiffness(&d2)? } else { (Matrix::zeros(0), self.points()?, None) };
// Incompatible modes: α = −K_aa⁻¹ K_au u.
let alpha = if modes.is_some() {
let n = 2 * self.ty.nodes;
let flat: Vec<f64> = u.iter().flatten().copied().collect();
let mut rhs: Vec<f64> =
(0..INCOMPATIBLE_DOFS).map(|a| -flat.iter().enumerate().map(|(i, x)| full.get(n + a, i) * x).sum::<f64>()).collect();
Cholesky::new(&sub_block(&full, n, n, INCOMPATIBLE_DOFS))?.solve(&mut rhs);
rhs
} else {
Vec::new()
};
let mut out = Vec::with_capacity(points.len());
for (i, (_, a)) in points.iter().enumerate() {
let mut e = [0.0; 4];
let mut add = |g: &Column, uv: [f64; 2]| {
e[0] += g[0] * uv[0];
e[1] += g[1] * uv[1];
e[2] += g[2] * uv[0];
e[3] += g[1] * uv[0] + g[0] * uv[1];
};
for (g, uv) in a.g.iter().zip(u) {
add(g, *uv);
}
if let Some(cols) = modes.as_ref().and_then(|m| m.get(i)) {
for (k, g) in cols.iter().enumerate() {
add(g, [alpha.get(2 * k).copied().unwrap_or(0.0), alpha.get(2 * k + 1).copied().unwrap_or(0.0)]);
}
}
if let Theory::PlaneStress { .. } = self.theory {
e[2] = -(d[2][0] * e[0] + d[2][1] * e[1] + d[2][3] * e[3]) / d[2][2];
}
// Plane stress: D̃ has no zz row or column, so the εzz just recovered adds nothing.
let s: [f64; 4] = std::array::from_fn(|r| (0..4).map(|c| d2[r][c] * e[c]).sum());
out.push(PointStress { xyz: a.x, strain: [e[0], e[1], e[2], e[3], 0.0, 0.0], stress: [s[0], s[1], s[2], s[3], 0.0, 0.0] });
}
Ok(out)
}
/// Extrapolate values at the integration points to the element's nodes.
pub fn extrapolate(&self, values: &[[f64; 6]]) -> Result<Vec<[f64; 6]>> {
let e = extrapolation(self.ty, self.rule())?;
if values.len() != e.points {
return Err(Error::Invalid(format!("{} values for {} integration points", values.len(), e.points)));
}
Ok(e.apply(values))
}
}
/// The `size` × `size` block of `m` starting at (`row`, `col`).
fn sub_block(m: &Matrix, row: usize, col: usize, size: usize) -> Matrix {
let mut out = Matrix::zeros(size);
for i in 0..size {
for j in 0..size {
out.add(i, j, m.get(row + i, col + j));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::material::isotropic;
/// A distorted element of `kind` with corners in x ∈ [1, 3] (so axisymmetric elements stay off
/// the axis); mid-side nodes off the chord midpoints by a little, so quadratic elements are
/// curved.
fn element(kind: ElementKind) -> Vec<[f64; 3]> {
let corners: Vec<[f64; 3]> = match kind {
ElementKind::Tri3 | ElementKind::Tri6 => vec![[1.0, 0.2, 0.0], [3.0, 0.0, 0.0], [1.6, 1.9, 0.0]],
_ => vec![[1.0, 0.1, 0.0], [3.0, 0.0, 0.0], [2.7, 2.2, 0.0], [1.2, 1.6, 0.0]],
};
let mut xyz = corners.clone();
for (k, e) in kind.edges().iter().enumerate() {
if let [a, b, _] = **e {
let bump = 0.05 * (k as f64 + 1.0);
xyz.push([(corners[a][0] + corners[b][0]) / 2.0 + bump, (corners[a][1] + corners[b][1]) / 2.0 - bump, 0.0]);
}
}
xyz
}
fn variants() -> Vec<(ElementKind, Formulation)> {
vec![
(ElementKind::Tri3, Formulation::Full),
(ElementKind::Tri6, Formulation::Full),
(ElementKind::Quad4, Formulation::Full),
(ElementKind::Quad4, Formulation::IncompatibleModes),
(ElementKind::Quad8, Formulation::Full),
(ElementKind::Quad8, Formulation::Reduced),
]
}
const THEORIES: [Theory; 3] = [Theory::PlaneStress { thickness: 0.7 }, Theory::PlaneStrain { thickness: 1.0 }, Theory::Axisymmetric];
#[test]
fn rigid_motion_strains_nothing_and_matrices_are_symmetric() {
let d = isotropic(1000.0, 0.3).unwrap();
for (kind, f) in variants() {
let xyz = element(kind);
for theory in THEORIES {
let e = Plane::new(kind, &xyz, f, theory).unwrap();
let k = e.stiffness(&d).unwrap();
let n = k.n;
for i in 0..n {
assert!(k.get(i, i) > 0.0);
for j in 0..n {
assert_eq!(k.get(i, j), k.get(j, i), "{kind:?} {f:?} {theory:?}");
}
}
// Translation along Y always; along X and the rotation only in plane models.
let mut motions: Vec<Vec<f64>> = vec![xyz.iter().flat_map(|_| [0.0, 1.0]).collect()];
if theory != Theory::Axisymmetric {
motions.push(xyz.iter().flat_map(|_| [1.0, 0.0]).collect());
motions.push(xyz.iter().flat_map(|p| [-p[1], p[0]]).collect());
}
for u in motions {
let f = k.mul_vec(&u);
let scale = k.data.iter().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(f.iter().all(|v| v.abs() < 1e-12 * scale), "{kind:?} {theory:?}: {f:?}");
}
}
}
}
#[test]
fn linear_fields_give_exact_constant_stress() {
let (young, nu) = (1000.0, 0.3);
let d = isotropic(young, nu).unwrap();
for (kind, f) in variants() {
let xyz = element(kind);
// Plane: u = 0.01 x + 0.002 y, v = −0.003 x + 0.004 y.
for theory in [Theory::PlaneStress { thickness: 0.7 }, Theory::PlaneStrain { thickness: 1.0 }] {
let e = Plane::new(kind, &xyz, f, theory).unwrap();
let u: Vec<[f64; 2]> = xyz.iter().map(|p| [0.01 * p[0] + 0.002 * p[1], -0.003 * p[0] + 0.004 * p[1]]).collect();
let (exx, eyy, gxy) = (0.01, 0.004, -0.001);
let (sxx, syy, szz, ezz) = match theory {
Theory::PlaneStress { .. } => {
let c = young / (1.0 - nu * nu);
(c * (exx + nu * eyy), c * (eyy + nu * exx), 0.0, -nu / (1.0 - nu) * (exx + eyy))
}
_ => {
let l = young * nu / ((1.0 + nu) * (1.0 - 2.0 * nu));
let m = young / (2.0 * (1.0 + nu));
(l * (exx + eyy) + 2.0 * m * exx, l * (exx + eyy) + 2.0 * m * eyy, l * (exx + eyy), 0.0)
}
};
let txy = young / (2.0 * (1.0 + nu)) * gxy;
for p in e.stresses(&d, &u).unwrap() {
let want = [sxx, syy, szz, txy, 0.0, 0.0];
assert!(p.stress.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-10), "{kind:?} {f:?} {theory:?}: {:?} vs {want:?}", p.stress);
assert!((p.strain[2] - ezz).abs() < 1e-14, "{kind:?} {theory:?}");
}
// The extrapolated nodal values are the constant too.
let values: Vec<[f64; 6]> = e.stresses(&d, &u).unwrap().iter().map(|p| p.stress).collect();
for v in e.extrapolate(&values).unwrap() {
assert!((v[0] - sxx).abs() < 1e-10 && (v[3] - txy).abs() < 1e-10);
}
}
// Axisymmetric: u_r = 0.01 r, u_z = 0.004 z gives εrr = εθθ = 0.01, εzz = 0.004.
let e = Plane::new(kind, &xyz, f, Theory::Axisymmetric).unwrap();
let u: Vec<[f64; 2]> = xyz.iter().map(|p| [0.01 * p[0], 0.004 * p[1]]).collect();
let l = young * nu / ((1.0 + nu) * (1.0 - 2.0 * nu));
let m = young / (2.0 * (1.0 + nu));
let tr = 0.024;
let want = [l * tr + 2.0 * m * 0.01, l * tr + 2.0 * m * 0.004, l * tr + 2.0 * m * 0.01, 0.0, 0.0, 0.0];
for p in e.stresses(&d, &u).unwrap() {
assert!(p.stress.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-10), "{kind:?} {f:?} axisymmetric: {:?}", p.stress);
}
// And its stiffness has no out-of-balance force for it inside the element: K u = f
// where f are the consistent forces of the boundary tractions; their total along Y
// vanishes (no Y body force).
let k = e.stiffness(&d).unwrap();
let flat: Vec<f64> = u.iter().flatten().copied().collect();
let fy: f64 = k.mul_vec(&flat).iter().skip(1).step_by(2).sum();
assert!(fy.abs() < 1e-10, "{kind:?} {f:?}: {fy}");
}
}
#[test]
fn volumes_masses_and_loads() {
let pi = std::f64::consts::PI;
// A unit square at x ∈ [1, 2]: area 1, ring volume 2π · 1.5.
let sq = [[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 1.0, 0.0], [1.0, 1.0, 0.0]];
let e = Plane::new(ElementKind::Quad4, &sq, Formulation::Full, Theory::PlaneStress { thickness: 3.0 }).unwrap();
assert!((e.volume().unwrap() - 3.0).abs() < 1e-14);
assert!((e.mass(2.0).unwrap().data.iter().sum::<f64>() - 6.0).abs() < 1e-13);
let e = Plane::new(ElementKind::Quad4, &sq, Formulation::Full, Theory::Axisymmetric).unwrap();
assert!((e.volume().unwrap() - 3.0 * pi).abs() < 1e-13);
assert!((e.lumped_mass(1.0).unwrap().iter().sum::<f64>() - 3.0 * pi).abs() < 1e-12);
// A centrifugal field ω² r: total ∫ ρ ω² r dV = 2π ∫∫ r² dr dz = 2π · 7/3.
let f = e.body_force_field(|x| [x[0], 0.0, 0.0]).unwrap();
assert!((f.iter().map(|v| v[0]).sum::<f64>() - 2.0 * pi * 7.0 / 3.0).abs() < 1e-12);
// Pressure on the outer edge (x = 2) of the ring pushes inward with p · 2π · 2 · 1.
let fp = modelcraft_model::nodal::edge_pressure(&[sq[1], sq[2]], Theory::Axisymmetric.extent(), 1.0).unwrap();
assert!((fp.iter().map(|v| v[0]).sum::<f64>() + 4.0 * pi).abs() < 1e-12, "{fp:?}");
}
#[test]
fn bad_input_is_an_error() {
let d = isotropic(1000.0, 0.3).unwrap();
let sq = [[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 1.0, 0.0], [1.0, 1.0, 0.0]];
let cw = [sq[0], sq[3], sq[2], sq[1]];
let e = Plane::new(ElementKind::Quad4, &cw, Formulation::Full, Theory::PlaneStrain { thickness: 1.0 }).unwrap();
assert!(matches!(e.stiffness(&d), Err(Error::Degenerate(_))));
assert!(Plane::new(ElementKind::Quad4, &sq, Formulation::Reduced, Theory::Axisymmetric).is_err());
assert!(Plane::new(ElementKind::Tri3, &sq[..3], Formulation::IncompatibleModes, Theory::Axisymmetric).is_err());
assert!(Plane::new(ElementKind::Hex8, &sq, Formulation::Full, Theory::Axisymmetric).is_err());
let off = [[1.0, 0.0, 0.5], sq[1], sq[2], sq[3]];
assert!(Plane::new(ElementKind::Quad4, &off, Formulation::Full, Theory::Axisymmetric).is_err());
let neg = sq.map(|p| [p[0] - 1.5, p[1], 0.0]);
assert!(Plane::new(ElementKind::Quad4, &neg, Formulation::Full, Theory::Axisymmetric).is_err());
assert!(Plane::new(ElementKind::Quad4, &sq, Formulation::Full, Theory::PlaneStress { thickness: f64::NAN }).is_err());
// A ring element touching the axis is fine (its integration points are off it).
let on_axis = sq.map(|p| [p[0] - 1.0, p[1], 0.0]);
let e = Plane::new(ElementKind::Quad4, &on_axis, Formulation::IncompatibleModes, Theory::Axisymmetric).unwrap();
assert!(e.stiffness(&d).is_ok());
assert!(formulation(ElementKind::Quad4, &modelcraft_model::Formulation { hybrid: true, ..Default::default() }).is_err());
}
}
+17 -4
View File
@@ -11,6 +11,7 @@
//! weights only.
//! - On the reference triangle {ξ, η ≥ 0, ξ + η ≤ 1}: the same collapsed product in two
//! dimensions (Jacobian 1 − u), exact for degree 2n − 2.
//! Also the centroid rule (degree 1) and the symmetric three-point rule (degree 2).
//! - On the reference square [−1, 1]²: tensor Gauss–Legendre.
//! - On the reference wedge (triangle × [−1, 1]): the symmetric three-point triangle rule
//! (points at area coordinates (⅔, ⅙, ⅙) and permutations, degree 2; Zienkiewicz, Taylor &
@@ -57,6 +58,11 @@ pub enum Rule {
Quad(u8),
/// The collapsed product on the reference triangle, n points per direction (z = 0).
TriConical(u8),
/// The triangle's centroid, weight 1/2 (degree 1).
TriCentroid,
/// Three symmetric points on the triangle at area coordinates (⅔, ⅙, ⅙) and permutations,
/// weight 1/6 each (degree 2); point k sits near corner k.
Tri3,
/// Three triangle points times two Gauss points in ζ on the reference wedge: point k of each
/// layer sits near corner k, the lower layer first.
Wedge,
@@ -109,6 +115,11 @@ impl Rule {
.flat_map(|&(u, wu)| g.iter().map(move |&(v, wv)| Point { xi: [u, v * (1.0 - u), 0.0], weight: wu * wv * (1.0 - u) }))
.collect()
}
Rule::TriCentroid => vec![Point { xi: [1.0 / 3.0, 1.0 / 3.0, 0.0], weight: 0.5 }],
Rule::Tri3 => {
let (a, b) = (2.0 / 3.0, 1.0 / 6.0);
[[b, b, 0.0], [a, b, 0.0], [b, a, 0.0]].into_iter().map(|xi| Point { xi, weight: 1.0 / 6.0 }).collect()
}
Rule::Wedge => {
let (a, b) = (2.0 / 3.0, 1.0 / 6.0);
let tri = [[b, b], [a, b], [b, a]];
@@ -172,10 +183,12 @@ mod tests {
}
}
// Triangle: ∫ ξ^a η^b = a! b! / (a + b + 2)!.
for a in 0..=4 {
for b in 0..=4 - a {
let got = integrate(Rule::TriConical(3), a, b, 0);
assert!((got - f(a) * f(b) / f(a + b + 2)).abs() < 1e-14, "tri ξ^{a} η^{b}");
for (rule, degree) in [(Rule::TriConical(3), 4), (Rule::TriConical(4), 6), (Rule::TriCentroid, 1), (Rule::Tri3, 2)] {
for a in 0..=degree {
for b in 0..=degree - a {
let got = integrate(rule, a, b, 0);
assert!((got - f(a) * f(b) / f(a + b + 2)).abs() < 1e-14, "{rule:?} ξ^{a} η^{b}");
}
}
}
}
+1
View File
@@ -9,6 +9,7 @@
pub mod cdt;
pub mod delaunay;
pub mod planar;
pub mod quad;
pub mod quality;
pub mod size;
+377
View File
@@ -0,0 +1,377 @@
//! 2D models of planar regions: the meshes plane stress, plane strain and axisymmetric analyses
//! run on.
//!
//! A region is bounded by loops of *curves* (polylines; the first loop is the outer boundary, the
//! others are holes). [`mesh_region`] divides every curve by the size, meshes the region with the
//! free quadrilateral mesher or the free triangle mesher of [`crate::quad`] on those boundary
//! nodes, and adds mid-side nodes for quadratic elements (on the boundary polyline for boundary
//! edges, at the chord midpoint inside). Every element edge on a curve is reported with the
//! curve's id, so a mesher above can tag it with the geometry it came from.
//!
//! [`face_boundary`] gives the curves of one planar face of a tessellated body: the face's
//! boundary loops in the XY plane, split where the neighbouring face changes (each curve's id is
//! the neighbour's face index, the B-rep edge the two faces share) and at sharp corners.
use std::collections::BTreeMap;
use modelcraft_model::ElementKind;
use crate::quad::{QuadOptions, free_quad_mesh, free_tri_mesh};
use crate::size::{UniformSize, divide_polyline, edge_divisions, even_divisions, ideal_divisions};
use crate::surface::weld::weld;
use crate::{Error, Result};
/// A boundary curve: a polyline from its start to the start of the next curve of its loop (the
/// end point is not repeated), and an id reported on the element edges that lie on it.
#[derive(Debug, Clone, PartialEq)]
pub struct Curve {
pub id: Option<u32>,
pub points: Vec<[f64; 2]>,
}
/// A planar mesh of 2D elements in the XY plane.
#[derive(Debug, Clone, PartialEq)]
pub struct PlanarMesh {
pub kind: ElementKind,
pub nodes: Vec<[f64; 2]>,
/// Counterclockwise node lists in the model's node order (corners, then mid-side nodes).
pub elements: Vec<Vec<u32>>,
/// Element edges on a curve with an id: (element index, edge number from 1, curve id).
pub edges: Vec<(usize, u8, u32)>,
/// The node at the start of each curve with an id: (node, curve id).
pub corners: Vec<(u32, u32)>,
}
/// Turns sharper than this (radians) start a new curve in [`face_boundary`], so corners stay
/// nodes of the mesh.
const CORNER_ANGLE: f64 = std::f64::consts::PI / 6.0;
/// Mesh the region bounded by `loops` with elements of `kind` (tri3, tri6, quad4 or quad8) of
/// about `size`.
pub fn mesh_region(loops: &[Vec<Curve>], kind: ElementKind, size: f64, opts: &QuadOptions) -> Result<PlanarMesh> {
if kind.dimension() != 2 {
return Err(Error::Invalid(format!("a planar mesh has tri3, tri6, quad4 or quad8 elements, not {}", kind.name())));
}
if !(size > 0.0 && size.is_finite()) {
return Err(Error::Invalid(format!("the element size must be a positive number, got {size}")));
}
if loops.is_empty() || loops.iter().any(Vec::is_empty) {
return Err(Error::Invalid("a region needs at least one loop of curves".into()));
}
let quads = matches!(kind, ElementKind::Quad4 | ElementKind::Quad8);
let quadratic = kind.is_quadratic();
let field = UniformSize(size);
// Boundary points per loop, the curve of each boundary segment, and the boundary mid points.
let mut boundary: Vec<Vec<[f64; 2]>> = Vec::with_capacity(loops.len());
let mut segment_curve: Vec<Option<u32>> = Vec::new();
let mut segment_mid: Vec<[f64; 2]> = Vec::new();
let mut curve_starts: Vec<(usize, u32)> = Vec::new();
for (l, curves) in loops.iter().enumerate() {
let polylines: Vec<Vec<[f64; 3]>> = curves
.iter()
.enumerate()
.map(|(i, c)| {
let next = curves.get((i + 1) % curves.len()).and_then(|n| n.points.first()).copied();
c.points.iter().chain(next.as_ref()).map(|p| [p[0], p[1], 0.0]).collect()
})
.collect();
let mut counts: Vec<usize> = polylines
.iter()
.map(|p| if quads { ideal_divisions(&field, p).and_then(even_divisions) } else { edge_divisions(&field, p) })
.collect::<Result<_>>()?;
// Enough points per loop for the meshers: 3 for triangles; 4 for a lone quad loop, 6 with
// holes.
let least = if !quads {
3
} else if loops.len() > 1 {
6
} else {
4
};
while counts.iter().sum::<usize>() < least {
let longest = (0..counts.len()).max_by(|&a, &b| counts[a].cmp(&counts[b]).then(b.cmp(&a))).unwrap_or(0);
if let Some(c) = counts.get_mut(longest) {
*c += if quads { 2 } else { 1 };
}
}
let mut pts = Vec::new();
for ((poly, n), curve) in polylines.iter().zip(&counts).zip(curves) {
let fine = divide_polyline(&field, poly, if quadratic { 2 * n } else { *n })?;
let step = if quadratic { 2 } else { 1 };
if let Some(id) = curve.id {
curve_starts.push((boundary.iter().map(Vec::len).sum::<usize>() + pts.len(), id));
}
for k in 0..*n {
let p = fine.get(step * k).ok_or_else(|| Error::Failed("curve division".into()))?;
pts.push([p[0], p[1]]);
segment_curve.push(curve.id);
let m = if quadratic { fine.get(2 * k + 1) } else { None }.copied().unwrap_or_default();
segment_mid.push([m[0], m[1]]);
}
}
if pts.len() < 3 {
return Err(Error::Invalid(format!("loop {l} is too short to mesh")));
}
boundary.push(pts);
}
let (nodes, corners_of): (Vec<[f64; 2]>, Vec<Vec<u32>>) = if quads {
let m = free_quad_mesh(&boundary, &field, opts)?;
(m.nodes, m.quads.iter().map(|q| q.to_vec()).collect())
} else {
let m = free_tri_mesh(&boundary, &field, opts)?;
(m.nodes, m.triangles.iter().map(|t| t.to_vec()).collect())
};
// Boundary segment of a node pair: segment k of loop l joins its points k and k + 1.
let mut segment: BTreeMap<(u32, u32), usize> = BTreeMap::new();
let mut offset = 0usize;
let mut s = 0usize;
for pts in &boundary {
let n = pts.len();
for k in 0..n {
let (a, b) = ((offset + k) as u32, (offset + (k + 1) % n) as u32);
segment.insert((a.min(b), a.max(b)), s);
s += 1;
}
offset += n;
}
let mut nodes = nodes;
let mut mids: BTreeMap<(u32, u32), u32> = BTreeMap::new();
let mut elements = Vec::with_capacity(corners_of.len());
let mut edges = Vec::new();
for (e, corners) in corners_of.iter().enumerate() {
let mut list = corners.clone();
for (i, local) in kind.edges().iter().enumerate() {
let (Some(&a), Some(&b)) = (local.first().and_then(|x| corners.get(*x)), local.get(1).and_then(|x| corners.get(*x))) else {
return Err(Error::Failed("element edge".into()));
};
let key = (a.min(b), a.max(b));
let on = segment.get(&key).copied();
if let Some(id) = on.and_then(|s| segment_curve.get(s).copied().flatten()) {
edges.push((e, i as u8 + 1, id));
}
if quadratic {
let id = match mids.get(&key) {
Some(id) => *id,
None => {
let p = match on.and_then(|s| segment_mid.get(s)) {
Some(m) => *m,
None => {
let (pa, pb) =
(nodes.get(a as usize).copied().unwrap_or_default(), nodes.get(b as usize).copied().unwrap_or_default());
[0.5 * (pa[0] + pb[0]), 0.5 * (pa[1] + pb[1])]
}
};
nodes.push(p);
let id = u32::try_from(nodes.len() - 1).map_err(|_| Error::Limit("too many nodes".into()))?;
mids.insert(key, id);
id
}
};
list.push(id);
}
}
elements.push(list);
}
let corners = curve_starts.into_iter().map(|(n, id)| (n as u32, id)).collect();
Ok(PlanarMesh { kind, nodes, elements, edges, corners })
}
/// The boundary of one planar face of a triangulated body, in the XY plane.
#[derive(Debug, Clone, PartialEq)]
pub struct FaceBoundary {
/// The outer loop first, then the holes; each curve's id is the face index of the neighbour
/// it borders (`None` where no neighbour was found).
pub loops: Vec<Vec<Curve>>,
/// The face's Z.
pub z: f64,
}
/// The boundary of face `face` of a triangulated body (`tri_face` gives each triangle's face):
/// the face must lie in a plane of constant Z (within `tolerance`); points of neighbouring faces
/// within `tolerance` are the same point.
pub fn face_boundary(positions: &[[f64; 3]], triangles: &[[u32; 3]], tri_face: &[u32], face: u32, tolerance: f64) -> Result<FaceBoundary> {
if !(tolerance >= 0.0 && tolerance.is_finite()) {
return Err(Error::Invalid(format!("tolerance must be a non-negative number, got {tolerance}")));
}
if let Some(v) = triangles.iter().flatten().find(|v| **v as usize >= positions.len()) {
return Err(Error::Invalid(format!("triangle vertex {v} is out of range")));
}
if tri_face.len() != triangles.len() {
return Err(Error::Invalid("every triangle needs its face".into()));
}
// One weld of the whole tessellation: each face's copies of a shared point become one point,
// so neighbours meet on welded edges.
let welded = weld(positions, triangles, 0.0, tolerance)?;
let xyz = welded.points;
let face_of = |t: usize| welded.source_triangle.get(t).and_then(|s| tri_face.get(*s as usize)).copied();
let mine: Vec<[u32; 3]> = welded.triangles.iter().enumerate().filter(|(t, _)| face_of(*t) == Some(face)).map(|(_, w)| *w).collect();
if mine.is_empty() {
return Err(Error::Invalid(format!("face {face} has no triangles")));
}
let z = mine.first().and_then(|w| xyz.get(w[0] as usize)).map_or(0.0, |p| p[2]);
let flat = mine.iter().flatten().filter_map(|v| xyz.get(*v as usize)).all(|p| (p[2] - z).abs() <= tolerance.max(1e-12 * (1.0 + z.abs())));
if !flat {
return Err(Error::Invalid("the face does not lie in a plane parallel to XY (a 2D model lies in the XY plane)".into()));
}
// Boundary edges: directed edges whose reverse is not in the face.
let mut directed: BTreeMap<(u32, u32), usize> = BTreeMap::new();
for w in &mine {
for k in 0..3 {
*directed.entry((w[k], w[(k + 1) % 3])).or_default() += 1;
}
}
let mut next: BTreeMap<u32, u32> = BTreeMap::new();
for (&(a, b), &count) in &directed {
if directed.contains_key(&(b, a)) {
continue;
}
if count > 1 || next.insert(a, b).is_some() {
return Err(Error::Invalid("the face's boundary touches itself (a non-manifold face); split the face".into()));
}
}
// Neighbours: the other faces' welded edges, by their ends.
let mut other_faces: BTreeMap<(u32, u32), u32> = BTreeMap::new();
for (t, w) in welded.triangles.iter().enumerate() {
let Some(f) = face_of(t).filter(|f| *f != face) else { continue };
for k in 0..3 {
let (a, b) = (w[k], w[(k + 1) % 3]);
other_faces.entry((a.min(b), a.max(b))).or_insert(f);
}
}
let neighbour = |a: u32, b: u32| other_faces.get(&(a.min(b), a.max(b))).copied();
// Chain the boundary into loops, each started at its smallest vertex id.
let mut loops: Vec<(f64, Vec<Curve>)> = Vec::new();
let mut unused: BTreeMap<u32, u32> = next.clone();
while let Some((&start, _)) = unused.iter().next() {
let mut chain = vec![start];
let mut v = start;
loop {
let Some(w) = unused.remove(&v) else {
return Err(Error::Invalid("the face's boundary is not closed".into()));
};
if w == start {
break;
}
chain.push(w);
v = w;
if chain.len() > next.len() {
return Err(Error::Failed("boundary chaining did not close".into()));
}
}
let at = |i: usize| chain.get(i % chain.len()).and_then(|v| xyz.get(*v as usize)).copied().unwrap_or_default();
let n = chain.len();
let ids: Vec<Option<u32>> = (0..n).map(|i| neighbour(chain[i], chain[(i + 1) % n])).collect();
// A curve starts where the neighbour changes or the boundary turns sharply.
let starts: Vec<bool> = (0..n)
.map(|i| {
let (p, q, r) = (at(i + n - 1), at(i), at(i + 1));
let (u, w) = ([q[0] - p[0], q[1] - p[1]], [r[0] - q[0], r[1] - q[1]]);
let turn = (u[0] * w[1] - u[1] * w[0]).atan2(u[0] * w[0] + u[1] * w[1]).abs();
ids[i] != ids[(i + n - 1) % n] || turn > CORNER_ANGLE
})
.collect();
let first = starts.iter().position(|s| *s).unwrap_or(0);
let mut curves: Vec<Curve> = Vec::new();
for k in 0..n {
let i = (first + k) % n;
let p = at(i);
if starts[i] || curves.is_empty() {
curves.push(Curve { id: ids[i], points: Vec::new() });
}
if let Some(c) = curves.last_mut() {
c.points.push([p[0], p[1]]);
}
}
let area: f64 = (0..n).map(|i| at(i)[0] * at(i + 1)[1] - at(i + 1)[0] * at(i)[1]).sum::<f64>() / 2.0;
loops.push((area.abs(), curves));
}
// The outer loop encloses the most area.
let outer = (0..loops.len()).max_by(|&a, &b| loops[a].0.total_cmp(&loops[b].0).then(b.cmp(&a))).unwrap_or(0);
let mut out = Vec::with_capacity(loops.len());
if outer < loops.len() {
out.push(loops.remove(outer).1);
}
out.extend(loops.into_iter().map(|l| l.1));
Ok(FaceBoundary { loops: out, z })
}
#[cfg(test)]
mod tests {
use super::*;
fn square_with_hole() -> Vec<Vec<Curve>> {
let side = |id: u32, a: [f64; 2]| Curve { id: Some(id), points: vec![a] };
let outer = vec![side(1, [0.0, 0.0]), side(2, [4.0, 0.0]), side(3, [4.0, 3.0]), side(4, [0.0, 3.0])];
let circle: Vec<[f64; 2]> = (0..32).map(|k| 2.0 * std::f64::consts::PI * k as f64 / 32.0).map(|t| [2.0 + t.cos(), 1.5 + t.sin()]).collect();
vec![outer, vec![Curve { id: Some(5), points: circle }]]
}
#[test]
fn regions_mesh_with_every_element_kind_and_report_curve_edges() {
let loops = square_with_hole();
let area_want = 12.0 - std::f64::consts::PI;
for kind in [ElementKind::Tri3, ElementKind::Tri6, ElementKind::Quad4, ElementKind::Quad8] {
let m = mesh_region(&loops, kind, 0.4, &QuadOptions::default()).unwrap();
assert!(m.elements.iter().all(|e| e.len() == kind.node_count()));
// Area by the corner polygons (the hole's chords make it a little larger).
let area: f64 = m
.elements
.iter()
.map(|e| {
let c: Vec<[f64; 2]> = e[..kind.corner_count()].iter().map(|v| m.nodes[*v as usize]).collect();
(0..c.len()).map(|i| c[i][0] * c[(i + 1) % c.len()][1] - c[(i + 1) % c.len()][0] * c[i][1]).sum::<f64>() / 2.0
})
.sum();
assert!(area > area_want && area - area_want < 0.1, "{kind:?}: {area}");
// Edges on each curve: the right side (x = 4, curve 2) has length 3.
let on_right: Vec<_> = m.edges.iter().filter(|e| e.2 == 2).collect();
assert!(!on_right.is_empty());
for (e, edge, _) in on_right {
let local = kind.edges()[usize::from(*edge) - 1];
for i in local {
assert!((m.nodes[m.elements[*e][*i] as usize][0] - 4.0).abs() < 1e-12, "{kind:?}");
}
}
// Quadratic mid-side nodes on the hole lie on its polyline (radius 1 within the chord sag).
assert_eq!(m.corners.len(), 5);
assert_eq!(mesh_region(&loops, kind, 0.4, &QuadOptions::default()).unwrap(), m, "deterministic");
}
assert!(mesh_region(&loops, ElementKind::Hex8, 0.4, &QuadOptions::default()).is_err());
assert!(mesh_region(&loops, ElementKind::Quad4, 0.0, &QuadOptions::default()).is_err());
assert!(mesh_region(&loops, ElementKind::Quad4, f64::NAN, &QuadOptions::default()).is_err());
assert!(mesh_region(&[], ElementKind::Quad4, 1.0, &QuadOptions::default()).is_err());
assert!(mesh_region(&loops, ElementKind::Tri3, 0.02, &QuadOptions { max_elements: 1000, ..QuadOptions::default() }).is_err());
}
#[test]
fn a_tessellated_face_gives_its_loops_and_neighbours() {
// A unit cube's bottom face (z = 0, faces 0), split into two triangles; four side faces 1–4
// with their own copies of the points.
let p = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
// Side y = 0 (face 1).
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
// Side x = 1 (face 2).
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[1.0, 1.0, 1.0],
];
let tris = vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [7, 8, 9]];
let faces = vec![0, 0, 1, 2];
let b = face_boundary(&p, &tris, &faces, 0, 1e-9).unwrap();
assert_eq!(b.loops.len(), 1);
let ids: Vec<Option<u32>> = b.loops[0].iter().map(|c| c.id).collect();
assert!(ids.contains(&Some(1)) && ids.contains(&Some(2)) && ids.contains(&None), "{ids:?}");
let points: usize = b.loops[0].iter().map(|c| c.points.len()).sum();
assert_eq!(points, 4);
assert!(face_boundary(&p, &tris, &faces, 1, 1e-9).is_err(), "face 1 is vertical");
assert!(face_boundary(&p, &tris, &faces, 7, 1e-9).is_err());
assert!(face_boundary(&p, &[[0, 1, 99]], &[0], 0, 1e-9).is_err());
assert!(face_boundary(&p, &tris, &faces, 0, f64::NAN).is_err());
}
}
+45
View File
@@ -160,6 +160,51 @@ pub fn free_quad_mesh(loops: &[Vec<[f64; 2]>], size: &dyn SizeField, opts: &Quad
free::mesh(loops, size, opts)
}
/// A planar triangle mesh.
#[derive(Debug, Clone, PartialEq)]
pub struct TriMesh {
/// Node coordinates; `nodes[..boundary_nodes]` are the input boundary points in input order.
pub nodes: Vec<[f64; 2]>,
/// Counterclockwise corner node ids (the `Tri3` node order).
pub triangles: Vec<[u32; 3]>,
pub boundary_nodes: usize,
}
/// Free triangle mesh of a planar region on prescribed boundary nodes: the constrained Delaunay
/// triangulation of the loops ([`cdt`]: Bowyer–Watson, Sloan 1993 recovery), refined to the size
/// field by Ruppert-style circumcentre insertion that never splits a boundary edge (Steiner points
/// keep a clearance of half the local boundary spacing from the boundary), so the boundary nodes
/// are exactly the input points. Every triangle is checked counterclockwise with exact
/// predicates. Loops as for [`free_quad_mesh`], with any number of points (at least 3 each).
pub fn free_tri_mesh(loops: &[Vec<[f64; 2]>], size: &dyn SizeField, opts: &QuadOptions) -> Result<TriMesh> {
let region = domain::region(loops)?;
let mut chords = Vec::new();
for l in &region.loops {
for (i, &a) in l.iter().enumerate() {
let b = l.get((i + 1) % l.len()).copied().ok_or_else(|| Error::Failed("loop index".into()))?;
let (pa, pb) = (*region.point(a)?, *region.point(b)?);
let (ca, cb) = (pa.coords(), pb.coords());
let m = Point2::new(snap_to_domain(0.5 * (ca[0] + cb[0])), snap_to_domain(0.5 * (ca[1] + cb[1])))?;
chords.push((pa, pb, m));
}
}
let guard = guard::Guard::new(&chords)?;
let mut cdt = cdt::Cdt::build(&region, opts.max_elements.saturating_add(4 * region.pts.len() + 16))?;
cdt.refine(size, 1.0, &|p, h| guard.allows(p, cdt::CLEARANCE * h))?;
let tri = cdt.into_triangulation();
if tri.tris.len() > opts.max_elements {
return Err(Error::Limit(format!("the mesh would have {} triangles; the limit is {}", tri.tris.len(), opts.max_elements)));
}
for t in &tri.tris {
let [a, b, c] = t.map(|v| tri.p2.get(v as usize));
match (a, b, c) {
(Some(a), Some(b), Some(c)) if crate::predicates::orient2d(a, b, c) == Sign::Positive => {}
_ => return Err(Error::Failed("a triangle is inverted or degenerate".into())),
}
}
Ok(TriMesh { nodes: tri.pts, triangles: tri.tris, boundary_nodes: region.pts.len() })
}
/// Mapped mesh of a four-sided region: `boundary` lists the region's boundary nodes once (either
/// orientation), and `corners` are the positions in it of the four corners in boundary order.
/// Opposite sides need the same number of divisions. The grid is transfinite interpolation
+31
View File
@@ -537,3 +537,34 @@ fn unsmoothed_meshes_are_valid() {
let m = free_quad_mesh(&loops, &UniformSize(0.2), &QuadOptions { smoothing_sweeps: 0, ..opts() }).unwrap();
check(&m, &loops);
}
#[test]
fn free_triangle_mesh_of_a_plate_with_a_hole() {
// Odd point counts are fine for triangles.
let outer = rect(0.0, 0.0, 10.0, 6.0, 9, 5);
let hole: Vec<[f64; 2]> = circle(4.0, 3.0, 1.5, 17).into_iter().rev().collect();
let loops = vec![outer.clone(), hole.clone()];
let m = free_tri_mesh(&loops, &UniformSize(0.7), &opts()).unwrap();
assert_eq!(m.boundary_nodes, outer.len() + hole.len());
let boundary: Vec<[f64; 2]> = outer.iter().chain(&hole).copied().collect();
assert_eq!(&m.nodes[..m.boundary_nodes], boundary.as_slice(), "boundary nodes are the input points");
let area: f64 = m
.triangles
.iter()
.map(|t| {
let [a, b, c] = t.map(|v| m.nodes[v as usize]);
let s = 0.5 * ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]));
assert!(s > 0.0, "counterclockwise");
s
})
.sum();
let want = signed_area(&outer).abs() - signed_area(&hole).abs();
assert!((area - want).abs() < 1e-9 * want, "{area} vs {want}");
assert!(m.triangles.len() > 100, "refined to the size: {}", m.triangles.len());
// Deterministic.
assert_eq!(free_tri_mesh(&loops, &UniformSize(0.7), &opts()).unwrap(), m);
// Hostile.
assert!(free_tri_mesh(&[vec![[0.0, 0.0], [1.0, 0.0]]], &UniformSize(0.5), &opts()).is_err());
assert!(free_tri_mesh(std::slice::from_ref(&outer), &UniformSize(0.001), &QuadOptions { max_elements: 100, ..opts() }).is_err());
assert!(free_tri_mesh(&[vec![[0.0, 0.0], [f64::NAN, 0.0], [0.0, 1.0]]], &UniformSize(0.5), &opts()).is_err());
}
+1 -1
View File
@@ -46,7 +46,7 @@
mod bvh;
pub mod intersect;
mod topology;
mod weld;
pub(crate) mod weld;
#[cfg(test)]
mod tests;
+165 -20
View File
@@ -10,7 +10,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::loads::Distribution;
use crate::{BoundaryCondition, Elastic, Error, Load, Model, Procedure, Result, SectionKind, Target};
use crate::{BoundaryCondition, Elastic, ElementKind, Error, Extent, Load, Model, Procedure, Result, SectionKind, Target};
/// How serious a [`Diagnostic`] is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -47,11 +47,14 @@ pub struct ResolvedSet {
pub nodes: Vec<u32>,
pub elements: Vec<u32>,
pub faces: Vec<crate::ElementFace>,
/// Element edges (every element kind; 2D models load the edges of their 2D elements).
#[serde(default)]
pub edges: Vec<crate::ElementEdge>,
}
impl ResolvedSet {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty() && self.elements.is_empty() && self.faces.is_empty()
self.nodes.is_empty() && self.elements.is_empty() && self.faces.is_empty() && self.edges.is_empty()
}
}
@@ -70,9 +73,30 @@ pub struct Resolved {
pub nodal_loads: BTreeMap<String, Vec<(u32, [f64; 3])>>,
/// The check's warnings (a resolved model has no errors).
pub warnings: Vec<Diagnostic>,
/// How each element of a 2D section (plane stress, plane strain, axisymmetric) extends out
/// of its plane; empty for a model without 2D sections. A model is 2D or not as a whole.
pub planar: BTreeMap<u32, Extent>,
}
impl Resolved {
/// Whether the model is a 2D model (its sections are plane stress, plane strain or
/// axisymmetric).
pub fn is_planar(&self) -> bool {
!self.planar.is_empty()
}
/// How a 2D model extends out of its plane: revolved when any of its sections is
/// axisymmetric (the check refuses a mix), else the first element's thickness; `None` for a
/// model without 2D sections.
pub fn extent(&self) -> Option<Extent> {
if self.planar.values().any(|e| *e == Extent::Revolved) { Some(Extent::Revolved) } else { self.planar.values().next().copied() }
}
/// The edges of a set that belong to 2D elements: where a 2D model's surface loads act.
pub fn planar_edges(&self, set: &str) -> Vec<crate::ElementEdge> {
self.sets.get(set).map(|s| s.edges.iter().filter(|e| self.planar.contains_key(&e.element)).copied().collect()).unwrap_or_default()
}
/// The nodes a boundary condition, force or moment acts on.
pub fn target_nodes(&self, target: &Target) -> &[u32] {
match target {
@@ -160,6 +184,14 @@ impl Model {
}
resolved.sets.insert(name.clone(), r);
}
// The elements of 2D sections.
for sec in self.sections.values() {
if let (Some(extent), Some(r)) = (sec.kind.extent(), resolved.sets.get(&sec.set)) {
for &e in &r.elements {
resolved.planar.insert(e, extent);
}
}
}
let empty = crate::ResolvedSet::default();
let set_of = |name: &str| resolved.sets.get(name).unwrap_or(&empty);
@@ -191,12 +223,16 @@ impl Model {
if r.elements.is_empty() {
d.error(&subject, format!("set `{}` has no elements", sec.set));
}
let fits = |k: crate::ElementKind| match sec.kind {
let fits = |k: ElementKind| match sec.kind {
SectionKind::Solid {} => k.dimension() == 3,
SectionKind::Shell { .. } => k.dimension() == 2,
SectionKind::Beam { .. } => k == crate::ElementKind::Beam2,
SectionKind::Rod { .. } => k == crate::ElementKind::Rod2,
SectionKind::Shell { .. } | SectionKind::PlaneStress { .. } | SectionKind::PlaneStrain { .. } | SectionKind::Axisymmetric {} => {
k.dimension() == 2
}
SectionKind::Beam { .. } => k == ElementKind::Beam2,
SectionKind::Rod { .. } => k == ElementKind::Rod2,
};
// The linear element with an incompatible-modes formulation for this kind of section.
let incompatible_kind = if sec.kind.is_planar() { ElementKind::Quad4 } else { ElementKind::Hex8 };
let mut wrong = Vec::new();
let mut not_hex8 = Vec::new();
let mut along = Vec::new();
@@ -206,7 +242,7 @@ impl Model {
if !fits(el.kind) {
wrong.push(e);
}
if sec.formulation.incompatible_modes && el.kind != crate::ElementKind::Hex8 {
if sec.formulation.incompatible_modes && el.kind != incompatible_kind {
not_hex8.push(e);
}
if let SectionKind::Beam { orientation, .. } = sec.kind
@@ -225,8 +261,17 @@ impl Model {
d.error(&subject, format!("a {} section cannot hold elements {}", sec.kind.name(), first_labels(&wrong)));
}
if !not_hex8.is_empty() {
d.error(&subject, format!("incompatible modes apply to hex8 elements only, not elements {}", first_labels(&not_hex8)));
d.error(
&subject,
format!(
"incompatible modes apply to {} elements only, not elements {} (in a {} section)",
incompatible_kind.name(),
first_labels(&not_hex8),
sec.kind.name()
),
);
}
if !along.is_empty() {
d.error(&subject, format!("the orientation vector is parallel to elements {}", first_labels(&along)));
}
@@ -241,6 +286,7 @@ impl Model {
if !unsectioned.is_empty() {
d.error("model", format!("elements {} have no section", first_labels(&unsectioned)));
}
self.check_planar(&resolved, &mut d);
// Mass: frequency steps and gravity need a density in every material in use.
let needs_mass = self
@@ -254,7 +300,13 @@ impl Model {
};
Some(format!("step `{}` {why}", s.name))
})
.or_else(|| self.loads.iter().find(|(_, l)| matches!(l, Load::Gravity { .. })).map(|(n, _)| format!("load `{n}` is gravity")));
.or_else(|| {
self.loads.iter().find_map(|(n, l)| match l {
Load::Gravity { .. } => Some(format!("load `{n}` is gravity")),
Load::Centrifugal { .. } => Some(format!("load `{n}` is centrifugal")),
_ => None,
})
});
if let Some(why) = needs_mass {
let used: BTreeSet<&str> = self.sections.values().map(|s| s.material.as_str()).collect();
for m in used {
@@ -285,9 +337,9 @@ impl Model {
Target::Set(s) => {
let rotational: &BTreeSet<u32> = rotational.get_or_init(|| {
self.elements
.values()
.filter(|e| e.kind.dimension() == 2 || e.kind == crate::ElementKind::Beam2)
.flat_map(|e| e.nodes.iter().copied())
.iter()
.filter(|(l, e)| (e.kind.dimension() == 2 && !resolved.planar.contains_key(l)) || e.kind == ElementKind::Beam2)
.flat_map(|(_, e)| e.nodes.iter().copied())
.collect()
});
set_of(s).nodes.iter().any(|n| rotational.contains(n))
@@ -325,8 +377,8 @@ impl Model {
Load::Moment { target, .. } if !has_rotations(target) => {
d.error(&subject, format!("{target} has no nodes with rotations; apply the moment to a reference point coupled to it"))
}
Load::Pressure { set, .. } if set_of(set).faces.is_empty() => {
d.error(&subject, format!("set `{set}` has no element faces to press on"))
Load::Pressure { set, .. } if set_of(set).faces.is_empty() && resolved.planar_edges(set).is_empty() => {
d.error(&subject, format!("set `{set}` has no element faces (or 2D element edges) to press on"))
}
Load::BodyForce { set, .. } if set_of(set).elements.is_empty() => d.error(&subject, format!("set `{set}` has no elements")),
Load::Gravity { acceleration, set } => {
@@ -338,8 +390,12 @@ impl Model {
d.error(&subject, format!("set `{s}` has no elements"));
}
}
Load::Centrifugal { set: Some(s), .. } if set_of(s).elements.is_empty() => d.error(&subject, format!("set `{s}` has no elements")),
_ => {}
}
if resolved.is_planar() {
Self::check_planar_load(load, resolved.extent(), &subject, &mut d);
}
}
// Steps and the activity table.
@@ -406,11 +462,12 @@ impl Model {
Load::Force { target, vector, distribution } | Load::Moment { target, vector, distribution } => (target, *vector, *distribution),
Load::Traction { set, vector } => {
let faces = r.sets.get(set).map(|s| s.faces.as_slice()).unwrap_or_default();
if faces.is_empty() {
return Err(format!("set `{set}` has no element faces to load"));
let edges = r.planar_edges(set);
if faces.is_empty() && edges.is_empty() {
return Err(format!("set `{set}` has no element faces (or 2D element edges) to load"));
}
let w = self.surface_weights(faces).map_err(|e| e.to_string())?;
return Ok(Some(w.into_iter().map(|(n, a)| (n, scale(*vector, a))).collect()));
let w = if faces.is_empty() { self.edge_weights(&edges, &r.planar) } else { self.surface_weights(faces) };
return Ok(Some(w.map_err(|e| e.to_string())?.into_iter().map(|(n, a)| (n, scale(*vector, a))).collect()));
}
_ => return Ok(None),
};
@@ -429,14 +486,16 @@ impl Model {
if set.nodes.is_empty() {
return Err(format!("set `{name}` has no nodes"));
}
let edges = if set.faces.is_empty() { r.planar_edges(name) } else { Vec::new() };
Ok(Some(match distribution {
Distribution::PerNode => set.nodes.iter().map(|n| (*n, vector)).collect(),
Distribution::Total if set.faces.is_empty() => {
Distribution::Total if set.faces.is_empty() && edges.is_empty() => {
let share = 1.0 / set.nodes.len() as f64;
set.nodes.iter().map(|n| (*n, scale(vector, share))).collect()
}
Distribution::Total => {
let w = self.surface_weights(&set.faces).map_err(|e| e.to_string())?;
let w = if edges.is_empty() { self.surface_weights(&set.faces) } else { self.edge_weights(&edges, &r.planar) };
let w = w.map_err(|e| e.to_string())?;
let area: f64 = w.values().sum();
if !(area > 0.0 && area.is_finite()) {
return Err(format!("the faces of set `{name}` have no area to share the load by"));
@@ -446,6 +505,92 @@ impl Model {
}))
}
/// The rules of a 2D model: one kind of 2D model (plane or axisymmetric) and nothing else, in
/// the XY plane (x ≥ 0 when axisymmetric), elements numbered counter-clockwise.
fn check_planar(&self, r: &Resolved, d: &mut Diagnostics) {
if !r.is_planar() {
return;
}
let others: Vec<&str> = self.sections.iter().filter(|(_, s)| !s.kind.is_planar()).map(|(n, _)| n.as_str()).collect();
if !others.is_empty() {
d.error(
"model",
format!(
"2D sections (plane stress, plane strain, axisymmetric) and other sections ({}) cannot be mixed in one model yet",
others.join(", ")
),
);
}
let axisymmetric = r.extent() == Some(Extent::Revolved);
if axisymmetric && r.planar.values().any(|e| *e != Extent::Revolved) {
d.error("model", "axisymmetric and plane (stress or strain) sections cannot be mixed in one model");
}
let (mut off_plane, mut negative_radius, mut clockwise) = (Vec::new(), Vec::new(), Vec::new());
for &label in r.planar.keys() {
let Some(e) = self.elements.get(&label) else { continue };
let xyz: Vec<[f64; 3]> = e.nodes.iter().filter_map(|n| self.nodes.get(n)).map(|n| n.xyz).collect();
if xyz.iter().any(|p| p[2] != 0.0) {
off_plane.push(label);
}
if axisymmetric && xyz.iter().any(|p| p[0] < 0.0) {
negative_radius.push(label);
}
// Twice the signed area of the corner polygon (shoelace).
let corners = xyz.get(..e.kind.corner_count()).unwrap_or_default();
let area2: f64 = corners.iter().zip(corners.iter().cycle().skip(1)).map(|(a, b)| a[0] * b[1] - b[0] * a[1]).sum();
if !(area2 > 0.0) {
clockwise.push(label);
}
}
if !off_plane.is_empty() {
d.error("model", format!("2D elements {} have nodes off the XY plane (z ≠ 0); 2D models lie in the XY plane", first_labels(&off_plane)));
}
if !negative_radius.is_empty() {
d.error(
"model",
format!(
"axisymmetric elements {} have nodes at negative x; x is the radius, so the model lies in x ≥ 0",
first_labels(&negative_radius)
),
);
}
if !clockwise.is_empty() {
d.error(
"model",
format!(
"2D elements {} are clockwise or have no area in the XY plane (2D elements number their nodes counter-clockwise)",
first_labels(&clockwise)
),
);
}
}
/// A load in a 2D model: in the XY plane, and a rotation about the axis the model can carry.
fn check_planar_load(load: &Load, extent: Option<Extent>, subject: &str, d: &mut Diagnostics) {
let in_plane = match load {
Load::Force { vector, .. } | Load::Traction { vector, .. } | Load::BodyForce { vector, .. } => vector[2] == 0.0,
Load::Gravity { acceleration, .. } => acceleration[2] == 0.0,
Load::Moment { .. } | Load::Pressure { .. } => true,
Load::Centrifugal { point, axis, .. } => {
let ok = if extent == Some(Extent::Revolved) {
axis[0] == 0.0 && axis[2] == 0.0 && point[0] == 0.0 && point[2] == 0.0
} else {
axis[0] == 0.0 && axis[1] == 0.0
};
if !ok {
d.error(
subject,
"in a 2D model a rotation is about the Y axis (axisymmetric: through x = 0, z = 0) or about an axis along Z (plane models)",
);
}
true
}
};
if !in_plane {
d.error(subject, "a 2D model is loaded in the XY plane; this load has a Z component");
}
}
/// Whether a vector is parallel to the axis of a two-node element (or the element has no
/// length).
fn parallel_to_axis(&self, nodes: &[u32], v: [f64; 3]) -> bool {
+3 -3
View File
@@ -53,10 +53,10 @@ pub use check::{Diagnostic, Resolved, ResolvedSet, Severity};
pub use constraints::{Coupling, CouplingKind, ReferencePoint};
pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition};
pub use geometry::{Classification, Entity, GeometryRef};
pub use loads::{Distribution, Load};
pub use loads::{Distribution, Load, centrifugal_acceleration};
pub use materials::{Elastic, Fluid, Material};
pub use mesh::{Element, ElementFace, ElementKind, Node, check_coordinates};
pub use sections::{BeamProfile, Formulation, Integration, Section, SectionKind};
pub use mesh::{Element, ElementEdge, ElementFace, ElementKind, Node, check_coordinates};
pub use sections::{BeamProfile, Extent, Formulation, Integration, Section, SectionKind};
pub use sets::Set;
pub use steps::{BulkViscosity, ExplicitDynamic, Hourglass, HourglassKind, InitialVelocity, OutputVariable, Procedure, RigidWall, Step};
pub use units::UnitSystem;
+43 -1
View File
@@ -1,4 +1,9 @@
//! Loads. Which steps a load is active in is listed explicitly by each step ([`crate::steps`]).
//!
//! In a 2D model (plane stress, plane strain, axisymmetric sections) loads lie in the XY plane:
//! surface loads act on the element edges of the set, forces are totals over the thickness (or
//! over the full circumference for axisymmetric models), and a vector with a Z component is an
//! error.
use std::collections::BTreeMap;
@@ -55,6 +60,27 @@ pub enum Load {
},
/// A force per volume on the set's elements.
BodyForce { set: String, vector: [f64; 3] },
/// The centrifugal load of a steady rotation at `angular_velocity` (radians per time unit)
/// about the axis through `point` along `axis`: a force per volume ρ ω² r on the mass of the
/// set's elements (or the whole model), r the distance from the axis, pointing away from it.
Centrifugal {
angular_velocity: f64,
point: [f64; 3],
axis: [f64; 3],
#[serde(default, skip_serializing_if = "Option::is_none")]
set: Option<String>,
},
}
/// The centrifugal acceleration ω² r at `x` of a rotation at `angular_velocity` about the axis
/// through `point` along `axis` (any length, not zero): r is the part of `x − point` normal to
/// the axis, so the acceleration points away from the axis.
pub fn centrifugal_acceleration(angular_velocity: f64, point: [f64; 3], axis: [f64; 3], x: [f64; 3]) -> [f64; 3] {
let len2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
let d = [x[0] - point[0], x[1] - point[1], x[2] - point[2]];
let along = if len2 > 0.0 { (d[0] * axis[0] + d[1] * axis[1] + d[2] * axis[2]) / len2 } else { 0.0 };
let w2 = angular_velocity * angular_velocity;
[0, 1, 2].map(|k| w2 * (d[k] - along * axis[k]))
}
impl Load {
@@ -67,6 +93,7 @@ impl Load {
Load::Pressure { .. } => "pressure",
Load::Gravity { .. } => "gravity",
Load::BodyForce { .. } => "body_force",
Load::Centrifugal { .. } => "centrifugal",
}
}
@@ -75,7 +102,7 @@ impl Load {
match self {
Load::Force { target, .. } | Load::Moment { target, .. } => target.set(),
Load::Pressure { set, .. } | Load::BodyForce { set, .. } | Load::Traction { set, .. } => Some(set),
Load::Gravity { set, .. } => set.as_deref(),
Load::Gravity { set, .. } | Load::Centrifugal { set, .. } => set.as_deref(),
}
}
@@ -95,6 +122,21 @@ impl Load {
finite("pressure", *magnitude)?;
return Ok(());
}
Load::Centrifugal { angular_velocity, point, axis, .. } => {
let w = finite("angular velocity", *angular_velocity)?;
if w == 0.0 || !(w * w).is_finite() {
return Err(Error::Invalid(format!(
"a centrifugal load's angular velocity must be a non-zero number whose square is finite, got {w}"
)));
}
for c in point.iter().chain(axis) {
finite("centrifugal axis", *c)?;
}
if axis.iter().all(|c| *c == 0.0) {
return Err(Error::Invalid("a centrifugal load's axis direction must not be zero".into()));
}
return Ok(());
}
};
for c in vector {
finite(self.kind(), *c)?;
+10
View File
@@ -195,6 +195,16 @@ pub struct ElementFace {
pub face: u8,
}
/// One edge of an element: the element's label and its edge number (1-based,
/// [`ElementKind::edges`]). In a 2D model (plane stress, plane strain, axisymmetric) the edges on
/// the boundary are what pressures and tractions act on, as faces are for solids.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ElementEdge {
pub element: u32,
pub edge: u8,
}
/// An element: a topology and its node labels.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Element {
+103 -1
View File
@@ -10,7 +10,7 @@
use std::collections::BTreeMap;
use crate::{ElementFace, Error, Model, Result};
use crate::{ElementEdge, ElementFace, Error, Extent, Model, Result};
/// Shape functions and their derivatives at one point of a reference face.
struct Shape {
@@ -137,7 +137,82 @@ pub fn face_normal_weights(xyz: &[[f64; 3]]) -> Result<Vec<[f64; 3]>> {
Ok(out)
}
/// Visit the 3-point Gauss–Legendre points of an element edge (its ends, then the mid-side node
/// of a quadratic edge): the shape function values, the position and the weighted tangent
/// (∂x/∂s) w, whose length is the point's share of the edge's length.
fn integrate_edge(xyz: &[[f64; 3]], mut visit: impl FnMut(&[f64], [f64; 3], [f64; 3])) -> Result<()> {
if xyz.iter().flatten().any(|c| !c.is_finite()) {
return Err(Error::Invalid("edge coordinates must be finite".into()));
}
if !matches!(xyz.len(), 2 | 3) {
return Err(Error::Invalid(format!("an edge has 2 or 3 nodes, not {}", xyz.len())));
}
let g = (0.6f64).sqrt();
for (s, w) in [(-g, 5.0 / 9.0), (0.0, 8.0 / 9.0), (g, 5.0 / 9.0)] {
let (n, dn) = if xyz.len() == 2 {
([(1.0 - s) / 2.0, (1.0 + s) / 2.0, 0.0], [-0.5, 0.5, 0.0])
} else {
([s * (s - 1.0) / 2.0, s * (s + 1.0) / 2.0, 1.0 - s * s], [s - 0.5, s + 0.5, -2.0 * s])
};
let (mut x, mut t) = ([0.0; 3], [0.0; 3]);
for ((p, ni), di) in xyz.iter().zip(&n).zip(&dn) {
for k in 0..3 {
x[k] += ni * p[k];
t[k] += di * p[k];
}
}
visit(n.get(..xyz.len()).unwrap_or_default(), x, t.map(|v| v * w));
}
Ok(())
}
/// ∫ Nᵢ n e dL for every node of an element edge in the XY plane (node coordinates in the edge's
/// order), with n the normal to the right of the edge's direction (outward for an edge of a
/// counter-clockwise 2D element) and e the out-of-plane extent (thickness, or 2πr when
/// revolved): the consistent nodal forces of a unit pressure pulling outward.
pub fn edge_normal_weights(xyz: &[[f64; 3]], extent: Extent) -> Result<Vec<[f64; 3]>> {
let mut out = vec![[0.0; 3]; xyz.len()];
integrate_edge(xyz, |n, x, t| {
let e = extent.at(x);
for (o, ni) in out.iter_mut().zip(n) {
o[0] += ni * t[1] * e;
o[1] -= ni * t[0] * e;
}
})?;
Ok(out)
}
/// Consistent nodal forces of a uniform pressure on an element edge in the XY plane (positive
/// pushes into a counter-clockwise element): `−p` times [`edge_normal_weights`].
pub fn edge_pressure(xyz: &[[f64; 3]], extent: Extent, magnitude: f64) -> Result<Vec<[f64; 3]>> {
Ok(edge_normal_weights(xyz, extent)?.into_iter().map(|w| w.map(|c| -magnitude * c)).collect())
}
impl Model {
/// The nodal weights `∫ N_i e dL` of a set of 2D element edges (e the out-of-plane extent of
/// each element in `planar`), summed per node. Their sum is the area of the surface the
/// edges stand for: length times thickness, or the surface of revolution.
pub fn edge_weights(&self, edges: &[ElementEdge], planar: &BTreeMap<u32, Extent>) -> Result<BTreeMap<u32, f64>> {
let mut out: BTreeMap<u32, f64> = BTreeMap::new();
for &g in edges {
let extent = planar.get(&g.element).copied().ok_or_else(|| Error::Invalid(format!("element {} is not a 2D element", g.element)))?;
let labels = self.edge_nodes(g).ok_or_else(|| Error::NotFound(format!("element {} edge {}", g.element, g.edge)))?;
let xyz: Vec<[f64; 3]> =
labels.iter().map(|n| self.node(*n).map(|p| p.xyz).ok_or_else(|| Error::NotFound(format!("node {n}")))).collect::<Result<_>>()?;
integrate_edge(&xyz, |n, x, t| {
let dl = (t[0] * t[0] + t[1] * t[1] + t[2] * t[2]).sqrt() * extent.at(x);
for (label, ni) in labels.iter().zip(n) {
*out.entry(*label).or_default() += ni * dl;
}
})
.map_err(|e| match e {
Error::Invalid(m) => Error::Invalid(format!("element {} edge {}: {m}", g.element, g.edge)),
e => e,
})?;
}
Ok(out)
}
/// The nodal weights `∫ N_i dA` of a set of element faces, summed per node. Their sum is the
/// faces' area.
pub fn surface_weights(&self, faces: &[ElementFace]) -> Result<BTreeMap<u32, f64>> {
@@ -165,6 +240,7 @@ impl Model {
mod tests {
use super::*;
use crate::ElementKind;
use std::collections::BTreeMap;
/// A flat 2 × 3 face of a hex8 or hex20 (face 1, z = 0), and its weights.
fn face_weights(kind: ElementKind) -> Vec<f64> {
@@ -211,4 +287,30 @@ mod tests {
let tri6: Vec<f64> = m.face_nodes(ElementFace { element: 2, face: 1 }).unwrap().iter().map(|n| w[n]).collect();
assert!(close(&tri6, &[0.0, 0.0, 0.0, 1.0 / 6.0, 1.0 / 6.0, 1.0 / 6.0]), "{tri6:?}");
}
#[test]
fn edge_weights_are_the_consistent_nodal_loads() {
let close = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-12);
// A quad8 2 × 1 in the XY plane: edge 1 (y = 0, x from 0 to 2) of thickness 3 shares
// its area 6 as 1/6, 1/6, 2/3; revolved about Y it shares the annulus π(2² − 0) = 4π
// as ∫ N 2π x dx.
let mut m = Model::default();
let p = [[0., 0., 0.], [2., 0., 0.], [2., 1., 0.], [0., 1., 0.], [1., 0., 0.], [2., 0.5, 0.], [1., 1., 0.], [0., 0.5, 0.]];
for (i, q) in p.iter().enumerate() {
m.set_node(i as u32 + 1, *q).unwrap();
}
m.set_element(1, ElementKind::Quad8, (1..=8).collect()).unwrap();
let edge = [ElementEdge { element: 1, edge: 1 }];
let w = m.edge_weights(&edge, &BTreeMap::from([(1, Extent::Thickness(3.0))])).unwrap();
assert!(close(&[w[&1], w[&2], w[&5]], &[1.0, 1.0, 4.0]), "{w:?}");
let w = m.edge_weights(&edge, &BTreeMap::from([(1, Extent::Revolved)])).unwrap();
// ∫₀² N 2π x dx with N on [0, 2]: corner at 0 → 0, corner at 2 → 2π·2/3·... summed: 4π.
let pi = std::f64::consts::PI;
assert!(close(&[w[&1], w[&2], w[&5]], &[0.0, 4.0 * pi / 3.0, 8.0 * pi / 3.0]), "{w:?}");
assert!(m.edge_weights(&edge, &BTreeMap::new()).is_err(), "not a 2D element");
// The outward normal of a counter-clockwise element's bottom edge is −Y.
let n = edge_normal_weights(&[p[0], p[1], p[4]], Extent::Thickness(1.0)).unwrap();
assert!(close(&n.iter().map(|v| v[1]).collect::<Vec<_>>(), &[-1.0 / 3.0, -1.0 / 3.0, -4.0 / 3.0]) && n.iter().all(|v| v[0] == 0.0), "{n:?}");
assert!(edge_normal_weights(&[p[0]], Extent::Revolved).is_err());
}
}
+96 -5
View File
@@ -120,29 +120,90 @@ pub enum SectionKind {
Beam { profile: BeamProfile, orientation: [f64; 3] },
/// Axial members (rods, trusses) with a cross-sectional area.
Rod { area: f64 },
/// 2D continuum in plane stress (σzz = 0: thin plates loaded in their plane), of the given
/// thickness. The elements lie in the XY plane.
PlaneStress { thickness: f64 },
/// 2D continuum in plane strain (εzz = 0: a slice of a long body), of the given thickness
/// (1 by default, so results are per unit length). The elements lie in the XY plane.
PlaneStrain {
#[serde(default = "unit_thickness")]
thickness: f64,
},
/// Axisymmetric continuum: the elements lie in the XY half plane x ≥ 0, which is the r–z
/// plane of a body of revolution about the Y axis (x = radius, y = axial position; the hoop
/// direction is the third). Forces, reactions and masses are totals over the full
/// circumference (2π radians).
Axisymmetric {},
}
fn default_shell_points() -> u32 {
5
}
fn unit_thickness() -> f64 {
1.0
}
/// How a 2D continuum section extends out of its plane: what turns a length along an element
/// edge into an area, and an element's area into a volume.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Extent {
/// Plane stress and plane strain: a uniform thickness.
Thickness(f64),
/// Axisymmetric: the full circumference 2πr at radius r = x.
Revolved,
}
impl Extent {
/// The out-of-plane measure at a point: the thickness, or 2π x.
pub fn at(&self, xyz: [f64; 3]) -> f64 {
match *self {
Extent::Thickness(t) => t,
Extent::Revolved => 2.0 * std::f64::consts::PI * xyz[0],
}
}
}
impl SectionKind {
/// The element dimension this section applies to.
pub fn dimension(&self) -> u8 {
match self {
SectionKind::Solid {} => 3,
SectionKind::Shell { .. } => 2,
SectionKind::Shell { .. } | SectionKind::PlaneStress { .. } | SectionKind::PlaneStrain { .. } | SectionKind::Axisymmetric {} => 2,
SectionKind::Beam { .. } | SectionKind::Rod { .. } => 1,
}
}
/// `solid`, `shell`, `beam` or `rod`.
/// Whether this is a continuum section (solid or 2D), the kinds with incompatible-mode
/// formulations.
pub fn is_continuum(&self) -> bool {
self.dimension() == 3 || self.is_planar()
}
/// Whether this is a 2D continuum section (plane stress, plane strain or axisymmetric).
pub fn is_planar(&self) -> bool {
self.extent().is_some()
}
/// How a 2D continuum section extends out of its plane (`None` for other kinds).
pub fn extent(&self) -> Option<Extent> {
match *self {
SectionKind::PlaneStress { thickness } | SectionKind::PlaneStrain { thickness } => Some(Extent::Thickness(thickness)),
SectionKind::Axisymmetric {} => Some(Extent::Revolved),
_ => None,
}
}
/// `solid`, `shell`, `beam`, `rod`, `plane_stress`, `plane_strain` or `axisymmetric`.
pub fn name(&self) -> &'static str {
match self {
SectionKind::Solid {} => "solid",
SectionKind::Shell { .. } => "shell",
SectionKind::Beam { .. } => "beam",
SectionKind::Rod { .. } => "rod",
SectionKind::PlaneStress { .. } => "plane_stress",
SectionKind::PlaneStrain { .. } => "plane_strain",
SectionKind::Axisymmetric {} => "axisymmetric",
}
}
@@ -172,6 +233,10 @@ impl SectionKind {
SectionKind::Rod { area } => {
positive("area", *area)?;
}
SectionKind::PlaneStress { thickness } | SectionKind::PlaneStrain { thickness } => {
positive("thickness", *thickness)?;
}
SectionKind::Axisymmetric {} => {}
}
Ok(())
}
@@ -194,7 +259,8 @@ pub enum Integration {
pub struct Formulation {
#[serde(default)]
pub integration: Integration,
/// Enhanced (incompatible) bending modes: linear hexahedra only.
/// Enhanced (incompatible) bending modes: linear hexahedra (solid sections) and linear
/// quadrilaterals (2D sections) only.
#[serde(default)]
pub incompatible_modes: bool,
/// Mixed displacement–pressure formulation for nearly incompressible materials: solids only.
@@ -209,8 +275,11 @@ impl Formulation {
if self.incompatible_modes && self.integration == Integration::Reduced {
return Err(Error::Invalid("incompatible modes use full integration".into()));
}
if kind.dimension() != 3 && (self.incompatible_modes || self.hybrid) {
return Err(Error::Invalid(format!("incompatible modes and hybrid formulations are for solid sections, not {}", kind.name())));
if !kind.is_continuum() && self.incompatible_modes {
return Err(Error::Invalid(format!("incompatible modes are for solid and 2D sections, not {}", kind.name())));
}
if kind.dimension() != 3 && self.hybrid {
return Err(Error::Invalid(format!("hybrid formulations are for solid sections, not {}", kind.name())));
}
Ok(())
}
@@ -303,4 +372,26 @@ mod tests {
assert!(serde_json::from_value::<SectionKind>(serde_json::json!({"type": "solid", "x": 1})).is_err());
assert!(serde_json::from_value::<SectionKind>(serde_json::json!({"type": "membrane"})).is_err());
}
#[test]
fn planar_sections() {
let strain: SectionKind = serde_json::from_value(serde_json::json!({"type": "plane_strain"})).unwrap();
assert_eq!(strain, SectionKind::PlaneStrain { thickness: 1.0 });
let stress: SectionKind = serde_json::from_value(serde_json::json!({"type": "plane_stress", "thickness": 2.5})).unwrap();
assert_eq!(stress.extent(), Some(Extent::Thickness(2.5)));
assert!(serde_json::from_value::<SectionKind>(serde_json::json!({"type": "plane_stress"})).is_err(), "plane stress needs a thickness");
let axi: SectionKind = serde_json::from_value(serde_json::json!({"type": "axisymmetric"})).unwrap();
assert_eq!(axi.extent(), Some(Extent::Revolved));
assert!((Extent::Revolved.at([2.0, 7.0, 0.0]) - 4.0 * std::f64::consts::PI).abs() < 1e-15);
assert!(axi.is_planar() && stress.is_planar() && !SectionKind::Solid {}.is_planar());
assert!(!SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 }.is_planar());
for bad in [SectionKind::PlaneStress { thickness: 0.0 }, SectionKind::PlaneStrain { thickness: f64::NAN }] {
assert!(bad.validate().is_err(), "{bad:?}");
}
let im = Formulation { incompatible_modes: true, ..Formulation::default() };
im.validate_for(&stress).unwrap();
im.validate_for(&axi).unwrap();
assert!(Formulation { hybrid: true, ..Formulation::default() }.validate_for(&strain).is_err());
assert!(im.validate_for(&SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 }).is_err());
}
}
+39 -10
View File
@@ -10,10 +10,11 @@
//! What each member contributes:
//! - a body: the elements filling it, their nodes, and the element faces on the body's faces;
//! - a face: the element faces on it, the shell elements filling it, and their nodes;
//! - an edge: the nodes of element edges on it, the line elements filling it, and their nodes;
//! - an edge: the element edges on it (what 2D models load), their nodes, the line elements
//! filling it, and their nodes;
//! - a vertex: the node on it;
//! - node labels: those nodes; element labels: those elements and their nodes; element faces:
//! those faces and their nodes.
//! those faces and their nodes; element edges: those edges and their nodes.
use std::collections::BTreeMap;
@@ -22,7 +23,8 @@ use serde::{Deserialize, Serialize};
use crate::check::ResolvedSet;
use crate::mesh::check_label;
use crate::{
ElementFace, Error, GeometryRef, MAX_ELEMENTS, MAX_NODES, Model, Result, context, insert_new, not_in_use, remove_existing, replace_existing,
ElementEdge, ElementFace, Error, GeometryRef, MAX_ELEMENTS, MAX_NODES, Model, Result, context, insert_new, not_in_use, remove_existing,
replace_existing,
};
/// Most geometry references in one set.
@@ -40,6 +42,8 @@ pub struct Set {
pub elements: Vec<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub faces: Vec<ElementFace>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<ElementEdge>,
}
impl Set {
@@ -49,35 +53,40 @@ impl Set {
}
pub fn is_empty(&self) -> bool {
self.geometry.is_empty() && self.nodes.is_empty() && self.elements.is_empty() && self.faces.is_empty()
self.geometry.is_empty() && self.nodes.is_empty() && self.elements.is_empty() && self.faces.is_empty() && self.edges.is_empty()
}
/// Check the definition's size and labels, and sort its members.
pub fn normalized(mut self) -> Result<Set> {
if self.is_empty() {
return Err(Error::Invalid("a set needs geometry, nodes, elements or element faces".into()));
return Err(Error::Invalid("a set needs geometry, nodes, elements, element faces or element edges".into()));
}
if self.geometry.len() > MAX_SET_REFERENCES {
return Err(Error::Limit(format!("a set names at most {MAX_SET_REFERENCES} geometry entities")));
}
if self.nodes.len() > MAX_NODES || self.elements.len() > MAX_ELEMENTS || self.faces.len() > MAX_ELEMENTS {
if self.nodes.len() > MAX_NODES || self.elements.len() > MAX_ELEMENTS || self.faces.len() > MAX_ELEMENTS || self.edges.len() > MAX_ELEMENTS {
return Err(Error::Limit("a set lists at most as many members as a model holds".into()));
}
for &l in &self.nodes {
check_label("node", l)?;
}
for &l in self.elements.iter().chain(self.faces.iter().map(|f| &f.element)) {
for &l in self.elements.iter().chain(self.faces.iter().map(|f| &f.element)).chain(self.edges.iter().map(|e| &e.element)) {
check_label("element", l)?;
}
if let Some(f) = self.faces.iter().find(|f| f.face == 0) {
return Err(Error::Invalid(format!("element {} face 0: faces are numbered from 1", f.element)));
}
if let Some(e) = self.edges.iter().find(|e| e.edge == 0) {
return Err(Error::Invalid(format!("element {} edge 0: edges are numbered from 1", e.element)));
}
for v in [&mut self.nodes, &mut self.elements] {
v.sort_unstable();
v.dedup();
}
self.faces.sort_unstable();
self.faces.dedup();
self.edges.sort_unstable();
self.edges.dedup();
self.geometry.sort();
self.geometry.dedup();
Ok(self)
@@ -134,6 +143,7 @@ impl Model {
let mut nodes = Vec::new();
let mut elements = Vec::new();
let mut faces = Vec::new();
let mut edges = Vec::new();
let mut missing = Vec::new();
// Geometry: mark the selected tags by role, then make one pass over the tagged elements.
@@ -192,8 +202,7 @@ impl Model {
}
for (&edge, &t) in &tags.edges {
if on(&edge_tags, t) {
let local = usize::from(edge).checked_sub(1).and_then(|i| element.kind.edges().get(i)).copied().unwrap_or_default();
nodes.extend(local.iter().filter_map(|&i| element.nodes.get(i)));
edges.push(ElementEdge { element: label, edge });
}
}
}
@@ -224,16 +233,28 @@ impl Model {
None => missing.push(format!("element {} is not in the mesh", f.element)),
}
}
for &g in &set.edges {
match self.elements.get(&g.element) {
Some(e) if usize::from(g.edge) <= e.kind.edges().len() => edges.push(g),
Some(e) => missing.push(format!("element {} ({}) has no edge {}", g.element, e.kind.name(), g.edge)),
None => missing.push(format!("element {} is not in the mesh", g.element)),
}
}
elements.sort_unstable();
elements.dedup();
faces.sort_unstable();
faces.dedup();
edges.sort_unstable();
edges.dedup();
for f in &faces {
if let Some(e) = self.elements.get(&f.element) {
let local = usize::from(f.face).checked_sub(1).and_then(|i| e.kind.faces().get(i)).copied().unwrap_or_default();
nodes.extend(local.iter().filter_map(|&i| e.nodes.get(i)));
}
}
for g in &edges {
nodes.extend(self.edge_nodes(*g).unwrap_or_default());
}
nodes.sort_unstable();
nodes.dedup();
// Long lists of missing labels are summarised.
@@ -242,7 +263,15 @@ impl Model {
missing.truncate(20);
missing.push(format!("and {more} more"));
}
(ResolvedSet { nodes, elements, faces }, missing)
(ResolvedSet { nodes, elements, faces, edges }, missing)
}
/// The node labels of an element edge, in the edge's order ([`crate::ElementKind::edges`]:
/// its ends, then the mid-side node of a quadratic element).
pub fn edge_nodes(&self, edge: ElementEdge) -> Option<Vec<u32>> {
let e = self.elements.get(&edge.element)?;
let local = e.kind.edges().get(usize::from(edge.edge).checked_sub(1)?)?;
local.iter().map(|&i| e.nodes.get(i).copied()).collect()
}
/// The node labels of an element face, in the face's order ([`crate::ElementKind::faces`]).
+110
View File
@@ -396,3 +396,113 @@ fn reference_point_labels_are_stable() {
"41 is a node of the remeshed bar"
);
}
/// Two quad4 in the XY plane (x from `x0`, 2 × 1), tagged as face `area` of body `Plate` with the
/// edges `left` (x = x0) and `right`, and a static step.
fn plate(kind: SectionKind, x0: f64) -> Model {
let mut m = Model::default();
for (i, x) in [x0, x0 + 1.0, x0 + 2.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();
}
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 area = m.geometry_tag(&GeometryRef::face("Plate", "area").unwrap()).unwrap();
let left = m.geometry_tag(&GeometryRef::new("Plate", Entity::Edge("left".into())).unwrap()).unwrap();
let right = m.geometry_tag(&GeometryRef::new("Plate", Entity::Edge("right".into())).unwrap()).unwrap();
m.tag_element(1, area).unwrap();
m.tag_element(2, area).unwrap();
m.tag_element_edge(1, 4, left).unwrap();
m.tag_element_edge(2, 2, right).unwrap();
m.add_set("all", Set::of_geometry(vec![GeometryRef::face("Plate", "area").unwrap()])).unwrap();
m.add_set("left", Set::of_geometry(vec![GeometryRef::new("Plate", Entity::Edge("left".into())).unwrap()])).unwrap();
m.add_set("right", Set::of_geometry(vec![GeometryRef::new("Plate", Entity::Edge("right".into())).unwrap()])).unwrap();
m.add_material("Steel", Material { elastic: Some(Elastic::Isotropic { young: 2.1e5, poisson: 0.3 }), density: Some(7.85e-9), fluid: None })
.unwrap();
m.add_section("plate", Section { set: "all".into(), material: "Steel".into(), kind, formulation: Formulation::default() }).unwrap();
m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("left".into()) }).unwrap();
m.add_load("pull", Load::Traction { set: "right".into(), vector: [10.0, 0.0, 0.0] }).unwrap();
m.add_load("total", Load::Force { target: Target::Set("right".into()), vector: [0.0, 6.0, 0.0], distribution: Distribution::Total }).unwrap();
let mut s = Step::new("Pull", Procedure::Static {});
s.boundary_conditions.insert("clamp".into());
s.loads.extend(["pull".to_string(), "total".to_string()]);
m.add_step(s, None).unwrap();
m
}
#[test]
fn two_dimensional_models_resolve_edges_and_check_their_rules() {
// Edge sets resolve to element edges; tractions spread over length × thickness.
let m = plate(SectionKind::PlaneStress { thickness: 2.0 }, 0.0);
let r = m.resolve().unwrap();
assert!(r.is_planar());
assert_eq!(r.planar_edges("right"), vec![ElementEdge { element: 2, edge: 2 }]);
assert_eq!(r.sets["left"].nodes, vec![1, 4]);
let pull: f64 = r.nodal_loads["pull"].iter().map(|(_, v)| v[0]).sum();
assert!((pull - 10.0 * 1.0 * 2.0).abs() < 1e-12, "{pull}");
assert_eq!(r.nodal_loads["total"], vec![(3, [0.0, 3.0, 0.0]), (6, [0.0, 3.0, 0.0])]);
// Axisymmetric: the right edge at r = 2 is a cylinder of area 2π·2·1.
let r = plate(SectionKind::Axisymmetric {}, 0.0).resolve().unwrap();
let pull: f64 = r.nodal_loads["pull"].iter().map(|(_, v)| v[0]).sum();
assert!((pull - 10.0 * 4.0 * std::f64::consts::PI).abs() < 1e-9, "{pull}");
let errors = |m: &Model| -> Vec<String> { m.check().into_iter().filter(|d| d.severity == Severity::Error).map(|d| d.to_string()).collect() };
assert!(errors(&plate(SectionKind::Axisymmetric {}, -1.0)).iter().any(|e| e.contains("negative x")));
let mut m = plate(SectionKind::PlaneStrain { thickness: 1.0 }, 0.0);
m.set_node(6, [2.0, 1.0, 0.5]).unwrap();
m.set_element(1, ElementKind::Quad4, vec![1, 4, 5, 2]).unwrap();
let area = m.geometry_tag(&GeometryRef::face("Plate", "area").unwrap()).unwrap();
m.tag_element(1, area).unwrap();
m.add_load("lift", Load::Force { target: Target::Set("right".into()), vector: [0.0, 0.0, 1.0], distribution: Distribution::Total }).unwrap();
m.add_load("spin", Load::Centrifugal { angular_velocity: 10.0, point: [0.0; 3], axis: [0.0, 1.0, 0.0], set: None }).unwrap();
let e = errors(&m);
assert!(e.iter().any(|e| e.contains("off the XY plane")), "{e:#?}");
assert!(e.iter().any(|e| e.contains("clockwise")), "{e:#?}");
assert!(e.iter().any(|e| e.contains("load `lift`") && e.contains("Z component")), "{e:#?}");
assert!(e.iter().any(|e| e.contains("load `spin`") && e.contains("about an axis along Z")), "{e:#?}");
// A 2D section with a solid one; quad4 takes incompatible modes in a 2D section.
let mut m = plate(SectionKind::PlaneStress { thickness: 1.0 }, 0.0);
m.replace_section(
"plate",
Section {
set: "all".into(),
material: "Steel".into(),
kind: SectionKind::PlaneStress { thickness: 1.0 },
formulation: Formulation { incompatible_modes: true, ..Formulation::default() },
},
)
.unwrap();
assert!(errors(&m).is_empty(), "{:#?}", errors(&m));
for v in 1..=4 {
m.set_node(10 + v, [f64::from(v & 1), f64::from(v >> 1 & 1), 5.0]).unwrap();
}
m.set_element(9, ElementKind::Tet4, vec![11, 12, 13, 14]).unwrap();
m.add_set("tet", Set { elements: vec![9], ..Set::default() }).unwrap();
m.add_section("tet", Section { set: "tet".into(), material: "Steel".into(), kind: SectionKind::Solid {}, formulation: Formulation::default() })
.unwrap();
assert!(errors(&m).iter().any(|e| e.contains("cannot be mixed")), "{:#?}", errors(&m));
// Pressure on an edge set of a 2D model is fine; on nothing it is an error.
let mut m = plate(SectionKind::PlaneStress { thickness: 1.0 }, 0.0);
m.add_load("press", Load::Pressure { set: "right".into(), magnitude: 1.0 }).unwrap();
m.add_load("bad", Load::Pressure { set: "all".into(), magnitude: 1.0 }).unwrap();
let e = errors(&m);
assert!(!e.iter().any(|e| e.contains("load `press`")) && e.iter().any(|e| e.contains("load `bad`")), "{e:#?}");
// Explicit element edges round-trip through JSON; edge 0 is an error.
let set = Set { edges: vec![ElementEdge { element: 2, edge: 2 }], ..Set::default() };
let json = serde_json::to_value(&set).unwrap();
assert_eq!(json, serde_json::json!({"edges": [{"element": 2, "edge": 2}]}));
assert!(m.add_set("zero", Set { edges: vec![ElementEdge { element: 2, edge: 0 }], ..Set::default() }).is_err());
assert!(m.add_set("five", Set { edges: vec![ElementEdge { element: 2, edge: 5 }], ..Set::default() }).is_ok());
assert!(m.resolve_set("five").is_err());
let json = serde_json::to_string(&m).unwrap();
assert_eq!(serde_json::from_str::<Model>(&json).unwrap(), m);
// Hostile centrifugal loads.
for bad in [
Load::Centrifugal { angular_velocity: 0.0, point: [0.0; 3], axis: [0.0, 1.0, 0.0], set: None },
Load::Centrifugal { angular_velocity: 1e200, point: [0.0; 3], axis: [0.0, 1.0, 0.0], set: None },
Load::Centrifugal { angular_velocity: 1.0, point: [f64::NAN, 0.0, 0.0], axis: [0.0, 1.0, 0.0], set: None },
Load::Centrifugal { angular_velocity: 1.0, point: [0.0; 3], axis: [0.0; 3], set: None },
] {
assert!(m.add_load("hostile", bad.clone()).is_err(), "{bad:?}");
}
}
+13 -11
View File
@@ -1,5 +1,6 @@
//! Linear static and frequency analysis of a model's step, and their results.
use modelcraft_fem::ElementType;
use modelcraft_fem::element::{Extrapolation, extrapolation};
use modelcraft_fem::quadrature::Rule;
use modelcraft_fem::{DofKind, DofLayout, PointStress, von_mises};
@@ -134,14 +135,15 @@ fn assemble(mesh: &Mesh, graph: &NodeGraph, mass: Option<MassKind>) -> Result<As
let mut k = graph.zero_matrix(layout);
let mut m = mass.map(|_| graph.zero_matrix(layout));
for e in &mesh.elements {
let solid = e.solid()?;
let kernel = e.kernel()?;
let kinds = kernel.dofs().node;
let at = |err| Error::element(e.label, err);
add_block(&mut k, layout, &e.nodes, solid.dofs().node, &solid.stiffness(&e.d).map_err(at)?.data)?;
add_block(&mut k, layout, &e.nodes, kinds, &kernel.stiffness(&e.d).map_err(at)?.data)?;
if let (Some(mm), Some(kind)) = (m.as_mut(), mass) {
let rho = e.density.ok_or_else(|| Error::Invalid(format!("element {} has no density", e.label)))?;
match kind {
MassKind::Consistent => add_scalar(mm, layout, &e.nodes, solid.dofs().node, &solid.mass(rho).map_err(at)?.data)?,
MassKind::Lumped => add_diagonal(mm, layout, &e.nodes, solid.dofs().node, &solid.lumped_mass(rho).map_err(at)?)?,
MassKind::Consistent => add_scalar(mm, layout, &e.nodes, kinds, &kernel.mass(rho).map_err(at)?.data)?,
MassKind::Lumped => add_diagonal(mm, layout, &e.nodes, kinds, &kernel.lumped_mass(rho).map_err(at)?)?,
}
}
}
@@ -243,22 +245,22 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
.map(|(p, (d, r))| PointResult { name: p.name.clone(), label: p.label, displacement: *d, reaction: *r })
.collect();
// Stresses: one extrapolation per element type and rule.
let mut extrapolations: Vec<(ElementKind, Rule, Extrapolation)> = Vec::new();
let mut extrapolations: Vec<(&'static ElementType, Rule, Extrapolation)> = Vec::new();
let mut elements = Vec::with_capacity(mesh.elements.len());
let mut sum = vec![[0.0; 6]; mesh.labels.len()];
let mut count = vec![0u32; mesh.labels.len()];
for e in &mesh.elements {
let solid = e.solid()?;
let kernel = e.kernel()?;
let at = |err| Error::element(e.label, err);
let ue: Vec<[f64; 3]> = e.nodes.iter().map(|n| displacement.get(*n).copied().unwrap_or_default()).collect();
let points = solid.stresses(&e.d, &ue).map_err(at)?;
let points = kernel.stresses(&e.d, &ue).map_err(at)?;
let values: Vec<[f64; 6]> = points.iter().map(|p| p.stress).collect();
let rule = solid.rule();
let cached = extrapolations.iter().position(|(k, r, _)| *k == e.kind && *r == rule);
let (ty, rule) = (kernel.ty(), kernel.rule());
let cached = extrapolations.iter().position(|(t, r, _)| std::ptr::eq(*t, ty) && *r == rule);
let index = match cached {
Some(i) => i,
None => {
extrapolations.push((e.kind, rule, extrapolation(solid.ty, rule).map_err(at)?));
extrapolations.push((ty, rule, extrapolation(ty, rule).map_err(at)?));
extrapolations.len() - 1
}
};
@@ -341,7 +343,7 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
let Assembled { k, m } = assemble(&mesh, &graph, Some(options.mass))?;
let m = m.ok_or_else(|| Error::Internal("mass matrix".into()))?;
// Every element's mass entries sum to its mass, once per direction.
let total_mass = m.val.iter().sum::<f64>() / 3.0;
let total_mass = m.val.iter().sum::<f64>() / if mesh.theory.is_some() { 2.0 } else { 3.0 };
let neq = red.neq();
if neq == 0 {
return Err(Error::Invalid("every degree of freedom is held; there is nothing to vibrate".into()));
+186 -25
View File
@@ -3,18 +3,20 @@
//! load vector of a step.
//!
//! Degrees of freedom ([`DofLayout`]): the kinds each mesh node's elements declare (three
//! translations for solids), nodes by label, then six per coupled reference point (points by
//! translations for solids, Ux and Uy for 2D elements), nodes by label, then six per coupled reference point (points by
//! name): translations along X, Y, Z and rotations about them.
use modelcraft_fem::element::{Formulation, Solid};
use modelcraft_fem::{DofKind, DofLayout, DofSet, Elasticity, elasticity, face};
use modelcraft_fem::plane::{Plane, Theory};
use modelcraft_fem::{DofKind, DofLayout, DofSet, Elasticity, ElementDofs, PointStress, elasticity, face, plane};
use modelcraft_model::nodal::edge_pressure;
use std::collections::BTreeSet;
use modelcraft_model::{ElementKind, Load, Model, Resolved, SectionKind, Step, Target};
use modelcraft_model::{ElementKind, Load, Model, Resolved, SectionKind, Step, Target, centrifugal_acceleration};
use crate::{Error, Result};
/// A solid element ready for the kernels.
/// An element ready for the kernels: a solid, or a 2D element (`theory`).
#[derive(Debug, Clone)]
pub struct Elem {
pub label: u32,
@@ -25,11 +27,93 @@ pub struct Elem {
pub formulation: Formulation,
pub d: Elasticity,
pub density: Option<f64>,
/// The 2D theory of an element of a 2D section; `None` for solids.
pub theory: Option<Theory>,
}
/// An element's kernel: a solid or a 2D element.
#[derive(Debug, Clone, Copy)]
pub enum Kernel<'a> {
Solid(Solid<'a>),
Plane(Plane<'a>),
}
impl Kernel<'_> {
/// The element's unknowns, in the order of its matrices.
pub fn dofs(&self) -> ElementDofs {
match self {
Kernel::Solid(s) => s.dofs(),
Kernel::Plane(p) => p.dofs(),
}
}
pub fn stiffness(&self, d: &Elasticity) -> modelcraft_fem::Result<modelcraft_fem::dense::Matrix> {
match self {
Kernel::Solid(s) => s.stiffness(d),
Kernel::Plane(p) => p.stiffness(d),
}
}
pub fn mass(&self, density: f64) -> modelcraft_fem::Result<modelcraft_fem::dense::Matrix> {
match self {
Kernel::Solid(s) => s.mass(density),
Kernel::Plane(p) => p.mass(density),
}
}
pub fn lumped_mass(&self, density: f64) -> modelcraft_fem::Result<Vec<f64>> {
match self {
Kernel::Solid(s) => s.lumped_mass(density),
Kernel::Plane(p) => p.lumped_mass(density),
}
}
/// Consistent nodal forces of a uniform force per volume, as 3-vectors (Z zero for 2D).
pub fn body_force(&self, b: [f64; 3]) -> modelcraft_fem::Result<Vec<[f64; 3]>> {
match self {
Kernel::Solid(s) => s.body_force(b),
Kernel::Plane(p) => Ok(p.body_force_field(|_| b)?.into_iter().map(|v| [v[0], v[1], 0.0]).collect()),
}
}
/// Consistent nodal forces of a force per volume b(x), as 3-vectors (Z zero for 2D).
pub fn body_force_field(&self, b: impl Fn([f64; 3]) -> [f64; 3]) -> modelcraft_fem::Result<Vec<[f64; 3]>> {
match self {
Kernel::Solid(s) => s.body_force_field(b),
Kernel::Plane(p) => Ok(p.body_force_field(b)?.into_iter().map(|v| [v[0], v[1], 0.0]).collect()),
}
}
/// Integration-point stresses for nodal displacements given as 3-vectors.
pub fn stresses(&self, d: &Elasticity, u: &[[f64; 3]]) -> modelcraft_fem::Result<Vec<PointStress>> {
match self {
Kernel::Solid(s) => s.stresses(d, u),
Kernel::Plane(p) => p.stresses(d, &u.iter().map(|v| [v[0], v[1]]).collect::<Vec<_>>()),
}
}
pub fn rule(&self) -> modelcraft_fem::quadrature::Rule {
match self {
Kernel::Solid(s) => s.rule(),
Kernel::Plane(p) => p.rule(),
}
}
pub fn ty(&self) -> &'static modelcraft_fem::ElementType {
match self {
Kernel::Solid(s) => s.ty,
Kernel::Plane(p) => p.ty,
}
}
}
impl Elem {
pub fn solid(&self) -> Result<Solid<'_>> {
Solid::new(self.kind, &self.xyz, self.formulation).map_err(|e| Error::element(self.label, e))
pub fn kernel(&self) -> Result<Kernel<'_>> {
let k = match self.theory {
None => Solid::new(self.kind, &self.xyz, self.formulation).map(Kernel::Solid),
Some(t) => Plane::new(self.kind, &self.xyz, self.formulation, t).map(Kernel::Plane),
};
k.map_err(|e| Error::element(self.label, e))
}
}
@@ -52,6 +136,8 @@ pub struct Mesh {
pub elements: Vec<Elem>,
pub points: Vec<Point>,
pub layout: DofLayout,
/// The 2D theory when this is a 2D model (a model is 2D as a whole).
pub theory: Option<Theory>,
}
impl Mesh {
@@ -102,13 +188,19 @@ impl Mesh {
/// Build it from the model and its resolved view, refusing what the solver cannot represent.
pub fn new(model: &Model, resolved: &Resolved, max_dofs: usize) -> Result<Mesh> {
let mut elements = Vec::new();
// A model is 2D as a whole (the model check refuses a mix).
let planar = model.sections().values().find_map(|s| Theory::from_section(&s.kind));
for (name, section) in model.sections() {
if !matches!(section.kind, SectionKind::Solid {}) {
let theory = Theory::from_section(&section.kind);
if !matches!(section.kind, SectionKind::Solid {}) && theory.is_none() {
return Err(Error::NotSupported(format!(
"{} sections (section `{name}`); the solver has solid elements so far",
"{} sections (section `{name}`); the solver has solid and 2D (plane stress, plane strain, axisymmetric) elements so far",
section.kind.name()
)));
}
if theory.is_some() != planar.is_some() {
return Err(Error::NotSupported("2D and solid sections in one model".into()));
}
let material = model
.material(&section.material)
.ok_or_else(|| Error::Invalid(format!("section `{name}`: material `{}` is missing", section.material)))?;
@@ -118,10 +210,17 @@ impl Mesh {
let set = resolved.sets.get(&section.set).ok_or_else(|| Error::Invalid(format!("set `{}` is not resolved", section.set)))?;
for &label in &set.elements {
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
let formulation = Formulation::from_section(el.kind, &section.formulation).map_err(|e| Error::element(label, e))?;
elements.push((label, el.kind, el.nodes.clone(), formulation, d, material.density));
let (formulation, dofs) = match theory {
None => Formulation::from_section(el.kind, &section.formulation).map(|f| (f, f.dofs())),
Some(_) => plane::formulation(el.kind, &section.formulation).map(|f| (f, ElementDofs::new(DofSet::PLANE))),
}
.map_err(|e| Error::element(label, e))?;
elements.push((label, el.kind, el.nodes.clone(), formulation, d, material.density, theory, dofs));
}
}
if let (Some(_), Some(name)) = (planar, model.couplings().keys().next()) {
return Err(Error::NotSupported(format!("couplings in 2D models (coupling `{name}`)")));
}
elements.sort_by_key(|e| e.0);
let mut labels: Vec<u32> = elements.iter().flat_map(|e| e.2.iter().copied()).collect();
labels.sort_unstable();
@@ -136,7 +235,7 @@ impl Mesh {
.collect::<Result<_>>()?;
let mut layout = DofLayout::builder();
for e in &elements {
layout.element(&e.2, e.3.dofs());
layout.element(&e.2, e.7);
}
for p in &points {
layout.point(p.label, DofSet::STRUCTURAL);
@@ -150,11 +249,11 @@ impl Mesh {
.iter()
.map(|l| model.node(*l).map(|n| n.xyz).ok_or_else(|| Error::Invalid(format!("node {l} is missing"))))
.collect::<Result<_>>()?;
let mut mesh = Mesh { labels, xyz, elements: Vec::with_capacity(elements.len()), points, layout };
for (label, kind, nodes, formulation, d, density) in elements {
let mut mesh = Mesh { labels, xyz, elements: Vec::with_capacity(elements.len()), points, layout, theory: planar };
for (label, kind, nodes, formulation, d, density, theory, _) in elements {
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density });
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density, theory });
}
Ok(mesh)
}
@@ -205,11 +304,31 @@ pub fn prescribed(
}
}
}
// Axisymmetric: a node on the axis cannot move radially.
if mesh.theory == Some(Theory::Axisymmetric) {
for (n, p) in mesh.xyz.iter().enumerate() {
let index = mesh.dof(n, DofKind::Ux)?;
if p[0] != 0.0 || !active.get(index).copied().unwrap_or(false) {
continue;
}
let slot = out.get_mut(index).ok_or_else(|| Error::Internal("dof index".into()))?;
match *slot {
Some(v) if v != 0.0 && values => {
return Err(Error::Invalid(format!(
"{} is on the axis, where the radial displacement is zero, not {v}",
mesh.describe_dof(index)
)));
}
_ => *slot = Some(0.0),
}
}
}
Ok(out)
}
/// Add a vector to the three degrees of freedom `dofs`.
fn add(f: &mut [f64], dofs: [usize; 3], v: [f64; 3]) {
/// Add a vector's components to the degrees of freedom `dofs` (the first `dofs.len()`
/// components).
fn add(f: &mut [f64], dofs: &[usize], v: [f64; 3]) {
for (d, c) in dofs.iter().zip(v) {
if let Some(x) = f.get_mut(*d) {
*x += c;
@@ -217,15 +336,20 @@ fn add(f: &mut [f64], dofs: [usize; 3], v: [f64; 3]) {
}
}
/// The translations (or rotations) of layout slot `slot`, if it has them.
fn vector_dofs(mesh: &Mesh, slot: usize, rotation: bool) -> Option<[usize; 3]> {
/// The translations (or rotations) of layout slot `slot`, if it has them: X, Y, Z, or X and Y
/// for a node of a 2D model.
fn vector_dofs(mesh: &Mesh, slot: usize, rotation: bool) -> Option<Vec<usize>> {
let kind = |c| if rotation { DofKind::rotation(c) } else { DofKind::translation(c) };
let [a, b, c] = [0, 1, 2].map(|c| kind(c).and_then(|k| mesh.layout.dof(slot, k)));
Some([a?, b?, c?])
let dofs: Vec<Option<usize>> = [0, 1, 2].map(|c| kind(c).and_then(|k| mesh.layout.dof(slot, k))).into();
match dofs.as_slice() {
[Some(a), Some(b), Some(c)] => Some(vec![*a, *b, *c]),
[Some(a), Some(b), None] if !rotation && mesh.theory.is_some() => Some(vec![*a, *b]),
_ => None,
}
}
/// The translations of mesh node `node`.
fn translations(mesh: &Mesh, node: usize) -> Result<[usize; 3]> {
fn translations(mesh: &Mesh, node: usize) -> Result<Vec<usize>> {
vector_dofs(mesh, node, false).ok_or_else(|| Error::Internal(format!("mesh node {node} has no translations")))
}
@@ -259,7 +383,28 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
)));
}
}
add(&mut f, dofs, *v);
add(&mut f, &dofs, *v);
}
}
Load::Pressure { set, magnitude } if mesh.theory.is_some() => {
if resolved.sets.get(set).is_some_and(|s| !s.faces.is_empty()) {
return Err(Error::Invalid(format!(
"load `{name}`: a 2D model's pressures act on element edges, and set `{set}` has element faces"
)));
}
for g in resolved.planar_edges(set) {
let e = mesh.element(g.element).ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} is missing", g.element)))?;
let labels = model
.edge_nodes(g)
.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} edge {} is missing", g.element, g.edge)))?;
let idx: Vec<usize> = labels.iter().filter_map(|l| mesh.node(*l)).collect();
let xyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
let theory = e.theory.ok_or_else(|| Error::Internal("2D element without a theory".into()))?;
let forces = edge_pressure(&xyz, theory.extent(), *magnitude)
.map_err(|err| Error::element(g.element, modelcraft_fem::Error::Invalid(err.to_string())))?;
for (i, v) in idx.iter().zip(forces) {
add(&mut f, &translations(mesh, *i)?, v);
}
}
}
Load::Pressure { set, magnitude } => {
@@ -275,7 +420,7 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
let xyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
let forces = face::pressure(&xyz, *magnitude).map_err(|e| Error::element(fc.element, e))?;
for (i, v) in idx.iter().zip(forces) {
add(&mut f, translations(mesh, *i)?, v);
add(&mut f, &translations(mesh, *i)?, v);
}
}
}
@@ -295,15 +440,31 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
body(&mut f, mesh, e, *vector)?;
}
}
Load::Centrifugal { angular_velocity, point, axis, set } => {
let chosen: Option<&[u32]> = set.as_ref().map(|s| resolved.sets.get(s).map(|r| r.elements.as_slice()).unwrap_or_default());
for e in &mesh.elements {
if chosen.is_some_and(|c| c.binary_search(&e.label).is_err()) {
continue;
}
let rho = e.density.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} has no density", e.label)))?;
let forces = e
.kernel()?
.body_force_field(|x| centrifugal_acceleration(*angular_velocity, *point, *axis, x).map(|a| rho * a))
.map_err(|err| Error::element(e.label, err))?;
for (node, v) in e.nodes.iter().zip(forces) {
add(&mut f, &translations(mesh, *node)?, v);
}
}
}
}
}
Ok(f)
}
fn body(f: &mut [f64], mesh: &Mesh, e: &Elem, b: [f64; 3]) -> Result<()> {
let forces = e.solid()?.body_force(b).map_err(|err| Error::element(e.label, err))?;
let forces = e.kernel()?.body_force(b).map_err(|err| Error::element(e.label, err))?;
for (node, v) in e.nodes.iter().zip(forces) {
add(f, translations(mesh, *node)?, v);
add(f, &translations(mesh, *node)?, v);
}
Ok(())
}
+567
View File
@@ -0,0 +1,567 @@
//! The analytic verification suite of the 2D elements (plane stress, plane strain,
//! axisymmetric): patch tests, a traction patch, a cantilever, Kirsch's plate, Lamé's cylinder
//! and rotating discs and cylinders against closed-form solutions. Run with `--nocapture` to see
//! the errors.
use std::collections::BTreeMap;
use std::f64::consts::PI;
use modelcraft_model::{
BoundaryCondition, Elastic, ElementEdge, ElementKind, Formulation, Integration, Load, Material, Model, Procedure, Section, SectionKind, Set,
Step, Symmetry, Target,
};
use modelcraft_solver::{FrequencyOptions, Limits, StaticResult, solve_frequency, solve_static};
/// A structured 2D mesh of `n` cells on [0, 1]², mapped by `map`; nodes on the doubled lattice so
/// quadratic elements take their mid-side nodes from the map.
struct Grid {
model: Model,
param: BTreeMap<u32, [f64; 2]>,
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn grid(n: [usize; 2], kind: ElementKind, map: impl Fn([f64; 2]) -> [f64; 2]) -> Grid {
let size = [2 * n[0] + 1, 2 * n[1] + 1];
let mut model = Model::default();
let mut param = BTreeMap::new();
let mut node = |model: &mut Model, i: [usize; 2]| -> u32 {
let l = (1 + i[0] + size[0] * i[1]) as u32;
param.entry(l).or_insert_with(|| {
let p = [i[0] as f64 / (2 * n[0]) as f64, i[1] as f64 / (2 * n[1]) as f64];
let x = map(p);
model.set_node(l, [x[0], x[1], 0.0]).unwrap();
p
});
l
};
let mut next = 1u32;
for cy in 0..n[1] {
for cx in 0..n[0] {
let at = |d: [usize; 2]| [2 * cx + d[0], 2 * cy + d[1]];
let polygons: Vec<Vec<[usize; 2]>> = match kind {
ElementKind::Quad4 | ElementKind::Quad8 => vec![vec![[0, 0], [2, 0], [2, 2], [0, 2]]],
_ => vec![vec![[0, 0], [2, 0], [2, 2]], vec![[0, 0], [2, 2], [0, 2]]],
};
for corners in polygons {
let mut nodes: Vec<u32> = corners.iter().map(|c| node(&mut model, at(*c))).collect();
if kind.is_quadratic() {
for e in kind.edges() {
let (a, b) = (corners[e[0]], corners[e[1]]);
nodes.push(node(&mut model, at([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2])));
}
}
model.set_element(next, kind, nodes).unwrap();
next += 1;
}
}
}
Grid { model, param }
}
impl Grid {
fn nodes_where(&self, f: impl Fn([f64; 2]) -> bool) -> Vec<u32> {
self.param.iter().filter(|(_, p)| f(**p)).map(|(l, _)| *l).collect()
}
fn edges_where(&self, f: impl Fn([f64; 2]) -> bool) -> Vec<ElementEdge> {
let mut out = Vec::new();
for (label, e) in self.model.elements() {
for (i, edge) in e.kind.edges().iter().enumerate() {
if edge.iter().all(|k| f(self.param[&e.nodes[*k]])) {
out.push(ElementEdge { element: *label, edge: i as u8 + 1 });
}
}
}
out
}
fn define(&mut self, kind: SectionKind, young: f64, nu: f64, density: Option<f64>, formulation: Formulation, procedure: Procedure) {
let all: Vec<u32> = self.model.elements().keys().copied().collect();
self.model.add_set("all", Set { elements: all, ..Set::default() }).unwrap();
self.model.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young, poisson: nu }), density, fluid: None }).unwrap();
self.model.add_section("plane", Section { set: "all".into(), material: "Mat".into(), kind, formulation }).unwrap();
self.model.add_step(Step::new("Step", procedure), None).unwrap();
}
fn bc(&mut self, name: &str, bc: BoundaryCondition) {
self.model.add_boundary_condition(name, bc).unwrap();
self.model.set_boundary_condition_active("Step", name, true).unwrap();
}
fn load(&mut self, name: &str, load: Load) {
self.model.add_load(name, load).unwrap();
self.model.set_load_active("Step", name, true).unwrap();
}
fn symmetry(&mut self, name: &str, plane: Symmetry, f: impl Fn([f64; 2]) -> bool) {
let nodes = self.nodes_where(f);
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
self.bc(name, BoundaryCondition::Symmetry { target: Target::Set(name.into()), plane });
}
fn fixed(&mut self, name: &str, f: impl Fn([f64; 2]) -> bool) {
let nodes = self.nodes_where(f);
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
self.bc(name, BoundaryCondition::Fixed { target: Target::Set(name.into()) });
}
fn edge_set(&mut self, name: &str, f: impl Fn([f64; 2]) -> bool) {
let edges = self.edges_where(f);
self.model.add_set(name, Set { edges, ..Set::default() }).unwrap();
}
fn solve(&self) -> StaticResult {
solve_static(&self.model, Some("Step"), &Limits::default()).unwrap()
}
}
fn at(r: &StaticResult, label: u32) -> usize {
r.nodes.binary_search(&label).unwrap()
}
fn full() -> Formulation {
Formulation::default()
}
fn reduced() -> Formulation {
Formulation { integration: Integration::Reduced, ..Formulation::default() }
}
fn incompatible() -> Formulation {
Formulation { incompatible_modes: true, ..Formulation::default() }
}
fn variants() -> Vec<(ElementKind, Formulation, &'static str)> {
vec![
(ElementKind::Tri3, full(), "tri3"),
(ElementKind::Tri6, full(), "tri6"),
(ElementKind::Quad4, full(), "quad4"),
(ElementKind::Quad4, incompatible(), "quad4 incompatible"),
(ElementKind::Quad8, full(), "quad8"),
(ElementKind::Quad8, reduced(), "quad8 reduced"),
]
}
const STRESS: SectionKind = SectionKind::PlaneStress { thickness: 0.5 };
const STRAIN: SectionKind = SectionKind::PlaneStrain { thickness: 1.0 };
const AXI: SectionKind = SectionKind::Axisymmetric {};
/// Displacement patch test on a distorted 2 × 2 patch over [1, 2] × [0, 1]: a linear
/// displacement prescribed on the boundary is reproduced inside, with exactly constant strain
/// and stress (plane: a general linear field; axisymmetric: u_r = a r, u_z = b z, the
/// constant-strain state a body of revolution can be in without body forces; a constant shear
/// γ_rz would need an axial body force τ/r).
#[test]
fn patch_test_every_element_and_theory() {
let (young, nu) = (1000.0, 0.25);
let map = |p: [f64; 2]| {
let b = (PI * p[0]).sin() * (PI * p[1]).sin();
[1.0 + p[0] + 0.15 * b, p[1] - 0.1 * b]
};
let lambda = young * nu / ((1.0 + nu) * (1.0 - 2.0 * nu));
let mu = young / (2.0 * (1.0 + nu));
for theory in [STRESS, STRAIN, AXI] {
// u = field(x); strain (xx, yy, zz, xy).
let (field, strain): (fn([f64; 3]) -> [f64; 2], [f64; 4]) = if theory == AXI {
(|x| [1e-3 * x[0], -5e-4 * x[1]], [1e-3, -5e-4, 1e-3, 0.0])
} else {
(|x| [1e-3 * x[0] + 2e-4 * x[1], 5e-4 * x[0] - 2e-3 * x[1]], [1e-3, -2e-3, 0.0, 7e-4])
};
let mut e = strain;
if let SectionKind::PlaneStress { .. } = theory {
e[2] = -nu / (1.0 - nu) * (e[0] + e[1]);
}
let tr = e[0] + e[1] + e[2];
let stress = [lambda * tr + 2.0 * mu * e[0], lambda * tr + 2.0 * mu * e[1], lambda * tr + 2.0 * mu * e[2], mu * e[3]];
for (kind, formulation, name) in variants() {
let mut g = grid([2, 2], kind, map);
if theory == AXI {
// Straight sides: on a curved quadratic element the axisymmetric integrand
// (r ∂N/∂r + N) det J is rational, so no Gauss rule integrates it exactly and the
// patch test holds only to the rule's accuracy.
straighten(&mut g.model);
}
g.define(theory, young, nu, None, formulation, Procedure::Static {});
let boundary = g.nodes_where(|p| near(p[0], 0.0) || near(p[0], 1.0) || near(p[1], 0.0) || near(p[1], 1.0));
for (i, label) in boundary.into_iter().enumerate() {
let u = field(g.model.node(label).unwrap().xyz);
let set = format!("b{i}");
g.model.add_set(&set, Set { nodes: vec![label], ..Set::default() }).unwrap();
g.bc(
&set,
BoundaryCondition::Displacement { target: Target::Set(set.clone()), values: [Some(u[0]), Some(u[1]), None, None, None, None] },
);
}
let r = g.solve();
let (mut eu, mut es): (f64, f64) = (0.0, 0.0);
for (label, u) in r.nodes.iter().zip(&r.displacement) {
let want = field(g.model.node(*label).unwrap().xyz);
eu = eu.max((u[0] - want[0]).abs().max((u[1] - want[1]).abs()));
assert_eq!(u[2], 0.0);
}
for el in &r.elements {
for p in &el.points {
for (got, want) in p.stress.iter().zip(&stress) {
es = es.max((got - want).abs() / young);
}
}
}
eprintln!("patch {} {name}: displacement error {eu:.1e}, stress error {es:.1e}", theory.name());
assert!(eu < 1e-15 && es < 1e-14, "{} {name}: {eu:e} {es:e}", theory.name());
}
}
}
/// Put every mid-side node at the middle of its edge's ends.
fn straighten(model: &mut Model) {
let elements: Vec<(ElementKind, Vec<u32>)> = model.elements().values().map(|e| (e.kind, e.nodes.clone())).collect();
for (kind, nodes) in elements {
for e in kind.edges() {
if let [a, b, m] = **e {
let (pa, pb) = (model.node(nodes[a]).unwrap().xyz, model.node(nodes[b]).unwrap().xyz);
model.set_node(nodes[m], [0, 1, 2].map(|k| (pa[k] + pb[k]) / 2.0)).unwrap();
}
}
}
}
/// A uniform traction on the free edge of a plate (plane stress), a slab (plane strain) and a
/// tube's end (axisymmetric), held by symmetry: the exact stress and displacement everywhere.
#[test]
fn uniform_traction_is_exact() {
let (young, nu, s0) = (1000.0, 0.3, 2.0);
for theory in [STRESS, STRAIN, AXI] {
for (kind, formulation, name) in variants() {
// Axisymmetric: a tube r ∈ [1, 2], pulled along its axis (Y); plane: pulled along X.
let mut g = grid([3, 2], kind, |p| [1.0 + p[0], 2.0 * p[1]]);
g.define(theory, young, nu, None, formulation, Procedure::Static {});
if theory == AXI {
g.symmetry("bottom", Symmetry::Y, |p| near(p[1], 0.0));
g.edge_set("top", |p| near(p[1], 1.0));
g.load("pull", Load::Traction { set: "top".into(), vector: [0.0, s0, 0.0] });
} else {
g.symmetry("left", Symmetry::X, |p| near(p[0], 0.0));
g.symmetry("bottom", Symmetry::Y, |p| near(p[1], 0.0));
g.edge_set("right", |p| near(p[0], 1.0));
g.load("pull", Load::Pressure { set: "right".into(), magnitude: -s0 });
}
let r = g.solve();
// Strains: along the load s/E' and across −ν' s/E'.
let (along, across) = match theory {
SectionKind::PlaneStrain { .. } => (s0 * (1.0 - nu * nu) / young, -nu * (1.0 + nu) * s0 / young),
_ => (s0 / young, -nu * s0 / young),
};
let mut eu: f64 = 0.0;
for (label, u) in r.nodes.iter().zip(&r.displacement) {
let x = g.model.node(*label).unwrap().xyz;
let want = if theory == AXI { [across * x[0], along * x[1]] } else { [along * (x[0] - 1.0), across * x[1]] };
eu = eu.max((u[0] - want[0]).abs().max((u[1] - want[1]).abs()));
}
let total: f64 = r.reaction_total.iter().map(|c| c.abs()).sum();
let area = if theory == AXI {
PI * (4.0 - 1.0)
} else {
2.0 * if let SectionKind::PlaneStress { thickness } = theory { thickness } else { 1.0 }
};
eprintln!("traction {} {name}: displacement error {eu:.1e}, reaction {total:.6} vs {:.6}", theory.name(), s0 * area);
assert!(eu < 1e-13, "{} {name}: {eu:e}", theory.name());
assert!((total - s0 * area).abs() < 1e-11 * s0 * area, "{} {name}: {total} vs {}", theory.name(), s0 * area);
}
}
}
/// Cantilever L × h of thickness t in plane stress, clamped at x = 0, a shear traction on the free
/// end; Timoshenko's deflection δ = P L³/(3 E I) + P L/(κ G A), κ = 5/6, with ν = 0.
fn cantilever_tip(kind: ElementKind, formulation: Formulation, n: [usize; 2]) -> f64 {
let (l, h, t, young, p) = (10.0, 1.0, 1.0, 1000.0, 1.0);
let mut g = grid(n, kind, |q| [l * q[0], h * q[1]]);
g.define(SectionKind::PlaneStress { thickness: t }, young, 0.0, None, formulation, Procedure::Static {});
g.fixed("root", |q| near(q[0], 0.0));
g.edge_set("end", |q| near(q[0], 1.0));
g.load("shear", Load::Traction { set: "end".into(), vector: [0.0, -p / (h * t), 0.0] });
let r = g.solve();
let end = g.nodes_where(|q| near(q[0], 1.0));
-end.iter().map(|l| r.displacement[at(&r, *l)][1]).sum::<f64>() / end.len() as f64
}
#[test]
fn cantilever_in_plane_stress() {
let (l, h, young, p) = (10.0f64, 1.0f64, 1000.0, 1.0);
let exact = p * l.powi(3) / (3.0 * young * h.powi(3) / 12.0) + p * l / (5.0 / 6.0 * young / 2.0 * h);
let err = |d: f64| (d - exact).abs() / exact;
let study = |kind, f, meshes: &[[usize; 2]], name: &str| -> Vec<f64> {
meshes
.iter()
.map(|n| {
let d = cantilever_tip(kind, f, *n);
eprintln!("cantilever {name} {n:?}: {d:.5} vs {exact:.5} (error {:.2}%)", 100.0 * err(d));
err(d)
})
.collect()
};
let q8 = study(ElementKind::Quad8, full(), &[[10, 1], [20, 2]], "quad8");
assert!(q8[1] < 0.005, "{q8:?}");
let q8r = study(ElementKind::Quad8, reduced(), &[[10, 2]], "quad8 reduced");
assert!(q8r[0] < 0.005, "{q8r:?}");
let im = study(ElementKind::Quad4, incompatible(), &[[10, 1], [20, 2]], "quad4 incompatible");
assert!(im[0] < 0.03 && im[1] < 0.01, "{im:?}");
let t6 = study(ElementKind::Tri6, full(), &[[10, 2]], "tri6");
assert!(t6[0] < 0.01, "{t6:?}");
let meshes = [[5, 1], [10, 2], [20, 4], [40, 8]];
for (kind, name) in [(ElementKind::Quad4, "quad4"), (ElementKind::Tri3, "tri3")] {
let e = study(kind, full(), &meshes, name);
let rates: Vec<f64> = e.windows(2).map(|w| (w[0] / w[1]).log2()).collect();
eprintln!(" {name} convergence rates {rates:.2?}");
assert!(e.windows(2).all(|w| w[1] < w[0]), "{name} converges monotonically");
assert!(*rates.last().unwrap() > 1.5, "{name} approaches second order: {rates:?}");
}
}
/// Kirsch's plate with a hole of radius a under remote tension, plane stress, quarter model of
/// half-width 20a: the hoop stress at the top of the hole tends to 3σ₀.
#[test]
fn kirsch_plate_with_hole() {
let (a, w, s0) = (1.0f64, 20.0f64, 1.0);
let ratio: f64 = 40.0;
let map = |q: [f64; 2]| {
let th = PI / 2.0 * q[1];
let outer = if th <= PI / 4.0 { [w, w * th.tan()] } else { [w / th.tan(), w] };
let s = (ratio.powf(q[0]) - 1.0) / (ratio - 1.0);
let inner = [a * th.cos(), a * th.sin()];
[inner[0] + s * (outer[0] - inner[0]), inner[1] + s * (outer[1] - inner[1])]
};
let mut results = Vec::new();
for (kind, formulation, n, name) in [
(ElementKind::Quad8, full(), [6, 8], "quad8 coarse"),
(ElementKind::Quad8, full(), [12, 16], "quad8 fine"),
(ElementKind::Tri6, full(), [12, 16], "tri6"),
(ElementKind::Quad4, incompatible(), [24, 32], "quad4 incompatible"),
] {
let mut g = grid(n, kind, map);
g.define(SectionKind::PlaneStress { thickness: 0.1 }, 1000.0, 0.3, None, formulation, Procedure::Static {});
g.symmetry("x0", Symmetry::X, |q| near(q[1], 1.0));
g.symmetry("y0", Symmetry::Y, |q| near(q[1], 0.0));
g.edge_set("right", |q| near(q[0], 1.0) && q[1] <= 0.5 + 1e-9);
g.load("pull", Load::Traction { set: "right".into(), vector: [s0, 0.0, 0.0] });
let r = g.solve();
let top = g.nodes_where(|q| near(q[0], 0.0) && near(q[1], 1.0));
let kt = top.iter().map(|l| r.stress[at(&r, *l)][0]).sum::<f64>() / top.len() as f64 / s0;
eprintln!("kirsch {name} {n:?}: Kt = {kt:.4} (error vs 3: {:.2}%)", 100.0 * (kt - 3.0).abs() / 3.0);
assert!(r.stress.iter().all(|s| s[2] == 0.0), "plane stress has no σzz");
results.push(kt);
}
assert!((results[1] - 3.0).abs() < (results[0] - 3.0).abs(), "{results:?}");
assert!((results[1] - 3.0).abs() / 3.0 < 0.02, "{results:?}");
assert!((results[2] - 3.0).abs() / 3.0 < 0.03, "{results:?}");
assert!((results[3] - 3.0).abs() / 3.0 < 0.04, "{results:?}");
}
/// Lamé's thick cylinder a ≤ r ≤ b under internal pressure p in plane strain:
/// σ_r = A − B/r², σ_θ = A + B/r², σ_z = 2νA, u_r = (1 + ν)/E ((1 − 2ν) A r + B/r),
/// A = p a²/(b² − a²), B = p a² b²/(b² − a²). Solved as a quarter section in plane strain and as
/// an axisymmetric slice held axially on both faces.
#[test]
fn lame_thick_cylinder() {
let (a, b, p, young, nu) = (1.0f64, 2.0f64, 10.0, 1000.0, 0.3);
let big_a = p * a * a / (b * b - a * a);
let big_b = p * a * a * b * b / (b * b - a * a);
let ur = |r: f64| (1.0 + nu) / young * ((1.0 - 2.0 * nu) * big_a * r + big_b / r);
let sigma_t = |r: f64| big_a + big_b / (r * r);
for (kind, formulation, n, name) in [
(ElementKind::Quad8, full(), [6, 8], "quad8"),
(ElementKind::Quad4, incompatible(), [12, 16], "quad4 incompatible"),
(ElementKind::Tri6, full(), [6, 8], "tri6"),
] {
// Plane strain quarter: (r, θ) mapped.
let mut g = grid(n, kind, |q| {
let (r, th) = (a + (b - a) * q[0], PI / 2.0 * q[1]);
[r * th.cos(), r * th.sin()]
});
g.define(STRAIN, young, nu, None, formulation, Procedure::Static {});
g.symmetry("y0", Symmetry::Y, |q| near(q[1], 0.0));
g.symmetry("x0", Symmetry::X, |q| near(q[1], 1.0));
g.edge_set("bore", |q| near(q[0], 0.0));
g.load("p", Load::Pressure { set: "bore".into(), magnitude: p });
let r = g.solve();
let mut eu: f64 = 0.0;
for (label, u) in r.nodes.iter().zip(&r.displacement) {
let x = g.model.node(*label).unwrap().xyz;
let rad = x[0].hypot(x[1]);
eu = eu.max(((u[0] * x[0] + u[1] * x[1]) / rad - ur(rad)).abs() / ur(a));
}
let bore = g.nodes_where(|q| near(q[0], 0.0) && near(q[1], 0.5));
let st = bore
.iter()
.map(|l| {
let s = r.stress[at(&r, *l)];
let x = g.model.node(*l).unwrap().xyz;
let (c, sn) = (x[0] / x[0].hypot(x[1]), x[1] / x[0].hypot(x[1]));
s[0] * sn * sn - 2.0 * s[3] * sn * c + s[1] * c * c
})
.sum::<f64>()
/ bore.len() as f64;
let es = (st - sigma_t(a)).abs() / sigma_t(a);
eprintln!("lamé plane strain {name} {n:?}: u_r error {:.3}%, bore hoop {st:.4} vs {:.4} ({:.2}%)", 100.0 * eu, sigma_t(a), 100.0 * es);
assert!(eu < 0.01 && es < 0.03, "{name}");
// Axisymmetric slice r ∈ [a, b], 0 ≤ y ≤ 0.5, held axially on both faces.
let mut g = grid([n[1], 2], kind, |q| [a + (b - a) * q[0], 0.5 * q[1]]);
g.define(AXI, young, nu, None, formulation, Procedure::Static {});
g.symmetry("ends", Symmetry::Y, |q| near(q[1], 0.0) || near(q[1], 1.0));
g.edge_set("bore", |q| near(q[0], 0.0));
g.load("p", Load::Pressure { set: "bore".into(), magnitude: p });
let r = g.solve();
let mut eu: f64 = 0.0;
for (label, u) in r.nodes.iter().zip(&r.displacement) {
let x = g.model.node(*label).unwrap().xyz;
eu = eu.max((u[0] - ur(x[0])).abs() / ur(a));
}
let bore = g.nodes_where(|q| near(q[0], 0.0));
let st = bore.iter().map(|l| r.stress[at(&r, *l)][2]).sum::<f64>() / bore.len() as f64;
let sz = r.stress.iter().zip(&r.nodes).map(|(s, _)| s[1]).sum::<f64>() / r.nodes.len() as f64;
let es = (st - sigma_t(a)).abs() / sigma_t(a);
eprintln!(
"lamé axisymmetric {name}: u_r error {:.3}%, bore hoop {st:.4} vs {:.4} ({:.2}%), mean σ_z {sz:.4} vs {:.4}",
100.0 * eu,
sigma_t(a),
100.0 * es,
2.0 * nu * big_a
);
assert!(eu < 0.01 && es < 0.03 && (sz - 2.0 * nu * big_a).abs() < 0.02 * sigma_t(a), "{name}");
}
}
/// Rotating discs and cylinders (S. Timoshenko, J. N. Goodier, *Theory of Elasticity*, 3rd ed.,
/// McGraw-Hill 1970, §32), solid, radius b, speed ω, density ρ:
/// - a thin disc (plane stress, rotating about Z): σ_r = (3 + ν)/8 ρω² (b² − r²),
/// σ_θ = ρω²/8 ((3 + ν) b² − (1 + 3ν) r²);
/// - a long cylinder (axisymmetric, axial strain held at zero):
/// σ_r = (3 − 2ν)/(8(1 − ν)) ρω² (b² − r²), σ_θ = ρω²/(8(1 − ν)) ((3 − 2ν) b² − (1 + 2ν) r²).
#[test]
fn rotating_solid_disc_and_cylinder() {
let (b, rho, w, young, nu) = (1.0f64, 1e-3, 30.0, 1000.0, 0.3);
let k = rho * w * w;
let disc = |r: f64| [(3.0 + nu) / 8.0 * k * (b * b - r * r), k / 8.0 * ((3.0 + nu) * b * b - (1.0 + 3.0 * nu) * r * r)];
let cylinder = |r: f64| {
let c = k / (8.0 * (1.0 - nu));
[c * (3.0 - 2.0 * nu) * (b * b - r * r), c * ((3.0 - 2.0 * nu) * b * b - (1.0 + 2.0 * nu) * r * r)]
};
let peak = disc(0.0)[0];
// Linear elements resolve the quadratic stress field to about their size (h/b of the peak
// in nodal averages); they get a finer mesh.
for (kind, formulation, n, tol, name) in [
(ElementKind::Quad8, full(), 8, 0.01, "quad8"),
(ElementKind::Tri6, full(), 8, 0.01, "tri6"),
(ElementKind::Quad4, incompatible(), 32, 0.04, "quad4 incompatible"),
] {
// Plane stress quarter disc, polar-mapped from a pin hole of radius b/50 (a polar map
// folds at the centre); the hole's disturbance decays as (r₀/r)², so the solid disc's
// field is compared at r ≥ b/2.
let mut g = grid([n, 8], kind, |q| {
let (r, th) = (b * (0.02 + 0.98 * q[0]), PI / 2.0 * q[1]);
[r * th.cos(), r * th.sin()]
});
g.define(SectionKind::PlaneStress { thickness: 0.05 }, young, nu, Some(rho), formulation, Procedure::Static {});
g.symmetry("y0", Symmetry::Y, |q| near(q[1], 0.0));
g.symmetry("x0", Symmetry::X, |q| near(q[1], 1.0));
g.load("spin", Load::Centrifugal { angular_velocity: w, point: [0.0; 3], axis: [0.0, 0.0, 1.0], set: None });
let r = g.solve();
let mut e: f64 = 0.0;
for (label, s) in r.nodes.iter().zip(&r.stress) {
let x = g.model.node(*label).unwrap().xyz;
let rad = x[0].hypot(x[1]);
if rad < 0.5 * b {
continue;
}
let (c, sn) = (x[0] / rad, x[1] / rad);
let sr = s[0] * c * c + 2.0 * s[3] * sn * c + s[1] * sn * sn;
let st = s[0] * sn * sn - 2.0 * s[3] * sn * c + s[1] * c * c;
let want = disc(rad);
e = e.max((sr - want[0]).abs().max((st - want[1]).abs()) / peak);
}
eprintln!("rotating disc (plane stress) {name}: stress error {:.3}% of the peak (r ≥ b/2)", 100.0 * e);
assert!(e < tol, "{name}: {e}");
// Axisymmetric solid cylinder 0 ≤ r ≤ b, a slice held axially on both faces: nodes on the
// axis are held radially by the solver.
let mut g = grid([n, 2], kind, |q| [b * q[0], 0.2 * q[1]]);
g.define(AXI, young, nu, Some(rho), formulation, Procedure::Static {});
g.symmetry("ends", Symmetry::Y, |q| near(q[1], 0.0) || near(q[1], 1.0));
g.load("spin", Load::Centrifugal { angular_velocity: w, point: [0.0; 3], axis: [0.0, 1.0, 0.0], set: None });
let r = g.solve();
let cyl_peak = cylinder(0.0)[0];
let mut e: f64 = 0.0;
for (label, s) in r.nodes.iter().zip(&r.stress) {
let x = g.model.node(*label).unwrap().xyz[0];
let want = cylinder(x);
e = e.max((s[0] - want[0]).abs().max((s[2] - want[1]).abs()) / cyl_peak);
}
let on_axis = g.nodes_where(|q| near(q[0], 0.0));
assert!(on_axis.iter().all(|l| r.displacement[at(&r, *l)][0] == 0.0), "the axis does not move radially");
eprintln!("rotating cylinder (axisymmetric) {name}: stress error {:.3}% of the peak", 100.0 * e);
assert!(e < 2.0 * tol, "{name}: {e}");
}
}
/// The total mass of a 2D model is its area times its thickness, or the volume of revolution
/// (Pappus) when axisymmetric; a free plate has three rigid-body modes in its plane.
#[test]
fn masses_and_rigid_modes() {
let rho = 2.0;
for (theory, mass) in [(STRESS, rho * 2.0 * 0.5), (AXI, rho * 2.0 * PI * 1.5 * 2.0)] {
let mut g = grid([4, 2], ElementKind::Quad8, |p| [1.0 + p[0], 2.0 * p[1]]);
g.define(theory, 1000.0, 0.3, Some(rho), full(), Procedure::Frequency { modes: 4, min_hz: None, max_hz: None });
let f = solve_frequency(&g.model, None, &FrequencyOptions::default(), &Limits::default()).unwrap();
eprintln!("{}: mass {} vs {mass}, frequencies {:?}", theory.name(), f.total_mass, f.modes.iter().map(|m| m.frequency).collect::<Vec<_>>());
assert!((f.total_mass - mass).abs() < 1e-12 * mass);
let rigid = f.modes.iter().filter(|m| m.frequency < 1e-3 * f.modes.last().unwrap().frequency).count();
// In-plane: two translations and a rotation; axisymmetric: the axial translation only.
assert_eq!(rigid, if theory == AXI { 1 } else { 3 }, "{}", theory.name());
}
}
#[test]
fn two_dimensional_results_are_deterministic_and_errors_name_the_problem() {
let make = || {
let mut g = grid([4, 2], ElementKind::Quad4, |p| [1.0 + p[0] + 0.1 * p[1] * p[0], p[1]]);
g.define(AXI, 1000.0, 0.3, Some(1.0), incompatible(), Procedure::Static {});
g.symmetry("bottom", Symmetry::Y, |p| near(p[1], 0.0));
g.edge_set("bore", |p| near(p[0], 0.0));
g.load("p", Load::Pressure { set: "bore".into(), magnitude: 3.0 });
g.load("g", Load::Gravity { acceleration: [0.0, -9.81, 0.0], set: None });
g
};
let g = make();
assert_eq!(g.solve(), g.solve());
// A coupling is not supported in 2D yet.
let mut g = make();
g.model.add_reference_point("RP", modelcraft_model::ReferencePoint { xyz: [0.0, 2.0, 0.0], label: 0 }).unwrap();
g.model
.add_coupling(
"c",
modelcraft_model::Coupling {
reference_point: "RP".into(),
set: "bottom".into(),
kind: modelcraft_model::CouplingKind::Kinematic,
dofs: modelcraft_model::Dofs::all(),
},
)
.unwrap();
let e = solve_static(&g.model, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("couplings in 2D models"), "{e}");
// A load with a Z component, and a rotation about a wrong axis, are refused by the check.
let mut g = make();
g.load("lift", Load::Gravity { acceleration: [0.0, 0.0, 1.0], set: None });
let e = solve_static(&g.model, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("Z component"), "{e}");
// No support: singular, naming a node and direction.
let mut g = grid([2, 1], ElementKind::Quad4, |p| [p[0], p[1]]);
g.define(STRESS, 1000.0, 0.3, None, full(), Procedure::Static {});
g.edge_set("right", |p| near(p[0], 1.0));
g.load("p", Load::Pressure { set: "right".into(), magnitude: 1.0 });
let e = solve_static(&g.model, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("singular") && e.contains("node"), "{e}");
}