Shells and beams: MITC4/MITC3 shells, Timoshenko beams, six-DOF nodes (M4) (#47)

* fem: MITC4/MITC3 flat shells and Timoshenko beams with profile properties (ElementDofs STRUCTURAL)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* solver: shells and beams on the DofLayout beside solids, 2D elements and trusses; analytic verification and tagged benchmarks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: probe rotations, reaction moments and shell and beam element results; docs for shells and beams

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 22:00:25 -04:00
1 parent 6de6de13aa
commit 29ae46ff84
18 files changed
+2411 -48

No files matched your search

+98 -17
View File
@@ -1,8 +1,10 @@
//! Linear static and frequency analysis of a model's step, and their results.
use modelcraft_fem::ElementType;
use modelcraft_fem::beam::BeamForces;
use modelcraft_fem::element::{Extrapolation, extrapolation};
use modelcraft_fem::quadrature::Rule;
use modelcraft_fem::shell::ShellStress;
use modelcraft_fem::{DofKind, DofLayout, PointStress, von_mises};
use modelcraft_model::{ElementKind, Model, Procedure, Resolved, Step};
use serde::Serialize;
@@ -12,7 +14,7 @@ use crate::constraints::{Budget, Reduction, coupling_rows};
use crate::eigen::{Operator, Wanted, lanczos};
use crate::ordering::nested_dissection;
use crate::sparse::{Csr, NodeGraph, add_block, add_diagonal, add_scalar};
use crate::system::{Mesh, inelastic_warnings, loads, prescribed, translation_block};
use crate::system::{Mesh, Structural, StructuralKernel, inelastic_warnings, loads, prescribed, structural_body_forces, translation_block};
use crate::{Error, Limits, Result};
/// Size and effort of a solution.
@@ -29,6 +31,22 @@ pub struct Stats {
pub lanczos_vectors: usize,
}
/// Section forces and surface stresses of one shell element.
#[derive(Debug, Clone, PartialEq)]
pub struct ShellResult {
pub label: u32,
pub kind: ElementKind,
pub stress: ShellStress,
}
/// End section forces of one beam element.
#[derive(Debug, Clone, PartialEq)]
pub struct BeamResult {
pub label: u32,
pub kind: ElementKind,
pub forces: BeamForces,
}
/// Stresses of one element.
#[derive(Debug, Clone, PartialEq)]
pub struct ElementStress {
@@ -57,13 +75,23 @@ pub struct StaticResult {
/// Node labels with degrees of freedom, sorted; the per-node vectors follow this order.
pub nodes: Vec<u32>,
pub displacement: Vec<[f64; 3]>,
/// Rotations about X, Y, Z (zero at nodes without rotations: those of solids and trusses
/// only).
pub rotation: Vec<[f64; 3]>,
/// Reaction forces (zero where nothing is prescribed).
pub reaction: Vec<[f64; 3]>,
/// Reaction moments at held rotations.
pub reaction_moment: Vec<[f64; 3]>,
/// Stress averaged at the nodes over the elements that share them (xx, yy, zz, xy, xz, yz).
pub stress: Vec<[f64; 6]>,
/// Von Mises stress of the averaged nodal stress.
pub von_mises: Vec<f64>,
pub elements: Vec<ElementStress>,
/// Shell section forces and surface stresses, by label (their top surface, the side their
/// normal points to, joins the nodal stress average).
pub shells: Vec<ShellResult>,
/// Beam end section forces, by label.
pub beams: Vec<BeamResult>,
/// The coupled reference points, by name.
pub reference_points: Vec<PointResult>,
/// Sum of the applied forces and of the reaction forces (moments are not included).
@@ -82,6 +110,8 @@ pub struct Mode {
pub frequency: f64,
/// Mass-normalized (φᵀ M φ = 1), per node in [`FrequencyResult::nodes`] order.
pub shape: Vec<[f64; 3]>,
/// The rotations of the shape (zero at nodes without rotations).
pub rotation: Vec<[f64; 3]>,
/// Participation factors Γ = φᵀ M r for unit translations along X, Y, Z.
pub participation: [f64; 3],
/// Effective mass Γ² along X, Y, Z.
@@ -161,6 +191,15 @@ fn assemble(mesh: &Mesh, graph: &NodeGraph, mass: Option<MassKind>) -> Result<As
}
}
}
for s in &mesh.structural {
let kernel = s.kernel()?;
let at = |err| Error::element(s.label, err);
add_block(&mut k, layout, &s.nodes, Structural::KINDS, &kernel.stiffness().map_err(at)?.data)?;
if let (Some(mm), Some(kind)) = (m.as_mut(), mass) {
let rho = s.density.ok_or_else(|| Error::Invalid(format!("element {} has no density", s.label)))?;
add_block(mm, layout, &s.nodes, Structural::KINDS, &kernel.mass(rho, kind).map_err(at)?.data)?;
}
}
mesh.discrete.add_stiffness(&mut k)?;
if let Some(mm) = m.as_mut() {
mesh.discrete.add_mass(mm)?;
@@ -195,11 +234,17 @@ fn directions<const N: usize>(full: &[f64], layout: &DofLayout, slot: usize) ->
std::array::from_fn(|c| DofKind::from_direction(c).and_then(|k| layout.dof(slot, k)).and_then(|d| full.get(d)).copied().unwrap_or(0.0))
}
/// The mesh nodes' translations in a full vector, per node, and the reference points'
/// translations and rotations, per point.
fn split(full: &[f64], mesh: &Mesh) -> (Vec<[f64; 3]>, Vec<[f64; 6]>) {
/// A full vector split into the mesh nodes' translations and rotations (zero where a node has
/// none), per node, and the reference points' translations and rotations, per point.
fn split(full: &[f64], mesh: &Mesh) -> (Vec<[f64; 3]>, Vec<[f64; 3]>, Vec<[f64; 6]>) {
let l = &mesh.layout;
((0..l.nodes()).map(|n| directions(full, l, n)).collect(), (0..mesh.points.len()).map(|k| directions(full, l, l.point_slot(k))).collect())
let (translation, rotation) = (0..l.nodes())
.map(|n| {
let [a, b, c, d, e, f] = directions::<6>(full, l, n);
([a, b, c], [d, e, f])
})
.unzip();
(translation, rotation, (0..mesh.points.len()).map(|k| directions(full, l, l.point_slot(k))).collect())
}
/// The matrix pattern of the mesh (node blocks of its elements, trusses and discrete elements)
@@ -252,7 +297,7 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
let resolved = model.resolve()?;
let step = model.select_step(step, |p| matches!(p, Procedure::Static {}), "static")?;
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).chain(mesh.warnings.iter().cloned()).collect();
warnings.extend(inelastic_warnings(model));
let (graph, order) = pattern(&mesh);
ignored_dashpots(&mesh, &mut warnings);
@@ -272,9 +317,9 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
if u.iter().any(|x| !x.is_finite()) {
return Err(Error::Singular("the solution is not finite; the model is ill-conditioned".into()));
}
let (displacement, point_motion) = split(&u, &mesh);
let (displacement, rotation, point_motion) = split(&u, &mesh);
let reactions = red.reactions(&k, &u, &f);
let (reaction, point_reaction) = split(&reactions, &mesh);
let (reaction, reaction_moment, point_reaction) = split(&reactions, &mesh);
let (mut applied_total, mut reaction_total) = ([0.0; 3], [0.0; 3]);
for (dof, (fv, rv)) in f.iter().zip(&reactions).enumerate() {
let Some(d) = mesh.kind(dof).and_then(DofKind::direction) else { continue };
@@ -337,6 +382,31 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
elements.push(ElementStress { label: t.label, kind: t.kind, points, nodal });
}
elements.sort_by_key(|e| e.label);
let (mut shells, mut beams) = (Vec::new(), Vec::new());
let per_volume = structural_body_forces(model, &resolved, &mesh, step)?;
for (s, body) in mesh.structural.iter().zip(&per_volume) {
let at = |err| Error::element(s.label, err);
let dofs = mesh
.layout
.element_dofs(&s.nodes, Structural::KINDS)
.ok_or_else(|| Error::Internal(format!("element {} degrees of freedom", s.label)))?;
let ue: Vec<f64> = dofs.iter().map(|d| u.get(*d).copied().unwrap_or(0.0)).collect();
match s.kernel()? {
StructuralKernel::Shell(shell) => {
let stress = shell.stresses(&ue).map_err(at)?;
for (n, v) in s.nodes.iter().zip(&stress.top) {
if let (Some(acc), Some(c)) = (sum.get_mut(*n), count.get_mut(*n)) {
for k in 0..6 {
acc[k] += v[k];
}
*c += 1;
}
}
shells.push(ShellResult { label: s.label, kind: s.kind, stress });
}
StructuralKernel::Beam(beam) => beams.push(BeamResult { label: s.label, kind: s.kind, forces: beam.forces(&ue, *body).map_err(at)? }),
}
}
let stress: Vec<[f64; 6]> = sum.iter().zip(&count).map(|(s, c)| s.map(|v| if *c > 0 { v / f64::from(*c) } else { 0.0 })).collect();
let von_mises = stress.iter().map(von_mises).collect();
let scale = applied_total.iter().chain(&reaction_total).fold(0.0f64, |m, v| m.max(v.abs()));
@@ -349,10 +419,14 @@ pub fn solve_static(model: &Model, step: Option<&str>, limits: &Limits) -> Resul
step: step.name.clone(),
nodes: mesh.labels.clone(),
displacement,
rotation,
reaction,
reaction_moment,
stress,
von_mises,
elements,
shells,
beams,
reference_points,
applied_total,
reaction_total,
@@ -398,7 +472,7 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
return Err(Error::Invalid(format!("the shift must be a finite number, got {s}")));
}
let mesh = Mesh::new(model, &resolved, limits.max_dofs)?;
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).collect();
let mut warnings: Vec<String> = resolved.warnings.iter().map(ToString::to_string).chain(mesh.warnings.iter().cloned()).collect();
warnings.extend(inelastic_warnings(model));
let (graph, order) = pattern(&mesh);
ignored_dashpots(&mesh, &mut warnings);
@@ -406,14 +480,18 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
let red = reduction(&setup, false, &order, budget(limits, &mesh, &graph, 2), &mut warnings)?;
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.
// (the mean over the directions for a point mass given per direction; rotary inertia is left
// out), added in storage order.
let translation = |d: usize| mesh.kind(d).and_then(DofKind::direction).is_some_and(|c| c < 3);
// Every element's mass entries between translations sum to its mass, once per direction
// (the mean over the directions for a point mass given per direction; rotary inertia, and
// the translation–rotation coupling of shells and beams, are left out), added in storage
// order.
let translation: Vec<bool> = (0..m.n).map(|d| mesh.kind(d).and_then(DofKind::direction).is_some_and(|c| c < 3)).collect();
let mut translational = 0.0;
for d in (0..m.n).filter(|d| translation(*d)) {
for v in m.row(d).1 {
translational += v;
for d in (0..m.n).filter(|d| translation.get(*d).copied().unwrap_or(false)) {
let (cols, vals) = m.row(d);
for (c, v) in cols.iter().zip(vals) {
if translation.get(*c).copied().unwrap_or(false) {
translational += v;
}
}
}
let total_mass = translational / mesh.translation_kinds().len() as f64;
@@ -468,11 +546,12 @@ pub fn solve_frequency(model: &Model, step: Option<&str>, options: &FrequencyOpt
let mut out = Vec::with_capacity(pairs.values.len());
for (lambda, phi) in pairs.values.iter().zip(&pairs.vectors) {
let participation = [0, 1, 2].map(|d| mr.get(d).map_or(0.0, |y| y.iter().zip(phi).map(|(a, b)| a * b).sum()));
let (shape, point_shape) = split(&red.expand(phi, false), &mesh);
let (shape, rotation, point_shape) = split(&red.expand(phi, false), &mesh);
out.push(Mode {
eigenvalue: *lambda,
frequency: lambda.max(0.0).sqrt() / two_pi,
shape,
rotation,
participation,
effective_mass: participation.map(|g| g * g),
point_shape,
@@ -501,6 +580,8 @@ pub enum Location {
/// The result of one step, as a session keeps it.
#[derive(Debug, Clone, PartialEq)]
// One per solved step; boxing the larger variant would buy nothing.
#[allow(clippy::large_enum_variant)]
pub enum StepResult {
Static(StaticResult),
Frequency(FrequencyResult),
+13 -3
View File
@@ -10,7 +10,8 @@
//! D. S. Malkus, M. E. Plesha, R. J. Witt, *Concepts and Applications of Finite Element
//! Analysis*, 4th ed., Wiley 2002, §9.2). The reference point's rotations take part through the
//! lever arms, so a moment on it reaches the face as a couple even when only translations are
//! coupled; coupled rotations have nothing to act on at solid nodes, which have none.
//! coupled. Coupled rotations tie the rotations of shell and beam nodes, θ_s = θ_r; solid
//! nodes have none.
//! - **Distributing** (an interpolation constraint): the reference point moves with the rigid
//! motion that fits the set's nodal translations best in the least-squares sense, with equal
//! weights (as the model and the deck writers define it). About the nodes' centroid c, with
@@ -50,8 +51,8 @@
use std::collections::BTreeMap;
use modelcraft_fem::DofKind;
use modelcraft_fem::dense::inverse3;
use modelcraft_fem::{DofKind, DofSet};
use modelcraft_model::{CouplingKind, Model, Resolved};
use crate::sparse::Csr;
@@ -113,7 +114,8 @@ pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) ->
let coupled = |range: std::ops::Range<usize>| range.filter(|k| c.dofs.contains(*k as u8 + 1));
match c.kind {
CouplingKind::Kinematic => {
if coupled(0..3).next().is_none() {
let rotations = |n: usize| mesh.layout.kinds(n).includes(DofSet::ROTATIONS);
if coupled(0..3).next().is_none() && !(nodes.iter().any(|n| rotations(*n)) && coupled(3..6).next().is_some()) {
return Err(Error::Invalid(format!(
"coupling `{name}` couples only rotations, which the nodes of solid elements do not have; couple translations too"
)));
@@ -133,6 +135,14 @@ pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) ->
}
rows.push(Row { terms, pivot, coupling: name });
}
// Shell and beam nodes also turn with the point: θ_s = θ_r.
if rotations(n) {
for k in coupled(3..6) {
let pivot =
DofKind::from_direction(k).ok_or_else(|| Error::Internal("direction".into())).and_then(|kind| mesh.dof(n, kind))?;
rows.push(Row { terms: vec![(pivot, 1.0), (rp(k)?, -1.0)], pivot, coupling: name });
}
}
}
}
CouplingKind::Distributing => {
+199 -5
View File
@@ -7,15 +7,19 @@
//! 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::beam::{Beam, BeamSection};
use modelcraft_fem::dense::Matrix;
use modelcraft_fem::element::{Formulation, Solid};
use modelcraft_fem::plane::{Plane, Theory};
use modelcraft_fem::shell::{Shell, ShellSection};
use modelcraft_fem::truss::{Truss, young_modulus};
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, centrifugal_acceleration};
use modelcraft_model::{ElementKind, Integration, Load, Model, Resolved, SectionKind, Step, Target, centrifugal_acceleration};
use crate::analysis::MassKind;
use crate::{Error, Result};
/// An element ready for the kernels: a solid, or a 2D element (`theory`).
@@ -138,6 +142,79 @@ impl Bar {
}
}
/// What a shell or beam element is made of.
#[derive(Debug, Clone, Copy)]
pub enum Section {
Shell(ShellSection),
Beam { section: BeamSection, orientation: [f64; 3] },
}
/// A shell or beam element ready for its kernel: six degrees of freedom per node.
#[derive(Debug, Clone)]
pub struct Structural {
pub label: u32,
pub kind: ElementKind,
/// Indices into [`Mesh::labels`] (layout slots).
pub nodes: Vec<usize>,
pub xyz: Vec<[f64; 3]>,
pub density: Option<f64>,
pub section: Section,
}
/// The kernel of a shell or beam element.
#[derive(Debug, Clone)]
pub enum StructuralKernel {
Shell(Shell),
Beam(Beam),
}
impl Structural {
/// The unknowns every node of a shell or beam carries.
pub const KINDS: DofSet = DofSet::STRUCTURAL;
pub fn kernel(&self) -> Result<StructuralKernel> {
let k = match self.section {
Section::Shell(s) => Shell::new(self.kind, &self.xyz, s).map(StructuralKernel::Shell),
Section::Beam { section, orientation } => Beam::new(&self.xyz, orientation, section).map(StructuralKernel::Beam),
};
k.map_err(|e| Error::element(self.label, e))
}
}
impl StructuralKernel {
pub fn stiffness(&self) -> modelcraft_fem::Result<Matrix> {
match self {
StructuralKernel::Shell(s) => s.stiffness(),
StructuralKernel::Beam(b) => Ok(b.stiffness()),
}
}
/// The consistent or lumped mass over every element degree of freedom.
pub fn mass(&self, density: f64, kind: MassKind) -> modelcraft_fem::Result<Matrix> {
Ok(match (self, kind) {
(StructuralKernel::Shell(s), MassKind::Consistent) => s.mass(density)?,
(StructuralKernel::Shell(s), MassKind::Lumped) => {
let diagonal = s.lumped_mass(density)?;
let mut m = Matrix::zeros(diagonal.len());
for (i, v) in diagonal.iter().enumerate() {
m.add(i, i, *v);
}
m
}
(StructuralKernel::Beam(b), MassKind::Consistent) => b.mass(density),
(StructuralKernel::Beam(b), MassKind::Lumped) => b.lumped_mass(density),
})
}
/// Consistent nodal forces and moments of a uniform force per volume, six per node.
pub fn body_force(&self, b: [f64; 3]) -> modelcraft_fem::Result<Vec<f64>> {
match self {
StructuralKernel::Shell(s) => s.body_force(b),
StructuralKernel::Beam(beam) => Ok(beam.body_force(b)),
}
}
}
/// The translations of a model's nodes: X and Y in a 2D model, else X, Y and Z. Trusses and
/// translational discrete elements declare these.
pub fn translation_kinds(planar: bool) -> DofSet {
@@ -177,6 +254,10 @@ pub struct Mesh {
/// The 2D theory when this is a 2D model (a model is 2D as a whole).
pub theory: Option<Theory>,
pub trusses: Vec<Bar>,
/// Shells and beams, by label.
pub structural: Vec<Structural>,
/// What the solver changed or ignored in the model's sections.
pub warnings: Vec<String>,
/// Point masses, springs and dashpots ([`crate::discrete`]).
pub discrete: crate::discrete::Discrete,
}
@@ -190,6 +271,11 @@ impl Mesh {
self.trusses.binary_search_by_key(&label, |e| e.label).ok().and_then(|i| self.trusses.get(i))
}
/// The index of a shell or beam element in [`Self::structural`].
pub fn structural_index(&self, label: u32) -> Option<usize> {
self.structural.binary_search_by_key(&label, |e| e.label).ok()
}
/// The kinds trusses and translational discrete elements carry at their nodes.
pub fn translation_kinds(&self) -> DofSet {
translation_kinds(self.theory.is_some())
@@ -239,6 +325,8 @@ impl Mesh {
pub fn new(model: &Model, resolved: &Resolved, max_dofs: usize) -> Result<Mesh> {
let mut elements = Vec::new();
let mut trusses = Vec::new();
let mut structural = Vec::new();
let mut warnings = 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));
if matches!(planar, Some(Theory::Axisymmetric))
@@ -257,7 +345,11 @@ impl Mesh {
SectionKind::Rod { area } => Some(area),
_ => None,
};
if !matches!(section.kind, SectionKind::Solid {}) && theory.is_none() && rod.is_none() {
let shell_or_beam = matches!(section.kind, SectionKind::Shell { .. } | SectionKind::Beam { .. });
if shell_or_beam && planar.is_some() {
return Err(Error::NotSupported(format!("{} sections in 2D models (section `{name}`)", section.kind.name())));
}
if !matches!(section.kind, SectionKind::Solid {}) && theory.is_none() && rod.is_none() && !shell_or_beam {
return Err(Error::NotSupported(format!(
"{} sections (section `{name}`); the solver has solid, 2D (plane stress, plane strain, axisymmetric) and truss elements so far",
section.kind.name()
@@ -272,6 +364,31 @@ impl Mesh {
let elastic =
material.elastic.as_ref().ok_or_else(|| Error::Invalid(format!("material `{}` has no elastic constants", section.material)))?;
let set = resolved.sets.get(&section.set).ok_or_else(|| Error::Invalid(format!("set `{}` is not resolved", section.set)))?;
let bad = |e: modelcraft_fem::Error| match e {
modelcraft_fem::Error::NotSupported(m) => Error::NotSupported(format!("section `{name}`: {m}")),
e => Error::Invalid(format!("section `{name}`, material `{}`: {e}", section.material)),
};
let shared = match &section.kind {
SectionKind::Shell { thickness, offset, .. } => {
if section.formulation.integration == Integration::Reduced {
warnings.push(format!(
"section `{name}`: shells are MITC elements with full integration; reduced integration is solved the same way"
));
}
Some(Section::Shell(ShellSection::new(*thickness, *offset, elastic).map_err(bad)?))
}
SectionKind::Beam { profile, orientation } => {
Some(Section::Beam { section: BeamSection::new(profile, elastic).map_err(bad)?, orientation: *orientation })
}
_ => None,
};
if let Some(body) = shared {
for &label in &set.elements {
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
structural.push((label, el.kind, el.nodes.clone(), material.density, body));
}
continue;
}
if let Some(area) = rod {
let young =
young_modulus(elastic).map_err(|e| Error::Invalid(format!("material `{}` of section `{name}`: {e}", section.material)))?;
@@ -297,12 +414,14 @@ impl Mesh {
}
elements.sort_by_key(|e| e.0);
trusses.sort_by_key(|e| e.0);
structural.sort_by_key(|e| e.0);
let translations = translation_kinds(planar.is_some());
// What discrete elements need: at mesh nodes (some only they use), and at reference
// points, which then get their six degrees of freedom like coupled ones.
let needs: Vec<(u32, DofSet)> = crate::discrete::needs(resolved, translations).collect();
let mut labels: Vec<u32> = elements.iter().flat_map(|e| e.2.iter().copied()).collect();
labels.extend(trusses.iter().flat_map(|e| e.2.iter().copied()));
labels.extend(structural.iter().flat_map(|e| e.2.iter().copied()));
labels.extend(needs.iter().map(|n| n.0).filter(|l| model.node(*l).is_some()));
labels.sort_unstable();
labels.dedup();
@@ -323,6 +442,9 @@ impl Mesh {
for e in &trusses {
layout.element(&e.2, modelcraft_fem::ElementDofs::new(translations));
}
for e in &structural {
layout.element(&e.2, ElementDofs::new(Structural::KINDS));
}
for (label, kinds) in needs.iter().filter(|n| model.node(n.0).is_some()) {
layout.node(*label, *kinds);
}
@@ -346,6 +468,8 @@ impl Mesh {
layout,
theory: planar,
trusses: Vec::with_capacity(trusses.len()),
structural: Vec::with_capacity(structural.len()),
warnings,
discrete: crate::discrete::Discrete::default(),
};
for (label, kind, nodes, formulation, d, density, theory, _) in elements {
@@ -358,6 +482,11 @@ impl Mesh {
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
mesh.trusses.push(Bar { label, kind, nodes: idx, xyz: exyz, young, area, density });
}
for (label, kind, nodes, density, section) in structural {
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.structural.push(Structural { label, kind, nodes: idx, xyz: exyz, density, section });
}
mesh.discrete = crate::discrete::Discrete::new(&mesh, resolved)?;
Ok(mesh)
}
@@ -365,7 +494,12 @@ impl Mesh {
/// The layout slots (mesh nodes, reference points) every element and discrete element
/// couples, for the matrix pattern.
pub fn blocks(&self) -> impl Iterator<Item = std::borrow::Cow<'_, [usize]>> {
let elements = self.elements.iter().map(|e| e.nodes.as_slice()).chain(self.trusses.iter().map(|t| t.nodes.as_slice()));
let elements = self
.elements
.iter()
.map(|e| e.nodes.as_slice())
.chain(self.trusses.iter().map(|t| t.nodes.as_slice()))
.chain(self.structural.iter().map(|s| s.nodes.as_slice()));
elements.map(std::borrow::Cow::Borrowed).chain(self.discrete.couplings().map(|dofs| {
let mut slots: Vec<usize> = dofs.iter().filter_map(|d| self.layout.locate(*d).map(|(slot, _)| slot)).collect();
slots.dedup();
@@ -491,13 +625,16 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
"load `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh and no spring or point mass uses"
)));
}
// Moments act on the rotations, which only reference points have.
// Moments act on the rotations, which reference points and the nodes of shells
// and beams have.
let rotation = matches!(load, Load::Moment { .. });
for (label, v) in resolved.nodal_loads.get(name).map(Vec::as_slice).unwrap_or_default() {
let slot =
mesh.layout.slot(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: node {label} belongs to no solid element")))?;
let dofs = vector_dofs(mesh, slot, rotation).ok_or_else(|| {
Error::Invalid(format!("moment `{name}`: solid nodes have no rotations; apply it to a coupled reference point"))
Error::Invalid(format!(
"moment `{name}`: node {label} has no rotations (only shell and beam nodes do); apply it to a coupled reference point"
))
})?;
for (&dof, x) in dofs.iter().zip(v) {
if *x != 0.0 && !active.get(dof).copied().unwrap_or(false) {
@@ -564,6 +701,13 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
let rho = t.density.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} has no density", t.label)))?;
bar_body(&mut f, mesh, t, acceleration.map(|a| rho * a))?;
}
for e in &mesh.structural {
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)))?;
structural_body(&mut f, mesh, e, acceleration.map(|a| rho * a))?;
}
// Point masses take gravity on the whole model.
if set.is_none() {
mesh.discrete.add_gravity(&mut f, *acceleration, mesh);
@@ -575,6 +719,10 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
bar_body(&mut f, mesh, t, *vector)?;
continue;
}
if let Some(e) = mesh.structural_index(*label).and_then(|i| mesh.structural.get(i)) {
structural_body(&mut f, mesh, e, *vector)?;
continue;
}
let e = mesh.element(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: element {label} is not a solid element")))?;
body(&mut f, mesh, e, *vector)?;
}
@@ -584,6 +732,9 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
if let Some(t) = mesh.trusses.iter().find(|t| chosen.is_none_or(|c| c.binary_search(&t.label).is_ok())) {
return Err(Error::NotSupported(format!("centrifugal loads on trusses (load `{name}`, element {})", t.label)));
}
if let Some(e) = mesh.structural.iter().find(|e| chosen.is_none_or(|c| c.binary_search(&e.label).is_ok())) {
return Err(Error::NotSupported(format!("centrifugal loads on shells and beams (load `{name}`, element {})", e.label)));
}
if set.is_none() && !mesh.discrete.masses.is_empty() {
return Err(Error::NotSupported(format!("centrifugal loads on point masses (load `{name}`)")));
}
@@ -615,6 +766,49 @@ fn bar_body(f: &mut [f64], mesh: &Mesh, t: &Bar, b: [f64; 3]) -> Result<()> {
Ok(())
}
/// Consistent forces and moments of a uniform force per volume on a shell or beam.
fn structural_body(f: &mut [f64], mesh: &Mesh, e: &Structural, b: [f64; 3]) -> Result<()> {
let forces = e.kernel()?.body_force(b).map_err(|err| Error::element(e.label, err))?;
let dofs =
mesh.layout.element_dofs(&e.nodes, Structural::KINDS).ok_or_else(|| Error::Internal(format!("element {} degrees of freedom", e.label)))?;
for (d, v) in dofs.iter().zip(&forces) {
if let Some(x) = f.get_mut(*d) {
*x += v;
}
}
Ok(())
}
/// The uniform force per volume a step's gravity and body forces put on each shell and beam
/// element ([`Mesh::structural`] order): beam end forces are recovered with it.
pub fn structural_body_forces(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Result<Vec<[f64; 3]>> {
let mut out = vec![[0.0; 3]; mesh.structural.len()];
for name in &step.loads {
let load = model.load(name).ok_or_else(|| Error::Invalid(format!("load `{name}` is missing")))?;
match load {
Load::Gravity { acceleration, 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, b) in mesh.structural.iter().zip(out.iter_mut()) {
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)))?;
*b = [0, 1, 2].map(|k| b[k] + rho * acceleration[k]);
}
}
Load::BodyForce { set, vector } => {
for label in resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default() {
if let Some(b) = mesh.structural_index(*label).and_then(|i| out.get_mut(i)) {
*b = [0, 1, 2].map(|k| b[k] + vector[k]);
}
}
}
_ => {}
}
}
Ok(out)
}
fn body(f: &mut [f64], mesh: &Mesh, e: &Elem, b: [f64; 3]) -> Result<()> {
let forces = e.kernel()?.body_force(b).map_err(|err| Error::element(e.label, err))?;
for (node, v) in e.nodes.iter().zip(forces) {