forked from 1b-agent/fieldcraft-upstream
* Renumber the layer table for solver crates: fem at L1, solver at L2, fsi at L3 L0 model -> L1 geometry, mesh, fem, decks, render -> L2 solver (explicit, lbm later) -> L3 fsi (later) -> L4 doc -> L5 io -> L6 engine -> L7 ui-egui, mcp -> apps. UI_MIN_LAYER moves to 7, the testkit sits above it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-fem: element kernels for tet4, tet10, hex8 (full, incompatible modes), hex20 (full, reduced) Shape functions, Gauss and conical-product quadrature, stiffness, consistent and HRZ-lumped mass, body forces, consistent pressure loads on faces, integration-point stresses and their least-squares extrapolation to nodes, isotropic and orthotropic elasticity. The element table is one row per kind, so wedges, shells and beams are added locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * modelcraft-solver: linear static and frequency analysis with an analytic verification suite Assembly from the resolved model on the node graph, boundary conditions by elimination, consistent pressure, gravity and body-force loads, nested-dissection ordering, sparse up-looking Cholesky with a factor-size cap, reactions, integration-point and nodal-averaged stresses, von Mises; shift-invert block Lanczos with full reorthogonalization for natural modes, participation and effective mass. ADR 0001 records the own-linear-algebra decision (determinism, wasm). Verified against closed forms: displacement and traction patch tests for every element, cantilever tip deflection (Timoshenko) with convergence rates, Lamé cylinder, Kirsch hole (Kt -> 3), cantilever and free-free beam frequencies with six rigid-body modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Engine: solve.static, solve.frequency and solve.probe; results kept in the session Results are stored per step, restored on a failed command and cleared when the model changes. Caps (degrees of freedom, factor entries, Lanczos vectors) may only be lowered; hostile parameters are tested against a solvable session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: implicit solver architecture, roadmap M4, parity and references Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Simplify the solver: shared face integration, block assembly, cached extrapolation - model: one face integrator (nodal::face_normal_weights) for tractions and pressures - fem: mid-side tables from ElementKind::edges, orthotropic via inverse3, symmetric accumulation without a symmetrize pass, incompatible-mode recovery without K_uu, model's coordinate check, dead table rows removed - solver: one pattern search per node pair (block, scalar and diagonal adds), extrapolation built once per element type, reactions from the prescribed rows only, matrices dropped after restriction, the free-body shift keyed on the factorization error, step selection in the solver, Error::Element naming the element - engine: the solver owns the default step and the shift check; the model comparison runs once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: results are stamped with the session revision 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>
219 lines
10 KiB
Rust
219 lines
10 KiB
Rust
//! From the resolved model to the discrete system: the solid elements with their materials and
|
|
//! formulations, the degrees of freedom, prescribed values and the load vector of a step.
|
|
|
|
use modelcraft_fem::element::{Formulation, Solid};
|
|
use modelcraft_fem::{Elasticity, elasticity, face};
|
|
use modelcraft_model::{ElementKind, Load, Model, Resolved, SectionKind, Step, Target};
|
|
|
|
use crate::{Error, Result};
|
|
|
|
/// A solid element ready for the kernels.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Elem {
|
|
pub label: u32,
|
|
pub kind: ElementKind,
|
|
/// Indices into [`Mesh::labels`].
|
|
pub nodes: Vec<usize>,
|
|
pub xyz: Vec<[f64; 3]>,
|
|
pub formulation: Formulation,
|
|
pub d: Elasticity,
|
|
pub density: Option<f64>,
|
|
}
|
|
|
|
impl Elem {
|
|
pub fn solid(&self) -> Result<Solid<'_>> {
|
|
Solid::new(self.kind, &self.xyz, self.formulation).map_err(|e| Error::element(self.label, e))
|
|
}
|
|
}
|
|
|
|
/// The analysed mesh: the nodes that carry degrees of freedom (those of the solid elements),
|
|
/// sorted by label, and the elements sorted by label.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Mesh {
|
|
pub labels: Vec<u32>,
|
|
pub xyz: Vec<[f64; 3]>,
|
|
pub elements: Vec<Elem>,
|
|
}
|
|
|
|
impl Mesh {
|
|
pub fn node(&self, label: u32) -> Option<usize> {
|
|
self.labels.binary_search(&label).ok()
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
pub fn dofs(&self) -> usize {
|
|
3 * self.labels.len()
|
|
}
|
|
|
|
/// 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> {
|
|
if let Some(name) = model.couplings().keys().next() {
|
|
return Err(Error::NotSupported(format!(
|
|
"couplings (coupling `{name}`); the solver handles solid elements without reference points so far"
|
|
)));
|
|
}
|
|
let mut elements = Vec::new();
|
|
for (name, section) in model.sections() {
|
|
if !matches!(section.kind, SectionKind::Solid {}) {
|
|
return Err(Error::NotSupported(format!(
|
|
"{} sections (section `{name}`); the solver has solid elements so far",
|
|
section.kind.name()
|
|
)));
|
|
}
|
|
let material = model
|
|
.material(§ion.material)
|
|
.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)))?;
|
|
for &label in &set.elements {
|
|
let el = model.element(label).ok_or_else(|| Error::Invalid(format!("element {label} is missing")))?;
|
|
let formulation = Formulation::from_section(el.kind, §ion.formulation).map_err(|e| Error::element(label, e))?;
|
|
elements.push((label, el.kind, el.nodes.clone(), formulation, d, material.density));
|
|
}
|
|
}
|
|
elements.sort_by_key(|e| e.0);
|
|
let mut labels: Vec<u32> = elements.iter().flat_map(|e| e.2.iter().copied()).collect();
|
|
labels.sort_unstable();
|
|
labels.dedup();
|
|
if 3 * labels.len() > max_dofs {
|
|
return Err(Error::Limit(format!("the model has {} degrees of freedom; the limit is {max_dofs}", 3 * labels.len())));
|
|
}
|
|
let xyz: Vec<[f64; 3]> = labels
|
|
.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()) };
|
|
for (label, kind, nodes, formulation, d, density) 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 });
|
|
}
|
|
Ok(mesh)
|
|
}
|
|
}
|
|
|
|
/// The prescribed value of every degree of freedom (`None` = free) under a step's boundary
|
|
/// conditions. Rotational degrees of freedom do not exist on solid nodes and are ignored.
|
|
pub fn prescribed(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step, values: bool) -> Result<Vec<Option<f64>>> {
|
|
let mut out: Vec<Option<f64>> = vec![None; mesh.dofs()];
|
|
for name in &step.boundary_conditions {
|
|
let bc = model.boundary_condition(name).ok_or_else(|| Error::Invalid(format!("boundary condition `{name}` is missing")))?;
|
|
if let Target::ReferencePoint(rp) = bc.target() {
|
|
return Err(Error::NotSupported(format!("boundary conditions on reference points (`{name}` on `{rp}`)")));
|
|
}
|
|
for &label in resolved.target_nodes(bc.target()) {
|
|
let Some(node) = mesh.node(label) else { continue };
|
|
for dof in 1..=3u8 {
|
|
let Some(v) = bc.value(dof) else { continue };
|
|
let v = if values { v } else { 0.0 };
|
|
let slot = out.get_mut(3 * node + usize::from(dof - 1)).ok_or_else(|| Error::Internal("dof index".into()))?;
|
|
match *slot {
|
|
Some(old) if old != v => {
|
|
return Err(Error::Invalid(format!(
|
|
"node {label} direction {dof} is prescribed both {old} and {v} (boundary condition `{name}` and another)"
|
|
)));
|
|
}
|
|
_ => *slot = Some(v),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn add(f: &mut [f64], node: usize, v: [f64; 3]) {
|
|
for (k, c) in v.iter().enumerate() {
|
|
if let Some(x) = f.get_mut(3 * node + k) {
|
|
*x += c;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The load vector of a static step: nodal forces and tractions as the model distributes them,
|
|
/// consistent pressure loads, gravity and body forces.
|
|
pub fn loads(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &Step) -> Result<Vec<f64>> {
|
|
let mut f = vec![0.0; mesh.dofs()];
|
|
for name in &step.loads {
|
|
let load = model.load(name).ok_or_else(|| Error::Invalid(format!("load `{name}` is missing")))?;
|
|
match load {
|
|
Load::Force { target: Target::ReferencePoint(rp), .. } => {
|
|
return Err(Error::NotSupported(format!("loads on reference points (`{name}` on `{rp}`)")));
|
|
}
|
|
Load::Force { .. } | Load::Traction { .. } => {
|
|
for (label, v) in resolved.nodal_loads.get(name).map(Vec::as_slice).unwrap_or_default() {
|
|
let node = mesh.node(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: node {label} belongs to no solid element")))?;
|
|
add(&mut f, node, *v);
|
|
}
|
|
}
|
|
Load::Moment { .. } => {
|
|
return Err(Error::NotSupported(format!("moments (`{name}`): solid nodes have no rotations; a coupled reference point is needed")));
|
|
}
|
|
Load::Pressure { set, magnitude } => {
|
|
let faces = resolved.sets.get(set).map(|s| s.faces.as_slice()).unwrap_or_default();
|
|
for fc in faces {
|
|
let labels = model
|
|
.face_nodes(*fc)
|
|
.ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} face {} is missing", fc.element, fc.face)))?;
|
|
let idx: Vec<usize> = labels
|
|
.iter()
|
|
.map(|l| mesh.node(*l).ok_or_else(|| Error::Invalid(format!("load `{name}`: node {l} belongs to no solid element"))))
|
|
.collect::<Result<_>>()?;
|
|
let xyz: Vec<[f64; 3]> = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect();
|
|
let forces = face::pressure(&xyz, *magnitude).map_err(|e| Error::element(fc.element, e))?;
|
|
for (i, v) in idx.iter().zip(forces) {
|
|
add(&mut f, *i, v);
|
|
}
|
|
}
|
|
}
|
|
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 in &mesh.elements {
|
|
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)))?;
|
|
body(&mut f, e, acceleration.map(|a| rho * a))?;
|
|
}
|
|
}
|
|
Load::BodyForce { set, vector } => {
|
|
for label in resolved.sets.get(set).map(|r| r.elements.as_slice()).unwrap_or_default() {
|
|
let e = mesh.element(*label).ok_or_else(|| Error::Invalid(format!("load `{name}`: element {label} is not a solid element")))?;
|
|
body(&mut f, e, *vector)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(f)
|
|
}
|
|
|
|
fn body(f: &mut [f64], e: &Elem, b: [f64; 3]) -> Result<()> {
|
|
let forces = e.solid()?.body_force(b).map_err(|err| Error::element(e.label, err))?;
|
|
for (node, v) in e.nodes.iter().zip(forces) {
|
|
add(f, *node, v);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Equation numbers of the free degrees of freedom, following a node order.
|
|
pub fn equations(order: &[usize], prescribed: &[Option<f64>]) -> (Vec<Option<usize>>, usize) {
|
|
let mut map = vec![None; prescribed.len()];
|
|
let mut next = 0;
|
|
for &node in order {
|
|
for c in 0..3 {
|
|
let dof = 3 * node + c;
|
|
if prescribed.get(dof).is_some_and(Option::is_none) {
|
|
if let Some(m) = map.get_mut(dof) {
|
|
*m = Some(next);
|
|
}
|
|
next += 1;
|
|
}
|
|
}
|
|
}
|
|
(map, next)
|
|
}
|