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

+119
View File
@@ -0,0 +1,119 @@
//! Fill-reducing ordering: nested dissection by coordinate bisection.
//!
//! Nested dissection (A. George, *Nested dissection of a regular finite element mesh*, SIAM J.
//! Numer. Anal. 10(2):345–363, 1973) orders a separator after the two parts it splits, so the
//! Cholesky factor of a 3D mesh with n nodes fills O(n^{4/3}) instead of the O(n^{5/3}) of a band
//! or envelope ordering. The separator here is geometric: the nodes are split at the median of
//! the longest axis of their bounding box, and the nodes of the smaller side that touch the other
//! side form the separator (the coordinate nested dissection of H. D. Simon, *Partitioning of
//! unstructured problems for parallel processing*, Computing Systems in Engineering 2:135–148,
//! 1991, with vertex separators). Ties are broken by node index, so the ordering is
//! deterministic.
/// Parts this small are ordered as they come.
const LEAF: usize = 16;
/// A permutation of the nodes: `order[k]` is the node eliminated k-th.
pub fn nested_dissection(xyz: &[[f64; 3]], adjacency: &[Vec<usize>]) -> Vec<usize> {
let n = xyz.len();
let mut order = Vec::with_capacity(n);
// 0 = not in the current part, 1 = left, 2 = right.
let mut side = vec![0u8; n];
// An explicit stack instead of recursion: (part, separator to emit after it).
enum Task {
Split(Vec<usize>),
Emit(Vec<usize>),
}
let mut stack = vec![Task::Split((0..n).collect())];
while let Some(task) = stack.pop() {
let mut part = match task {
Task::Emit(sep) => {
order.extend(sep);
continue;
}
Task::Split(p) => p,
};
if part.len() <= LEAF {
order.extend(part);
continue;
}
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for &v in &part {
let p = xyz.get(v).copied().unwrap_or_default();
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
let axis = (0..3).fold(0, |best, k| if hi[k] - lo[k] > hi[best] - lo[best] { k } else { best });
let key = |v: usize| xyz.get(v).map_or(0.0, |p| p[axis]);
part.sort_by(|&a, &b| key(a).total_cmp(&key(b)).then(a.cmp(&b)));
let half = part.len() / 2;
for (i, &v) in part.iter().enumerate() {
if let Some(s) = side.get_mut(v) {
*s = if i < half { 1 } else { 2 };
}
}
let touches = |v: usize, other: u8| adjacency.get(v).is_some_and(|row| row.iter().any(|w| side.get(*w) == Some(&other)));
let (left, right) = part.split_at(half);
let left_boundary: Vec<usize> = left.iter().copied().filter(|&v| touches(v, 2)).collect();
let right_boundary: Vec<usize> = right.iter().copied().filter(|&v| touches(v, 1)).collect();
let separator = if left_boundary.len() <= right_boundary.len() { left_boundary } else { right_boundary };
for &v in &part {
if let Some(s) = side.get_mut(v) {
*s = 0;
}
}
// Mark the separator so the parts leave it out.
for &v in &separator {
if let Some(s) = side.get_mut(v) {
*s = 3;
}
}
let a: Vec<usize> = left.iter().copied().filter(|v| side.get(*v) != Some(&3)).collect();
let b: Vec<usize> = right.iter().copied().filter(|v| side.get(*v) != Some(&3)).collect();
for &v in &separator {
if let Some(s) = side.get_mut(v) {
*s = 0;
}
}
// Order: a, then b, then the separator (the stack runs in reverse).
stack.push(Task::Emit(separator));
stack.push(Task::Split(b));
stack.push(Task::Split(a));
}
order
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_permutation_with_separators_last() {
// A 12 × 12 grid graph.
let m = 12;
let id = |i: usize, j: usize| i * m + j;
let xyz: Vec<[f64; 3]> = (0..m * m).map(|v| [(v / m) as f64, (v % m) as f64, 0.0]).collect();
let mut adj = vec![Vec::new(); m * m];
for i in 0..m {
for j in 0..m {
for (di, dj) in [(0i64, 1i64), (1, 0), (0, -1), (-1, 0), (0, 0)] {
let (a, b) = (i as i64 + di, j as i64 + dj);
if (0..m as i64).contains(&a) && (0..m as i64).contains(&b) {
adj[id(i, j)].push(id(a as usize, b as usize));
}
}
}
}
let order = nested_dissection(&xyz, &adj);
let mut sorted = order.clone();
sorted.sort_unstable();
assert_eq!(sorted, (0..m * m).collect::<Vec<_>>());
// The top separator (last 12 entries) is one grid line.
let last: Vec<usize> = order[m * m - m..].to_vec();
assert!(last.iter().all(|v| v / m == last[0] / m) || last.iter().all(|v| v % m == last[0] % m), "{last:?}");
assert_eq!(order, nested_dissection(&xyz, &adj), "deterministic");
}
}