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

+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`]).