1D elements I: trusses (rod2, rod3), point masses, rotary inertia, springs and dashpots (#38)

* Model, fem and decks: point masses, rotary inertia, springs, dashpots and rod3 trusses

Model: PointMass and Spring definitions (spring | dashpot; to ground or
two-point; along a degree of freedom, a vector or the line of the ends) on a
set or reference point, resolved per node; ElementKind::Rod3. fem: rod2/rod3
truss kernel on Basis::Line2/Line3 declaring ElementDofs (translations).
Decks: Abaqus T3D3, MASS, ROTARYI, SPRING1/2/A, DASHPOT1/2/A; Nastran CONM1,
CELAS2, CDAMP2; LS-DYNA ELEMENT_MASS/INERTIA and ELEMENT_DISCRETE; what a
format cannot take is reported. Commands model.mass.* and model.spring.*.

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

* Implicit solver: trusses, point masses, rotary inertia and springs on the DofLayout

Trusses declare their translations like solids; point masses, inertia and
springs ask the layout for the kinds they need at their nodes (translations,
rotations), and reference points they use get six degrees of freedom. The
matrix pattern takes rows for those points; dashpots are ignored with a
warning. Existing results are bit-identical (hash-checked).

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

* Explicit solver: trusses, point masses, springs and dashpots on the node-major layout

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

* docs: fem.md on trusses and discrete elements in the layout; regenerate features.md

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

* docs: trusses in plane models and corotational explicit trusses

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 21:36:46 -04:00
1 parent 5e5a2ae251
commit 15a9ac9157
53 files changed
+3626 -110

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
//! Trusses, point masses, springs and dashpots in explicit dynamics.
//!
//! - **Trusses** (rod2, rod3), elastic, with lumped (HRZ) mass. With nonlinear geometry a rod2
//! is corotational: its strain is (l − L)/L from its current length l and its force acts along
//! its current axis (Belytschko et al. 2014, §2.4 and §4.5); a rod3 then is not supported yet.
//! Without it the internal force is K u with the kernel's stiffness ([`modelcraft_fem::truss`]).
//! The stress is the uniaxial tensor along the axis (the mean of the integration points'), and
//! the critical increment comes from the kernel's frequency bound (exact for rod2: L/c).
//! - **Point masses** add to the lumped mass of their node, per direction.
//! - **Springs** add the force k (cᵀ Δu) c and **dashpots** c_d (cᵀ Δv) c, with Δu, Δv the
//! relative motion of the ends along the unit direction c (a fixed direction; one along the line
//! of the ends follows that line as it turns under nonlinear geometry, with the stretch
//! l − L₀); dashpots use the velocity of the
//! previous half increment, v^{n−½}, as the bulk viscosity does (Belytschko, Liu, Moran,
//! Elkhodary 2014, §6.2). Spring work counts as internal energy, dashpot work as viscous
//! (dissipated) energy.
//! - **Stable increment** of springs and dashpots, per degree of freedom: with
//! ω² ≤ Σⱼ |Kᵢⱼ| / √(mᵢ mⱼ) (Gershgorin's bound on M^{-1/2} K M^{-1/2}, exact for one spring on
//! one mass) and the same row sum D of the damping, the central-difference scheme with lagged
//! damping is stable for Δt ≤ (2/ω)(√(1 + ξ²) − ξ) with ξ = D / (2ω) (Belytschko et al. 2014,
//! §6.3), which is 2/D for a dashpot alone. Rotary inertia and rotational springs need
//! rotational degrees of freedom, which this solver does not have yet.
use modelcraft_fem::DofKind;
use modelcraft_fem::dense::Matrix;
use modelcraft_fem::truss::{Truss, uniaxial};
use modelcraft_model::{ElementKind, Resolved, ResolvedDirection};
use crate::system::NODE_DOFS;
use crate::{Error, Result};
/// A truss element.
#[derive(Debug, Clone)]
pub struct Rod {
pub label: u32,
/// Position in [`crate::system::System::elements`].
pub index: usize,
pub nodes: Vec<usize>,
pub material: usize,
pub young: f64,
/// Stiffness on the element's translations, node by node.
pub stiffness: Matrix,
/// Per integration point: the axial strain operator and the tangent.
pub strain: Vec<(Vec<f64>, [f64; 3])>,
/// The lumped mass per node for unit density.
pub nodal_volume: Vec<f64>,
/// The stable length: the critical increment is `length / wave_speed`.
pub length: f64,
/// E A and the rest length (a rod2's corotational force).
pub axial: f64,
pub rest_length: f64,
}
/// What a truss is made of: Young's modulus, density and cross-sectional area.
#[derive(Debug, Clone, Copy)]
pub struct RodSection {
pub young: f64,
pub density: f64,
pub area: f64,
}
impl Rod {
/// `material` is the index in [`crate::system::System::materials`].
pub fn new(
label: u32,
index: usize,
kind: ElementKind,
nodes: Vec<usize>,
xyz: &[[f64; 3]],
material: usize,
section: RodSection,
) -> Result<Rod> {
let RodSection { young, density, area } = section;
let at = |e| Error::element(label, e);
let t = Truss::new(kind, xyz).map_err(at)?;
let omega2 = t.frequency_bound(young, density).map_err(at)?;
let wave_speed = (young / density).sqrt();
Ok(Rod {
label,
index,
nodes,
material,
young,
stiffness: t.stiffness(young, area).map_err(at)?,
strain: t.strain_operator().map_err(at)?,
nodal_volume: t.lumped_mass(1.0, area).map_err(at)?,
length: 2.0 * wave_speed / omega2.sqrt(),
axial: young * area,
rest_length: t.length().map_err(at)?,
})
}
/// The element's degrees of freedom, node by node.
pub fn dofs(&self) -> impl Iterator<Item = usize> + '_ {
self.nodes.iter().flat_map(|n| modelcraft_fem::truss::DOFS.node.iter().filter_map(DofKind::direction).map(|d| dof(*n, d)))
}
/// Add its internal force into `force` and return the element's mean stress and strain
/// tensors: corotational for a rod2 with nonlinear geometry (`xyz` the rest positions of the
/// system's nodes), K u otherwise.
pub fn forces(&self, xyz: &[[f64; 3]], u: &[f64], nonlinear: bool, force: &mut [f64]) -> ([f64; 6], [f64; 6]) {
if nonlinear && let [a, b] = self.nodes[..] {
let at = |n: usize| -> [f64; 3] {
let x = xyz.get(n).copied().unwrap_or_default();
[0, 1, 2].map(|k| x[k] + u.get(dof(n, k)).copied().unwrap_or(0.0))
};
let (pa, pb) = (at(a), at(b));
let d = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let t = if l > 0.0 { d.map(|c| c / l) } else { [0.0; 3] };
let e = (l - self.rest_length) / self.rest_length;
let n = self.axial * e;
for k in 0..3 {
if let Some(x) = force.get_mut(dof(a, k)) {
*x -= n * t[k];
}
if let Some(x) = force.get_mut(dof(b, k)) {
*x += n * t[k];
}
}
return (uniaxial(self.young * e, t, 1.0), uniaxial(e, t, 2.0));
}
// At most three nodes of three translations: the element vectors live on the stack.
let n = self.stiffness.n.min(MAX_DOFS);
let mut ue = [0.0; MAX_DOFS];
for (x, d) in ue.iter_mut().zip(self.dofs()) {
*x = u.get(d).copied().unwrap_or(0.0);
}
for (i, d) in self.dofs().enumerate().take(n) {
let f: f64 = (0..n).map(|j| self.stiffness.get(i, j) * ue[j]).sum();
if let Some(x) = force.get_mut(d) {
*x += f;
}
}
let (mut stress, mut strain) = ([0.0; 6], [0.0; 6]);
let share = 1.0 / self.strain.len().max(1) as f64;
for (row, t) in &self.strain {
let e: f64 = row.iter().zip(&ue).map(|(a, b)| a * b).sum();
for (k, (s, x)) in uniaxial(self.young * e, *t, 1.0).iter().zip(uniaxial(e, *t, 2.0)).enumerate() {
stress[k] += share * s;
strain[k] += share * x;
}
}
(stress, strain)
}
}
/// Degrees of freedom of the largest truss (rod3: three nodes of three translations).
const MAX_DOFS: usize = 9;
/// The global degree of freedom of translation `d` (0–2) of node `n` in the system's uniform,
/// node-major layout ([`NODE_DOFS`] per node, checked when the system is built).
pub fn dof(n: usize, d: usize) -> usize {
NODE_DOFS * n + d
}
/// The node and the direction (0–2) of a degree of freedom: the inverse of [`dof`].
pub fn node_direction(dof: usize) -> (usize, usize) {
(dof / NODE_DOFS, dof % NODE_DOFS)
}
/// A spring or dashpot on degrees of freedom: the elongation is Σ c u(second) − Σ c u(first).
#[derive(Debug, Clone)]
pub struct Link {
pub name: String,
pub dashpot: bool,
/// (degree of freedom, coefficient) with the first end's coefficients negated.
pub terms: Vec<(usize, f64)>,
pub value: f64,
/// For one along the line of its ends: the two nodes and their rest distance.
pub line: Option<(usize, usize, f64)>,
}
impl Link {
fn stretch(&self, x: &[f64]) -> f64 {
self.terms.iter().map(|(d, c)| c * x.get(*d).copied().unwrap_or(0.0)).sum()
}
/// Add its internal force (spring on `u`, dashpot on `v`; `xyz` the rest positions of the
/// system's nodes, for a line that turns under nonlinear geometry).
pub fn forces(&self, xyz: &[[f64; 3]], u: &[f64], v: &[f64], nonlinear: bool, stiffness: &mut [f64], viscous: &mut [f64]) {
if nonlinear && let Some((a, b, rest)) = self.line {
let at = |n: usize, x: &[f64]| -> [f64; 3] { [0, 1, 2].map(|k| x.get(dof(n, k)).copied().unwrap_or(0.0)) };
let pos = |n: usize| {
let (p, d) = (xyz.get(n).copied().unwrap_or_default(), at(n, u));
[p[0] + d[0], p[1] + d[1], p[2] + d[2]]
};
let (pa, pb) = (pos(a), pos(b));
let d = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let t = if l > 0.0 { d.map(|c| c / l) } else { [0.0; 3] };
let (va, vb) = (at(a, v), at(b, v));
let s = if self.dashpot { self.value * (0..3).map(|k| t[k] * (vb[k] - va[k])).sum::<f64>() } else { self.value * (l - rest) };
let out = if self.dashpot { viscous } else { stiffness };
for k in 0..3 {
if let Some(x) = out.get_mut(dof(a, k)) {
*x -= s * t[k];
}
if let Some(x) = out.get_mut(dof(b, k)) {
*x += s * t[k];
}
}
return;
}
let (x, out) = if self.dashpot { (v, viscous) } else { (u, stiffness) };
let s = self.value * self.stretch(x);
for (d, c) in &self.terms {
if let Some(f) = out.get_mut(*d) {
*f += s * c;
}
}
}
}
/// Springs, dashpots and point masses of a model on the explicit system's nodes.
#[derive(Debug, Clone, Default)]
pub struct Discrete {
pub links: Vec<Link>,
/// Point mass per degree of freedom.
pub mass: Vec<f64>,
}
impl Discrete {
/// `node` maps a label to its node index.
/// `node` maps a label to its node index, `xyz` gives the nodes' rest positions.
pub fn new(resolved: &Resolved, dofs: usize, xyz: &[[f64; 3]], node: impl Fn(u32) -> Option<usize>) -> Result<Discrete> {
let off = |what: &str, name: &str, label: u32| {
Error::NotSupported(format!(
"{what} `{name}` at node {label}: explicit dynamics takes discrete elements on mesh nodes (not reference points) so far"
))
};
let mut out = Discrete { links: Vec::new(), mass: vec![0.0; dofs] };
for m in &resolved.masses {
if m.inertia.iter().any(|c| *c != 0.0) {
return Err(Error::NotSupported(format!("rotary inertia (point mass `{}`) in explicit dynamics: it has no rotations yet", m.name)));
}
let n = node(m.node).ok_or_else(|| off("point mass", &m.name, m.node))?;
for d in 0..3 {
if let Some(x) = out.mass.get_mut(dof(n, d)) {
*x += m.mass[d];
}
}
}
for s in &resolved.springs {
let what = s.kind();
if s.direction.is_rotational() {
return Err(Error::NotSupported(format!("rotational {what}s (`{}`) in explicit dynamics: it has no rotations yet", s.name)));
}
let c = s.direction.coefficients();
let mut terms = Vec::new();
let mut ends = Vec::new();
for (label, sign) in std::iter::once((s.first, -1.0)).chain(s.second.map(|b| (b, 1.0))) {
let n = node(label).ok_or_else(|| off(what, &s.name, label))?;
ends.push(n);
terms.extend((0..3).filter(|d| c[*d] != 0.0).map(|d| (dof(n, d), sign * c[d])));
}
let line = match (s.direction, ends.as_slice()) {
(ResolvedDirection::Line(_), [a, b]) => {
let (pa, pb) = (xyz.get(*a).copied().unwrap_or_default(), xyz.get(*b).copied().unwrap_or_default());
Some((*a, *b, ((pb[0] - pa[0]).powi(2) + (pb[1] - pa[1]).powi(2) + (pb[2] - pa[2]).powi(2)).sqrt()))
}
_ => None,
};
out.links.push(Link { name: s.name.clone(), dashpot: s.dashpot, terms, value: s.value, line });
}
Ok(out)
}
/// The smallest stable increment of the springs and dashpots on the lumped mass `mass` (per
/// degree of freedom), and the name of a link at the degree of freedom that sets it. A link
/// along its turning line (`nonlinear`) is bounded for every direction it may take.
pub fn critical_increment(&self, mass: &[f64], nonlinear: bool) -> Option<(f64, String)> {
let mut stiff = vec![0.0; mass.len()];
let mut damp = vec![0.0; mass.len()];
let mut owner: Vec<Option<usize>> = vec![None; mass.len()];
for (k, l) in self.links.iter().enumerate() {
let rows = if l.dashpot { &mut damp } else { &mut stiff };
let turning: Vec<(usize, f64)>;
let terms = match l.line {
Some((a, b, _)) if nonlinear => {
turning = (0..3).map(|k| (dof(a, k), -1.0)).chain((0..3).map(|k| (dof(b, k), 1.0))).collect();
&turning
}
_ => &l.terms,
};
for (i, ci) in terms {
let mi = mass.get(*i).copied().unwrap_or(0.0);
let sum: f64 = terms.iter().map(|(j, cj)| (l.value * ci * cj).abs() / (mi * mass.get(*j).copied().unwrap_or(0.0)).sqrt()).sum();
if let Some(r) = rows.get_mut(*i) {
*r += sum;
}
if let Some(o) = owner.get_mut(*i) {
o.get_or_insert(k);
}
}
}
let mut best: Option<(f64, usize)> = None;
for (i, (w2, d)) in stiff.iter().zip(&damp).enumerate() {
let dt = match (*w2 > 0.0, *d > 0.0) {
(false, false) => continue,
(false, true) => 2.0 / d,
(true, _) => {
let w = w2.sqrt();
let xi = d / (2.0 * w);
(2.0 / w) * ((1.0 + xi * xi).sqrt() - xi)
}
};
if best.is_none_or(|(b, _)| dt < b) {
best = Some((dt, i));
}
}
let (dt, i) = best?;
let name = owner.get(i).copied().flatten().and_then(|k| self.links.get(k)).map(|l| l.name.clone()).unwrap_or_default();
Some((dt, name))
}
}
+16
View File
@@ -477,6 +477,22 @@ pub fn internal_forces(sys: &System, m: &Motion, state: &mut State, ws: &mut Wor
critical = critical.min(element.finish(ws, &es.point.stress, &inc.v, m.bulk));
}
}
for r in &sys.rods {
let (sigma, strain) = r.forces(&sys.xyz, m.u, sys.nonlinear, &mut ws.stress_force);
if let Some(es) = state.elements.get_mut(r.index) {
es.point.stress = sigma;
es.strain = strain;
}
if let Some(mat) = sys.materials.get(r.material) {
critical = critical.min(r.length / mat.wave_speed);
}
}
for l in &sys.discrete.links {
l.forces(&sys.xyz, m.u, m.v, sys.nonlinear, &mut ws.stress_force, &mut ws.viscous_force);
}
if let Some((dt, _)) = &sys.discrete_increment {
critical = critical.min(*dt);
}
Ok(critical)
}
+27 -13
View File
@@ -133,13 +133,31 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
.iter()
.map(|h| (h.label, h.length, h.material))
.chain(sys.tets.iter().map(|t| (t.label, t.length, t.material)))
.chain(sys.rods.iter().map(|r| (r.label, r.length, r.material)))
.map(|(label, length, m)| ControllingElement { label, increment: length / sys.materials.get(m).map_or(f64::INFINITY, |m| m.wave_speed) })
.collect();
critical.sort_by(|a, b| a.increment.total_cmp(&b.increment).then(a.label.cmp(&b.label)));
critical.truncate(CONTROLLING);
let smallest = critical
.first()
.map(|c| c.increment)
let n = sys.dofs();
let mass = sys.dof_mass();
if let Some(d) = mass.iter().position(|m| !(*m > 0.0 && m.is_finite())) {
let label = sys.labels.get(crate::discrete::node_direction(d).0).copied().unwrap_or(0);
return Err(Error::Invalid(format!(
"node {label} has no mass in direction {}; explicit dynamics needs a mass at every node (give the nodes only springs or dashpots use a point mass)",
crate::discrete::node_direction(d).1 + 1
)));
}
// Springs and dashpots have their own stable increment on the lumped mass.
let elements_smallest = critical.first().map(|c| c.increment);
if let Some((l, name)) = &sys.discrete_increment
&& elements_smallest.is_none_or(|e| *l < e)
{
warnings.push(format!("spring or dashpot `{name}` limits the time increment to {l:.3e} (before the scale factor)"));
}
let smallest = elements_smallest
.into_iter()
.chain(sys.discrete_increment.as_ref().map(|l| l.0))
.reduce(f64::min)
.filter(|d| *d > 0.0 && d.is_finite())
.ok_or_else(|| Error::Invalid("no element has a finite, positive critical time increment (check the material constants)".into()))?;
let estimate = end / (scale * smallest);
@@ -170,12 +188,8 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
)));
}
let n = sys.dofs();
let mass: Vec<f64> = (0..n).map(|d| sys.mass.get(d / NODE_DOFS).copied().unwrap_or(0.0)).collect();
if mass.iter().any(|m| !(*m > 0.0 && m.is_finite())) {
return Err(Error::Internal("a node has no positive mass".into()));
}
let total_mass: f64 = sys.mass.iter().sum();
// Point masses count with their mean over the three directions.
let total_mass: f64 = sys.mass.iter().sum::<f64>() + sys.discrete.mass.iter().sum::<f64>() / 3.0;
let mut ws = Workspace::new(&sys);
let mut previous = Workspace::new(&sys);
let mut state = State::new(&sys);
@@ -211,17 +225,17 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
history.time.push(t);
history.increment.push(dt);
history.energies.push(e);
let sum = |x: &[f64], weight: &dyn Fn(usize) -> f64| {
let sum = |x: &[f64], weight: &dyn Fn(usize, usize) -> f64| {
let mut s = [0.0; 3];
for (node, c) in x.as_chunks::<NODE_DOFS>().0.iter().enumerate() {
for k in 0..3 {
s[k] += weight(node) * c[k];
s[k] += weight(node, k) * c[k];
}
}
s
};
history.momentum.push(sum(v, &|node| sys.mass.get(node).copied().unwrap_or(0.0)));
history.constraint_force.push(sum(r, &|_| 1.0));
history.momentum.push(sum(v, &|node, k| mass.get(crate::discrete::dof(node, k)).copied().unwrap_or(0.0)));
history.constraint_force.push(sum(r, &|_, _| 1.0));
let pick = |x: &[f64]| sys.history_nodes.iter().map(|i| [0, 1, 2].map(|k| x.get(NODE_DOFS * i + k).copied().unwrap_or(0.0))).collect();
history.displacement.push(pick(u));
history.velocity.push(pick(v));
+1
View File
@@ -32,6 +32,7 @@
// Small fixed-size vector kernels read as the textbook formulas with index loops.
#![allow(clippy::needless_range_loop)]
pub mod discrete;
pub mod elements;
pub mod integrate;
pub mod result;
+78 -12
View File
@@ -1,5 +1,6 @@
//! From the resolved model to the explicit system: elements with their materials and stable
//! lengths, lumped masses, prescribed velocities, rigid walls, initial velocities and the load
//! From the resolved model to the explicit system: elements (solids and trusses) with their
//! materials and stable lengths, lumped masses with the point masses, springs and dashpots
//! ([`crate::discrete`]), prescribed velocities, rigid walls, initial velocities and the load
//! vector of an explicit dynamic step.
use modelcraft_fem::material::Elasticity;
@@ -10,6 +11,7 @@ use modelcraft_model::{
Elastic, ElementKind, ExplicitDynamic, HourglassKind, Integration, Load, Model, Resolved, SectionKind, Step, StressRate, Target,
};
use crate::discrete::{Discrete, Rod};
use crate::elements::{HexScales, add_at, frequency_bound, hex_scales};
use crate::{Error, Result};
@@ -97,8 +99,14 @@ pub struct System {
pub materials: Vec<Material>,
pub hexes: Vec<Hex>,
pub tets: Vec<Tet>,
pub rods: Vec<Rod>,
/// Row-sum lumped mass per node.
pub mass: Vec<f64>,
/// Point masses, springs and dashpots.
pub discrete: Discrete,
/// The springs' and dashpots' stable increment on the lumped mass, and a link that sets it,
/// when there are any.
pub discrete_increment: Option<(f64, String)>,
/// Prescribed velocity per degree of freedom (`None` = free).
pub prescribed: Vec<Option<f64>>,
pub walls: Vec<Wall>,
@@ -162,6 +170,7 @@ fn check_formulation(kind: ElementKind, section_name: &str, f: &modelcraft_model
ElementKind::Hex8 => Ok(()),
ElementKind::Tet4 if f.integration == Integration::Reduced => refuse("reduced integration of tet4 elements"),
ElementKind::Tet4 => Ok(()),
ElementKind::Rod2 | ElementKind::Rod3 => Ok(()),
other => refuse(&format!("{} elements in explicit dynamics (it has one-point hex8 and nodal-pressure tet4 so far)", other.name())),
}
}
@@ -173,14 +182,18 @@ impl System {
}
// Elements by label, with their section's material.
let mut materials: Vec<Material> = Vec::new();
let mut chosen: Vec<(u32, ElementKind, Vec<u32>, usize)> = Vec::new();
let mut chosen: Vec<(u32, ElementKind, Vec<u32>, usize, Option<f64>)> = Vec::new();
for (name, section) in model.sections() {
if !matches!(section.kind, SectionKind::Solid {}) {
return Err(Error::NotSupported(format!(
"{} sections (section `{name}`); explicit dynamics has solid elements so far",
section.kind.name()
)));
}
let area = match section.kind {
SectionKind::Solid {} => None,
SectionKind::Rod { area } => Some(area),
_ => {
return Err(Error::NotSupported(format!(
"{} sections (section `{name}`); explicit dynamics has solid and truss elements so far",
section.kind.name()
)));
}
};
let mi = match materials.iter().position(|m| m.name == section.material) {
Some(i) => i,
None => {
@@ -191,6 +204,13 @@ impl System {
let set = resolved.sets.get(&section.set).ok_or_else(|| invalid(format!("set `{}` is not resolved", section.set)))?;
for &label in &set.elements {
let el = model.element(label).ok_or_else(|| invalid(format!("element {label} is missing")))?;
if area.is_some() != matches!(el.kind, ElementKind::Rod2 | ElementKind::Rod3) {
return Err(invalid(format!(
"section `{name}`: a {} section cannot hold {} element {label}",
section.kind.name(),
el.kind.name()
)));
}
check_formulation(el.kind, name, &section.formulation)?;
if el.kind == ElementKind::Tet4 && materials.get(mi).is_some_and(|m| m.j2.is_none()) {
return Err(Error::NotSupported(format!(
@@ -198,11 +218,13 @@ impl System {
section.material
)));
}
chosen.push((label, el.kind, el.nodes.clone(), mi));
chosen.push((label, el.kind, el.nodes.clone(), mi, area));
}
}
chosen.sort_by_key(|e| e.0);
let mut labels: Vec<u32> = chosen.iter().flat_map(|e| e.2.iter().copied()).collect();
// Mesh nodes only discrete elements use (reference points are refused by Discrete::new).
labels.extend(resolved.discrete_labels().filter(|l| model.node(*l).is_some()));
labels.sort_unstable();
labels.dedup();
let layout = DofLayout::uniform(labels.iter().copied(), DofSet::TRANSLATIONS);
@@ -213,7 +235,7 @@ impl System {
return Err(Error::Internal("explicit dynamics needs three translations per node".into()));
}
if labels.is_empty() {
return Err(invalid("the model has no elements to analyse"));
return Err(invalid("the model has no elements or point masses to analyse"));
}
let xyz: Vec<[f64; 3]> =
labels.iter().map(|l| model.node(*l).map(|n| n.xyz).ok_or_else(|| invalid(format!("node {l} is missing")))).collect::<Result<_>>()?;
@@ -224,7 +246,10 @@ impl System {
materials,
hexes: Vec::new(),
tets: Vec::new(),
rods: Vec::new(),
mass: vec![0.0; n],
discrete: Discrete::default(),
discrete_increment: None,
prescribed: vec![None; dofs],
walls: Vec::new(),
initial_velocity: vec![0.0; dofs],
@@ -238,7 +263,7 @@ impl System {
labels,
xyz,
};
for (index, (label, kind, nodes, mi)) in chosen.into_iter().enumerate() {
for (index, (label, kind, nodes, mi, area)) in chosen.into_iter().enumerate() {
let idx: Vec<usize> = nodes.iter().map(|l| sys.node(*l).ok_or_else(|| Error::Internal(format!("node {l}")))).collect::<Result<_>>()?;
let mat = sys.materials.get(mi).ok_or_else(|| Error::Internal("material index".into()))?;
let (rho, c) = (mat.density, mat.wave_speed);
@@ -264,9 +289,34 @@ impl System {
sys.lump(&nodes, &mass);
sys.tets.push(Tet { label, index, nodes, material: mi, kinematics: k, mass, omega2, length: 2.0 * c / omega2.sqrt() });
}
ElementKind::Rod2 | ElementKind::Rod3 => {
let area = area.ok_or_else(|| Error::Internal("truss area".into()))?;
let source = model.material(&mat.name).ok_or_else(|| Error::Internal("truss material".into()))?;
if source.plastic.is_some() || source.damage.is_some() {
return Err(Error::NotSupported(format!(
"plastic or damaging materials on trusses (material `{}`, element {label}): explicit trusses are elastic so far",
mat.name
)));
}
if kind == ElementKind::Rod3 && settings.nonlinear_geometry {
return Err(Error::NotSupported(format!(
"three-node trusses with nonlinear geometry (element {label}); set the step's nonlinear_geometry to false"
)));
}
let young =
modelcraft_fem::truss::young_modulus(source.elastic.as_ref().ok_or_else(|| Error::Internal("truss material".into()))?)
.map_err(|e| invalid(format!("material `{}` of element {label}: {e}", mat.name)))?;
let x: Vec<[f64; 3]> = idx.iter().map(|i| sys.xyz.get(*i).copied().unwrap_or_default()).collect();
let rod = Rod::new(label, index, kind, idx.clone(), &x, mi, crate::discrete::RodSection { young, density: rho, area })?;
let mass: Vec<f64> = rod.nodal_volume.iter().map(|v| rho * v).collect();
sys.lump(&idx, &mass);
sys.rods.push(rod);
}
other => return Err(Error::Internal(format!("{} element kind", other.name()))),
}
}
sys.discrete = Discrete::new(resolved, sys.dofs(), &sys.xyz, |l| sys.node(l))?;
sys.discrete_increment = sys.discrete.critical_increment(&sys.dof_mass(), sys.nonlinear);
sys.prescribe(model, resolved, step, settings.end_time)?;
sys.load(model, resolved, step)?;
sys.conditions(resolved, settings)?;
@@ -290,6 +340,13 @@ impl System {
self.layout.dofs()
}
/// The lumped mass per degree of freedom: the elements' and the point masses'.
pub fn dof_mass(&self) -> Vec<f64> {
(0..self.dofs())
.map(|d| self.mass.get(crate::discrete::node_direction(d).0).copied().unwrap_or(0.0) + self.discrete.mass.get(d).copied().unwrap_or(0.0))
.collect()
}
/// Prescribed velocities from the step's boundary conditions: zero for held degrees of
/// freedom, value / end time for a displacement reached over the step.
fn prescribe(&mut self, model: &Model, resolved: &Resolved, step: &Step, end_time: f64) -> Result<()> {
@@ -363,6 +420,12 @@ impl System {
add_at(&mut f, *node, &acceleration.map(|a| rho * w * a));
}
});
// Point masses take gravity on the whole model.
if set.is_none() {
for (d, (x, m)) in f.iter_mut().zip(&self.discrete.mass).enumerate() {
*x += m * acceleration.get(crate::discrete::node_direction(d).1).copied().unwrap_or(0.0);
}
}
}
Load::BodyForce { set, vector } => {
let chosen = resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default();
@@ -399,6 +462,9 @@ impl System {
for t in self.tets.iter().filter(|t| wanted(t.label)) {
visit(&t.nodes, &t.kinematics.nodal_volume, density(t.material));
}
for r in self.rods.iter().filter(|r| wanted(r.label)) {
visit(&r.nodes, &r.nodal_volume, density(r.material));
}
}
/// The analysed nodes of a resolved set.