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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: implicit solver architecture, roadmap M4, parity and references

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: results are stamped with the session revision

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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

+49 -19
View File
@@ -14,8 +14,8 @@ pub enum Class {
Layer(u8),
/// Layer 0, and additionally may depend on no workspace crate at all.
Standalone,
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
/// purposes); other crates may use it only as a dev-dependency.
/// Test tooling: may depend on anything up to L7 (it sits at [`TESTKIT_LAYER`]
/// for rule purposes); other crates may use it only as a dev-dependency.
Testkit,
/// Binaries and build tooling: exempt from the rules.
Exempt,
@@ -26,7 +26,7 @@ impl Class {
match self {
Class::Layer(l) => Some(l),
Class::Standalone => Some(0),
Class::Testkit => Some(6),
Class::Testkit => Some(TESTKIT_LAYER),
Class::Exempt => None,
}
}
@@ -35,17 +35,23 @@ impl Class {
/// The layering table. Names are package names without the `modelcraft-`
/// prefix.
pub const TABLE: &[(&str, Class)] = &[
// L0: the solver-neutral model.
("model", Class::Layer(0)),
// L1: geometry and math kernels.
("geometry", Class::Layer(1)),
("mesh", Class::Layer(1)),
("solver", Class::Layer(1)),
("fem", Class::Layer(1)),
("decks", Class::Layer(1)),
("render", Class::Layer(1)),
("doc", Class::Layer(2)),
("io", Class::Layer(3)),
("engine", Class::Layer(4)),
("ui-egui", Class::Layer(5)),
("mcp", Class::Layer(5)),
// L2: solvers (the explicit and lattice Boltzmann solvers join here).
("solver", Class::Layer(2)),
// L3: coupling between solvers (`fsi`, later).
// L4 and up: the document, files, commands, UI.
("doc", Class::Layer(4)),
("io", Class::Layer(5)),
("engine", Class::Layer(6)),
("ui-egui", Class::Layer(7)),
("mcp", Class::Layer(7)),
("testkit", Class::Testkit),
// apps and tooling
("modelcraft", Class::Exempt),
@@ -73,7 +79,13 @@ pub const UI_CRATES: &[&str] = &["egui", "eframe", "egui-wgpu", "wgpu", "winit",
pub const SOLIDCRAFT_GATEWAY: &str = "geometry";
/// First layer allowed to use UI crates.
pub const UI_MIN_LAYER: u8 = 5;
pub const UI_MIN_LAYER: u8 = 7;
/// The layer the testkit sits at for rule purposes: above every layered crate.
pub const TESTKIT_LAYER: u8 = UI_MIN_LAYER + 1;
/// The layer reported for apps and tooling when a layered crate depends on one.
const APPS_LAYER: u8 = TESTKIT_LAYER + 1;
pub fn short_name(pkg: &str) -> &str {
pkg.strip_prefix("modelcraft-").unwrap_or(pkg)
@@ -118,7 +130,7 @@ pub enum Violation {
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
StandaloneHasWorkspaceDep { krate: String, dep: String },
TestkitAsNormalDep { krate: String },
UiBelowL5 { krate: String, dep: String, layer: u8 },
UiBelowUiLayer { krate: String, dep: String, layer: u8 },
SolidCraftOutsideGeometry { krate: String, dep: String },
}
@@ -137,8 +149,8 @@ impl std::fmt::Display for Violation {
Violation::TestkitAsNormalDep { krate } => {
write!(f, "{krate}: modelcraft-testkit may only be a dev-dependency")
}
Violation::UiBelowL5 { krate, dep, layer } => {
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L5+")
Violation::UiBelowUiLayer { krate, dep, layer } => {
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L{UI_MIN_LAYER}+")
}
Violation::SolidCraftOutsideGeometry { krate, dep } => {
write!(f, "{krate} depends on `{dep}`; only modelcraft-{SOLIDCRAFT_GATEWAY} may use SolidCraft")
@@ -183,7 +195,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
krate: c.name.clone(),
dep: d.name.clone(),
from: layer,
to: if to == u8::MAX { 7 } else { to },
to: to.min(APPS_LAYER),
kind: d.kind,
});
}
@@ -192,7 +204,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
} else if d.name.starts_with("solidcraft-") && short_name(&c.name) != SOLIDCRAFT_GATEWAY {
out.push(Violation::SolidCraftOutsideGeometry { krate: c.name.clone(), dep: d.name.clone() });
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
out.push(Violation::UiBelowL5 { krate: c.name.clone(), dep: d.name.clone(), layer });
out.push(Violation::UiBelowUiLayer { krate: c.name.clone(), dep: d.name.clone(), layer });
}
}
}
@@ -259,12 +271,14 @@ mod tests {
#[test]
fn upward_dependency_flagged() {
let v = check(&[c("modelcraft-doc", &[("modelcraft-engine", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 4, .. }]));
assert!(matches!(v[..], [Violation::Upward { from: 4, to: 6, .. }]));
let v = check(&[c("modelcraft-fem", &[("modelcraft-solver", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 2, .. }]));
}
#[test]
fn sideways_dependency_flagged() {
let v = check(&[c("modelcraft-mesh", &[("modelcraft-solver", Normal, true)])]);
let v = check(&[c("modelcraft-mesh", &[("modelcraft-fem", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 1, .. }]));
}
@@ -280,10 +294,26 @@ mod tests {
}
#[test]
fn ui_crates_below_l6_flagged() {
fn solvers_sit_above_the_kernels() {
let g = [
c("modelcraft-fem", &[("modelcraft-model", Normal, true)]),
c("modelcraft-solver", &[("modelcraft-fem", Normal, true), ("modelcraft-mesh", Normal, true)]),
c("modelcraft-engine", &[("modelcraft-solver", Normal, true)]),
];
assert!(check(&g).is_empty(), "{:?}", check(&g));
let v = check(&[c("modelcraft-solver", &[("modelcraft-engine", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 6, .. }]));
let v = check(&[c("modelcraft-engine", &[("modelcraft-cli", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 6, to: APPS_LAYER, .. }]));
}
#[test]
fn ui_crates_below_the_ui_layer_flagged() {
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
let v = check(&[c("modelcraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL5 { layer: 4, .. }]), "{dep}");
assert!(matches!(v[..], [Violation::UiBelowUiLayer { layer: 6, .. }]), "{dep}");
let v = check(&[c("modelcraft-solver", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowUiLayer { layer: 2, .. }]), "{dep}");
}
assert!(check(&[c("modelcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
assert!(check(&[c("modelcraft-ui-egui", &[("winit", Normal, false)])]).is_empty());