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

+76
View File
@@ -0,0 +1,76 @@
//! Element faces: consistent nodal forces of surface loads.
//!
//! A pressure p on a face gives node i the force fᵢ = −p ∫ Nᵢ n dA, where n dA =
//! (∂x/∂ξ × ∂x/∂η) dξ dη on the face's own, possibly curved, geometry; faces are numbered so this
//! normal points out of the element ([`modelcraft_model::ElementKind::faces`]), and a positive
//! pressure pushes in. This is the consistent load vector of the virtual work of the surface load
//! (Zienkiewicz, Taylor & Zhu 2013, §6.6), integrated with the model's face rules (exact for a
//! uniform pressure on a flat face).
use crate::{Error, Result};
/// ∫ Nᵢ n dA for every node of a face (node coordinates in the face's node order): the model's
/// face integration ([`modelcraft_model::nodal::face_normal_weights`]), so pressures and the
/// tractions the model distributes use the same rule.
pub fn normal_weights(xyz: &[[f64; 3]]) -> Result<Vec<[f64; 3]>> {
modelcraft_model::nodal::face_normal_weights(xyz).map_err(|e| Error::Invalid(e.to_string()))
}
/// Consistent nodal forces of a uniform pressure (positive pushes into the element).
pub fn pressure(xyz: &[[f64; 3]], magnitude: f64) -> Result<Vec<[f64; 3]>> {
Ok(normal_weights(xyz)?.into_iter().map(|w| w.map(|c| -magnitude * c)).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pressure_on_flat_faces_is_the_textbook_split() {
// A 2 × 3 quadrilateral in z = 0, normal +z: quad4 shares p A equally, quad8 gives the
// corners −A/12 and the mid-sides A/3.
let quad = [[0., 0., 0.], [2., 0., 0.], [2., 3., 0.], [0., 3., 0.]];
let f = pressure(&quad, 1.0).unwrap();
assert!(f.iter().all(|v| (v[2] + 1.5).abs() < 1e-14 && v[0] == 0.0 && v[1] == 0.0), "{f:?}");
let mut q8 = quad.to_vec();
for (a, b) in [(0, 1), (1, 2), (2, 3), (3, 0)] {
q8.push([0, 1, 2].map(|k| (quad[a][k] + quad[b][k]) / 2.0));
}
let f = pressure(&q8, 1.0).unwrap();
for (i, v) in f.iter().enumerate() {
let want = if i < 4 { 0.5 } else { -2.0 };
assert!((v[2] - want).abs() < 1e-13, "{i}: {v:?}");
}
// Triangles: tri3 A/3 each; tri6 corners 0, mid-sides A/3.
let tri = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
let f = pressure(&tri, 6.0).unwrap();
assert!(f.iter().all(|v| (v[2] + 1.0).abs() < 1e-14), "{f:?}");
let mut t6 = tri.to_vec();
for (a, b) in [(0, 1), (1, 2), (2, 0)] {
t6.push([0, 1, 2].map(|k| (tri[a][k] + tri[b][k]) / 2.0));
}
let f = pressure(&t6, 6.0).unwrap();
for (i, v) in f.iter().enumerate() {
let want = if i < 3 { 0.0 } else { -1.0 };
assert!((v[2] - want).abs() < 1e-13, "{i}: {v:?}");
}
assert!(pressure(&tri[..2], 1.0).is_err());
}
#[test]
fn pressure_on_a_curved_face_sums_to_the_projected_area() {
// A quadratic face on a quarter cylinder r = 2, z in 0..1, normal outward: the total
// pressure force is −p times the projected areas (2 in x and 2 in y).
let r: f64 = 2.0;
let at = |t: f64, z: f64| [r * t.cos(), r * t.sin(), z];
let h = std::f64::consts::FRAC_PI_2;
// Corners counter-clockwise seen from outside: (0,0), (0,1)... with the normal outward.
let xyz = vec![at(0.0, 0.0), at(h, 0.0), at(h, 1.0), at(0.0, 1.0), at(h / 2.0, 0.0), at(h, 0.5), at(h / 2.0, 1.0), at(0.0, 0.5)];
let w = normal_weights(&xyz).unwrap();
let total = w.iter().fold([0.0; 3], |a, v| [a[0] + v[0], a[1] + v[1], a[2] + v[2]]);
// A quadratic arc interpolates the circle closely; the projected area is exact for the
// interpolated surface (its end points are exact).
assert!((total[0].abs() - 2.0).abs() < 1e-12 && (total[1].abs() - 2.0).abs() < 1e-12 && total[2].abs() < 1e-12, "{total:?}");
assert!(total[0] > 0.0 && total[1] > 0.0, "this ordering points outwards: {total:?}");
}
}