Implicit solver: fem kernels (tet4/10, hex8, hex20), sparse Cholesky, block Lanczos, solve.* commands (M4) (#23)

* 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>
This commit is contained in:
authored and GitHub committed 2026-10-13 17:44:21 -04:00
1 parent 81fa8d7220
commit 40f98f0af9
33 files changed
+4559 -53

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
//! Linear elastic constitutive matrices.
//!
//! Stress and strain are 6-vectors in the order xx, yy, zz, xy, xz, yz, with engineering shear
//! strains (γ = 2ε), so σ = D ε. The order puts the orthotropic shear moduli G₁₂, G₁₃, G₂₃ on
//! the diagonal in the order the model lists them. Material axes are the global axes until the
//! model has material orientations.
//!
//! Orthotropic stiffness is the inverse of the compliance written with engineering constants
//! (R. M. Jones, *Mechanics of Composite Materials*, 2nd ed., Taylor & Francis 1999, §2.4):
//! S₁₁ = 1/E₁, S₁₂ = −ν₁₂/E₁, S₁₃ = −ν₁₃/E₁, S₂₃ = −ν₂₃/E₂ (ν_ji/E_j = ν_ij/E_i), and
//! 1/G on the shear diagonal.
use modelcraft_model::Elastic;
use crate::dense::inverse3;
use crate::{Error, Result};
/// A 6 × 6 elasticity matrix, row-major.
pub type Elasticity = [[f64; 6]; 6];
/// The isotropic elasticity matrix.
pub fn isotropic(young: f64, poisson: f64) -> Result<Elasticity> {
if !(young > 0.0 && young.is_finite() && poisson > -1.0 && poisson < 0.5) {
return Err(Error::Invalid(format!("isotropic constants E = {young}, ν = {poisson} are not a stable material")));
}
let lambda = young * poisson / ((1.0 + poisson) * (1.0 - 2.0 * poisson));
let mu = young / (2.0 * (1.0 + poisson));
let mut d = [[0.0; 6]; 6];
for i in 0..3 {
for j in 0..3 {
d[i][j] = lambda;
}
d[i][i] = lambda + 2.0 * mu;
d[i + 3][i + 3] = mu;
}
Ok(d)
}
/// The orthotropic elasticity matrix from engineering constants.
pub fn orthotropic(young: [f64; 3], poisson: [f64; 3], shear: [f64; 3]) -> Result<Elasticity> {
let [e1, e2, e3] = young;
let [n12, n13, n23] = poisson;
let s = [[1.0 / e1, -n12 / e1, -n13 / e1], [-n12 / e1, 1.0 / e2, -n23 / e2], [-n13 / e1, -n23 / e2, 1.0 / e3]];
let scale = (1.0 / e1) * (1.0 / e2) * (1.0 / e3);
let inverse = inverse3(&s).filter(|(_, det)| *det > 1e-12 * scale);
let Some((normal, _)) = inverse.filter(|_| shear.iter().all(|g| *g > 0.0 && g.is_finite())) else {
return Err(Error::Invalid("the orthotropic constants do not describe a stable material".into()));
};
let mut d = [[0.0; 6]; 6];
for i in 0..3 {
d[i][..3].copy_from_slice(&normal[i]);
}
for (k, g) in shear.iter().enumerate() {
d[3 + k][3 + k] = *g;
}
Ok(d)
}
/// The elasticity matrix of a model material's elastic behaviour.
pub fn elasticity(e: &Elastic) -> Result<Elasticity> {
match *e {
Elastic::Isotropic { young, poisson } => isotropic(young, poisson),
Elastic::EngineeringConstants { young, poisson, shear } => orthotropic(young, poisson, shear),
}
}
/// The von Mises equivalent stress of a stress 6-vector.
pub fn von_mises(s: &[f64; 6]) -> f64 {
let [xx, yy, zz, xy, xz, yz] = *s;
let normal = (xx - yy) * (xx - yy) + (yy - zz) * (yy - zz) + (zz - xx) * (zz - xx);
(0.5 * normal + 3.0 * (xy * xy + xz * xz + yz * yz)).sqrt()
}
/// σ = D ε.
pub fn stress(d: &Elasticity, strain: &[f64; 6]) -> [f64; 6] {
let mut s = [0.0; 6];
for (si, row) in s.iter_mut().zip(d) {
*si = row.iter().zip(strain).map(|(a, b)| a * b).sum();
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn orthotropic_with_isotropic_constants_is_isotropic() {
let (e, nu) = (210e3, 0.3);
let g = e / (2.0 * (1.0 + nu));
let a = isotropic(e, nu).unwrap();
let b = orthotropic([e; 3], [nu; 3], [g; 3]).unwrap();
for i in 0..6 {
for j in 0..6 {
assert!((a[i][j] - b[i][j]).abs() < 1e-9 * e, "{i} {j}: {} vs {}", a[i][j], b[i][j]);
}
}
assert!(isotropic(1.0, 0.5).is_err());
assert!(orthotropic([1.0; 3], [0.9; 3], [1.0; 3]).is_err());
}
#[test]
fn orthotropic_uniaxial_response() {
// Stress along 1 alone gives strains σ/E1, −ν12 σ/E1, −ν13 σ/E1.
let (young, poisson) = ([140e3, 10e3, 12e3], [0.3, 0.25, 0.4]);
let d = orthotropic(young, poisson, [5e3, 4e3, 3.8e3]).unwrap();
let strain = [1.0 / young[0], -poisson[0] / young[0], -poisson[1] / young[0], 0.0, 0.0, 0.0];
let s = stress(&d, &strain);
assert!((s[0] - 1.0).abs() < 1e-12 && s[1].abs() < 1e-12 && s[2].abs() < 1e-12, "{s:?}");
assert!((von_mises(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]) - 1.0).abs() < 1e-15);
assert!((von_mises(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0]) - 3f64.sqrt()).abs() < 1e-15);
}
}