forked from 1b-agent/fieldcraft-upstream
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:
53 files changed
+3626
-110
No files matched your search
+165
-11
@@ -1,5 +1,6 @@
|
||||
//! From the resolved model to the discrete system: the solid elements with their materials and
|
||||
//! formulations, the coupled reference points, the degrees of freedom, prescribed values and the
|
||||
//! From the resolved model to the discrete system: the solid, 2D and truss elements with their
|
||||
//! materials and formulations, point masses, springs and dashpots ([`crate::discrete`]), the
|
||||
//! reference points couplings or discrete elements use, the degrees of freedom, prescribed values and the
|
||||
//! load vector of a step.
|
||||
//!
|
||||
//! Degrees of freedom ([`DofLayout`]): the kinds each mesh node's elements declare (three
|
||||
@@ -8,6 +9,7 @@
|
||||
|
||||
use modelcraft_fem::element::{Formulation, Solid};
|
||||
use modelcraft_fem::plane::{Plane, Theory};
|
||||
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;
|
||||
@@ -117,6 +119,42 @@ impl Elem {
|
||||
}
|
||||
}
|
||||
|
||||
/// A truss element (rod2, rod3) ready for the kernel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bar {
|
||||
pub label: u32,
|
||||
pub kind: ElementKind,
|
||||
/// Indices into [`Mesh::labels`] (layout slots).
|
||||
pub nodes: Vec<usize>,
|
||||
pub xyz: Vec<[f64; 3]>,
|
||||
pub young: f64,
|
||||
pub area: f64,
|
||||
pub density: Option<f64>,
|
||||
}
|
||||
|
||||
impl Bar {
|
||||
pub fn truss(&self) -> Result<Truss<'_>> {
|
||||
Truss::new(self.kind, &self.xyz).map_err(|e| Error::element(self.label, e))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if planar { DofSet::PLANE } else { DofSet::TRANSLATIONS }
|
||||
}
|
||||
|
||||
/// Keep the rows and columns of a matrix over three translations per node (node by node) that
|
||||
/// `kinds` has: the in-plane block of a truss in a 2D model, or the matrix itself.
|
||||
pub fn translation_block(data: &[f64], nodes: usize, kinds: DofSet) -> Vec<f64> {
|
||||
if kinds == DofSet::TRANSLATIONS {
|
||||
return data.to_vec();
|
||||
}
|
||||
let keep: Vec<usize> = (0..3 * nodes).filter(|i| DofKind::translation(i % 3).is_some_and(|k| kinds.contains(k))).collect();
|
||||
let width = 3 * nodes;
|
||||
keep.iter().flat_map(|i| keep.iter().map(move |j| data.get(i * width + j).copied().unwrap_or(0.0))).collect()
|
||||
}
|
||||
|
||||
/// A reference point some coupling ties to the mesh.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Point {
|
||||
@@ -138,6 +176,9 @@ pub struct Mesh {
|
||||
pub layout: DofLayout,
|
||||
/// The 2D theory when this is a 2D model (a model is 2D as a whole).
|
||||
pub theory: Option<Theory>,
|
||||
pub trusses: Vec<Bar>,
|
||||
/// Point masses, springs and dashpots ([`crate::discrete`]).
|
||||
pub discrete: crate::discrete::Discrete,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
@@ -145,6 +186,15 @@ impl Mesh {
|
||||
self.labels.binary_search(&label).ok()
|
||||
}
|
||||
|
||||
pub fn truss(&self, label: u32) -> Option<&Bar> {
|
||||
self.trusses.binary_search_by_key(&label, |e| e.label).ok().and_then(|i| self.trusses.get(i))
|
||||
}
|
||||
|
||||
/// The kinds trusses and translational discrete elements carry at their nodes.
|
||||
pub fn translation_kinds(&self) -> DofSet {
|
||||
translation_kinds(self.theory.is_some())
|
||||
}
|
||||
|
||||
pub fn element(&self, label: u32) -> Option<&Elem> {
|
||||
self.elements.binary_search_by_key(&label, |e| e.label).ok().and_then(|i| self.elements.get(i))
|
||||
}
|
||||
@@ -188,17 +238,32 @@ impl Mesh {
|
||||
/// Build it from the model and its resolved view, refusing what the solver cannot represent.
|
||||
pub fn new(model: &Model, resolved: &Resolved, max_dofs: usize) -> Result<Mesh> {
|
||||
let mut elements = Vec::new();
|
||||
let mut trusses = 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))
|
||||
&& let Some(what) = model
|
||||
.sections()
|
||||
.values()
|
||||
.any(|s| matches!(s.kind, SectionKind::Rod { .. }))
|
||||
.then_some("trusses")
|
||||
.or_else(|| (!model.masses().is_empty() || !model.springs().is_empty()).then_some("point masses, springs and dashpots"))
|
||||
{
|
||||
return Err(Error::NotSupported(format!("{what} in axisymmetric models")));
|
||||
}
|
||||
for (name, section) in model.sections() {
|
||||
let theory = Theory::from_section(§ion.kind);
|
||||
if !matches!(section.kind, SectionKind::Solid {}) && theory.is_none() {
|
||||
let rod = match section.kind {
|
||||
SectionKind::Rod { area } => Some(area),
|
||||
_ => None,
|
||||
};
|
||||
if !matches!(section.kind, SectionKind::Solid {}) && theory.is_none() && rod.is_none() {
|
||||
return Err(Error::NotSupported(format!(
|
||||
"{} sections (section `{name}`); the solver has solid and 2D (plane stress, plane strain, axisymmetric) elements so far",
|
||||
"{} sections (section `{name}`); the solver has solid, 2D (plane stress, plane strain, axisymmetric) and truss elements so far",
|
||||
section.kind.name()
|
||||
)));
|
||||
}
|
||||
if theory.is_some() != planar.is_some() {
|
||||
if theory.is_some() != planar.is_some() && rod.is_none() {
|
||||
return Err(Error::NotSupported("2D and solid sections in one model".into()));
|
||||
}
|
||||
let material = model
|
||||
@@ -206,8 +271,17 @@ impl Mesh {
|
||||
.ok_or_else(|| Error::Invalid(format!("section `{name}`: material `{}` is missing", section.material)))?;
|
||||
let elastic =
|
||||
material.elastic.as_ref().ok_or_else(|| Error::Invalid(format!("material `{}` has no elastic constants", section.material)))?;
|
||||
let d = elasticity(elastic).map_err(|e| Error::Invalid(format!("material `{}`: {e}", section.material)))?;
|
||||
let set = resolved.sets.get(§ion.set).ok_or_else(|| Error::Invalid(format!("set `{}` is not resolved", section.set)))?;
|
||||
if let Some(area) = rod {
|
||||
let young =
|
||||
young_modulus(elastic).map_err(|e| Error::Invalid(format!("material `{}` of section `{name}`: {e}", section.material)))?;
|
||||
for &label in &set.elements {
|
||||
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
|
||||
trusses.push((label, el.kind, el.nodes.clone(), young, area, material.density));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let d = elasticity(elastic).map_err(|e| Error::Invalid(format!("material `{}`: {e}", section.material)))?;
|
||||
for &label in &set.elements {
|
||||
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
|
||||
let (formulation, dofs) = match theory {
|
||||
@@ -222,10 +296,19 @@ impl Mesh {
|
||||
return Err(Error::NotSupported(format!("couplings in 2D models (coupling `{name}`)")));
|
||||
}
|
||||
elements.sort_by_key(|e| e.0);
|
||||
trusses.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(needs.iter().map(|n| n.0).filter(|l| model.node(*l).is_some()));
|
||||
labels.sort_unstable();
|
||||
labels.dedup();
|
||||
let coupled: BTreeSet<&str> = model.couplings().values().map(|c| c.reference_point.as_str()).collect();
|
||||
let mut coupled: BTreeSet<&str> = model.couplings().values().map(|c| c.reference_point.as_str()).collect();
|
||||
let discrete_labels: BTreeSet<u32> = needs.iter().map(|n| n.0).collect();
|
||||
coupled.extend(model.reference_points().iter().filter(|(_, p)| discrete_labels.contains(&p.label)).map(|(n, _)| n.as_str()));
|
||||
let points: Vec<Point> = coupled
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
@@ -237,6 +320,12 @@ impl Mesh {
|
||||
for e in &elements {
|
||||
layout.element(&e.2, e.7);
|
||||
}
|
||||
for e in &trusses {
|
||||
layout.element(&e.2, modelcraft_fem::ElementDofs::new(translations));
|
||||
}
|
||||
for (label, kinds) in needs.iter().filter(|n| model.node(n.0).is_some()) {
|
||||
layout.node(*label, *kinds);
|
||||
}
|
||||
for p in &points {
|
||||
layout.point(p.label, DofSet::STRUCTURAL);
|
||||
}
|
||||
@@ -249,14 +338,46 @@ impl Mesh {
|
||||
.iter()
|
||||
.map(|l| model.node(*l).map(|n| n.xyz).ok_or_else(|| Error::Invalid(format!("node {l} is missing"))))
|
||||
.collect::<Result<_>>()?;
|
||||
let mut mesh = Mesh { labels, xyz, elements: Vec::with_capacity(elements.len()), points, layout, theory: planar };
|
||||
let mut mesh = Mesh {
|
||||
labels,
|
||||
xyz,
|
||||
elements: Vec::with_capacity(elements.len()),
|
||||
points,
|
||||
layout,
|
||||
theory: planar,
|
||||
trusses: Vec::with_capacity(trusses.len()),
|
||||
discrete: crate::discrete::Discrete::default(),
|
||||
};
|
||||
for (label, kind, nodes, formulation, d, density, theory, _) in elements {
|
||||
let idx: Vec<usize> = nodes.iter().filter_map(|n| mesh.node(*n)).collect();
|
||||
let exyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
|
||||
mesh.elements.push(Elem { label, kind, nodes: idx, xyz: exyz, formulation, d, density, theory });
|
||||
}
|
||||
for (label, kind, nodes, young, area, density) in trusses {
|
||||
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.trusses.push(Bar { label, kind, nodes: idx, xyz: exyz, young, area, density });
|
||||
}
|
||||
mesh.discrete = crate::discrete::Discrete::new(&mesh, resolved)?;
|
||||
Ok(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()));
|
||||
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();
|
||||
std::borrow::Cow::Owned(slots)
|
||||
}))
|
||||
}
|
||||
|
||||
/// The slots of the matrix pattern: the mesh nodes, and the reference points too when a
|
||||
/// discrete element acts on them (couplings tie points through constraints, not the matrix).
|
||||
pub fn block_count(&self) -> usize {
|
||||
if self.discrete.couplings().flatten().any(|d| d >= self.mesh_dofs()) { self.layout.slots() } else { self.labels.len() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The prescribed value of every degree of freedom (`None` = free) under a step's boundary
|
||||
@@ -279,8 +400,9 @@ pub fn prescribed(
|
||||
if let Target::ReferencePoint(rp) = bc.target()
|
||||
&& mesh.point(rp).is_none()
|
||||
{
|
||||
warnings
|
||||
.push(format!("boundary condition `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh; it holds nothing"));
|
||||
warnings.push(format!(
|
||||
"boundary condition `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh and no spring or point mass uses; it holds nothing"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
for slot in resolved.target_nodes(bc.target()).iter().filter_map(|l| mesh.layout.slot(*l)) {
|
||||
@@ -365,7 +487,9 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
if let Some(rp) = load.reference_point()
|
||||
&& mesh.point(rp).is_none()
|
||||
{
|
||||
return Err(Error::Invalid(format!("load `{name}` acts on reference point `{rp}`, which no coupling ties to the mesh")));
|
||||
return Err(Error::Invalid(format!(
|
||||
"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.
|
||||
let rotation = matches!(load, Load::Moment { .. });
|
||||
@@ -433,15 +557,36 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
let rho = e.density.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} has no density", e.label)))?;
|
||||
body(&mut f, mesh, e, acceleration.map(|a| rho * a))?;
|
||||
}
|
||||
for t in &mesh.trusses {
|
||||
if chosen.is_some_and(|c| c.binary_search(&t.label).is_err()) {
|
||||
continue;
|
||||
}
|
||||
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))?;
|
||||
}
|
||||
// Point masses take gravity on the whole model.
|
||||
if set.is_none() {
|
||||
mesh.discrete.add_gravity(&mut f, *acceleration, mesh);
|
||||
}
|
||||
}
|
||||
Load::BodyForce { set, vector } => {
|
||||
for label in resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default() {
|
||||
if let Some(t) = mesh.truss(*label) {
|
||||
bar_body(&mut f, mesh, t, *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)?;
|
||||
}
|
||||
}
|
||||
Load::Centrifugal { angular_velocity, point, axis, set } => {
|
||||
let chosen: Option<&[u32]> = set.as_ref().map(|s| resolved.sets.get(s).map(|r| r.elements.as_slice()).unwrap_or_default());
|
||||
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 set.is_none() && !mesh.discrete.masses.is_empty() {
|
||||
return Err(Error::NotSupported(format!("centrifugal loads on point masses (load `{name}`)")));
|
||||
}
|
||||
for e in &mesh.elements {
|
||||
if chosen.is_some_and(|c| c.binary_search(&e.label).is_err()) {
|
||||
continue;
|
||||
@@ -461,6 +606,15 @@ pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, activ
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// A force `b` per volume on a truss.
|
||||
fn bar_body(f: &mut [f64], mesh: &Mesh, t: &Bar, b: [f64; 3]) -> Result<()> {
|
||||
let forces = t.truss()?.body_force(b, t.area).map_err(|err| Error::element(t.label, err))?;
|
||||
for (node, v) in t.nodes.iter().zip(forces) {
|
||||
add(f, &translations(mesh, *node)?, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user