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

+142
View File
@@ -0,0 +1,142 @@
//! Local frames of structural elements (shells, beams, rods) and the rotation of their matrices
//! between the element's local axes and the global ones.
//!
//! A frame is a rotation R whose rows are the local axes e₁, e₂, e₃ in global components, so
//! local components are a_l = R a_g. Element degrees of freedom come in groups of three (the
//! translations, then the rotations, of each node), each rotated by R, so the element matrix is
//! K_g = Tᵀ K_l T with T = diag(R, R, …) (Cook, Malkus, Plesha & Witt 2002, §2.10 and §7.4).
use crate::dense::Matrix;
pub type Vec3 = [f64; 3];
/// The rows e₁, e₂, e₃ of a rotation from global to local components.
pub type Axes = [Vec3; 3];
pub fn sub(a: Vec3, b: Vec3) -> Vec3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
pub fn dot(a: Vec3, b: Vec3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
pub fn cross(a: Vec3, b: Vec3) -> Vec3 {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
pub fn norm(a: Vec3) -> f64 {
dot(a, a).sqrt()
}
/// The unit vector along `a`, or `None` when it is zero or not finite.
pub fn unit(a: Vec3) -> Option<Vec3> {
let n = norm(a);
(n > 0.0 && n.is_finite()).then(|| a.map(|c| c / n))
}
/// R a: global components to local.
pub fn to_local(r: &Axes, a: Vec3) -> Vec3 {
[dot(r[0], a), dot(r[1], a), dot(r[2], a)]
}
/// Rᵀ a: local components to global.
pub fn to_global(r: &Axes, a: Vec3) -> Vec3 {
let mut g = [0.0; 3];
for (k, axis) in r.iter().enumerate() {
for c in 0..3 {
g[c] += axis[c] * a[k];
}
}
g
}
/// Tᵀ K T for a symmetric matrix whose degrees of freedom come in groups of three local
/// components; the result is exactly symmetric.
pub fn rotate_matrix(k: &Matrix, r: &Axes) -> Matrix {
let n = k.n;
let groups = n / 3;
let mut out = Matrix::zeros(n);
for a in 0..groups {
for b in a..groups {
// Rᵀ K_ab R, mirrored to (b, a): K is symmetric.
let mut kr = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
kr[i][j] = (0..3).map(|m| k.get(3 * a + i, 3 * b + m) * r[m][j]).sum();
}
}
for i in 0..3 {
for j in 0..3 {
let v: f64 = (0..3).map(|m| r[m][i] * kr[m][j]).sum();
out.add(3 * a + i, 3 * b + j, v);
if a != b {
out.add(3 * b + j, 3 * a + i, v);
}
}
}
}
}
crate::element::symmetrize(&mut out);
out
}
/// T v: a vector of global components in groups of three to local components.
pub fn rotate_to_local(v: &[f64], r: &Axes) -> Vec<f64> {
v.as_chunks::<3>().0.iter().flat_map(|g| to_local(r, *g)).collect()
}
/// Tᵀ v: local components in groups of three to global ones.
pub fn rotate_to_global(v: &[f64], r: &Axes) -> Vec<f64> {
v.as_chunks::<3>().0.iter().flat_map(|g| to_global(r, *g)).collect()
}
/// A uniform translation `b` of `nodes` nodes with six degrees of freedom each (rotations
/// zero): the nodal field whose product with a consistent mass gives body-force loads.
pub fn translation(b: Vec3, nodes: usize) -> Vec<f64> {
(0..nodes).flat_map(|_| [b[0], b[1], b[2], 0.0, 0.0, 0.0]).collect()
}
/// A symmetric tensor in local axes, [[xx, xy, xz], [xy, yy, yz], [xz, yz, zz]], in global
/// components as the 6-vector xx, yy, zz, xy, xz, yz: Rᵀ S R.
pub fn tensor_to_global(r: &Axes, s: [[f64; 3]; 3]) -> [f64; 6] {
let mut g = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
g[i][j] = (0..3).map(|a| (0..3).map(|b| r[a][i] * s[a][b] * r[b][j]).sum::<f64>()).sum();
}
}
[g[0][0], g[1][1], g[2][2], g[0][1], g[0][2], g[1][2]]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rotations_round_trip() {
let e1 = unit([1.0, 1.0, 0.0]).unwrap();
let e3 = [0.0, 0.0, 1.0];
let r: Axes = [e1, cross(e3, e1), e3];
let v = [0.3, -1.2, 2.0];
let back = to_global(&r, to_local(&r, v));
assert!(back.iter().zip(v).all(|(a, b)| (a - b).abs() < 1e-15));
// A uniaxial stress along e1 is xx = yy = xy = σ/2 globally.
let s = tensor_to_global(&r, [[2.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]);
assert!((s[0] - 1.0).abs() < 1e-15 && (s[1] - 1.0).abs() < 1e-15 && (s[3] - 1.0).abs() < 1e-15 && s[2] == 0.0, "{s:?}");
// Rotating a matrix keeps its quadratic form: (T v)ᵀ K (T v) = vᵀ (Tᵀ K T) v.
let mut k = Matrix::zeros(6);
for i in 0..6 {
for j in 0..6 {
k.data[i * 6 + j] = 1.0 / (1.0 + i as f64 + j as f64);
}
}
let g = rotate_matrix(&k, &r);
let x = [0.1, 0.2, -0.3, 0.7, 0.0, 1.0];
let xl = rotate_to_local(&x, &r);
let ql: f64 = (0..6).map(|i| (0..6).map(|j| xl[i] * k.get(i, j) * xl[j]).sum::<f64>()).sum();
let qg: f64 = (0..6).map(|i| (0..6).map(|j| x[i] * g.get(i, j) * x[j]).sum::<f64>()).sum();
assert!((ql - qg).abs() < 1e-14, "{ql} {qg}");
assert!(unit([0.0; 3]).is_none() && unit([f64::NAN, 0.0, 0.0]).is_none());
}
}