Files
OneCAD-Simulation/crates/solver/src/ordering.rs
T
40f98f0af9 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>
2026-10-13 17:44:21 -04:00

120 lines
5.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}