Files
OneCAD-Simulation/crates/solver/src/contact.rs
T
jyvikoandClaude Opus 5.5 98eaede2dd WIP (parked): folding robustness, stabilization, mesh.revolve, boot-seal profile
Parked work for #182/#185 (not for merge as is):
- contact: held active set with primal-dual updates replaces the
  inconsistent ENGAGED_BAND tangent after chattering (moves the
  nl-friction-sliding-blocks fixture by 4.5e-8; not re-blessed)
- nonlinear: opt-in viscous stabilization (step 'stabilization',
  calibrated on the first increment), Abaqus STABILIZE, LS-DYNA refusal
- ordering: graph (level-structure) nested dissection, chosen when it
  fills less: half the fill on the revolved boot, factor time 62 s -> 25 s
- engine: mesh.revolve command (2D profile -> hex8/wedge6, tags kept)
- engine: sample/boot.rs boot-seal profile, axisymmetric and 3D scripts
Scratch still in the code: FC_TRACE/TIMES timing prints, NO_BAND/STAB/
SQUEEZE env hooks in tests, boot_axisymmetric_scratch test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 17:54:43 -04:00

1305 lines
63 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.
//! Contact in nonlinear static steps: mortar (segment-to-segment) normal contact enforced by an
//! augmented Lagrangian, with its consistent tangent.
//!
//! **Discretisation: mortar, one pass.** A contact pair's `secondary` surface is the non-mortar
//! side, its `main` surface the mortar side (M. A. Puso, T. A. Laursen, *A mortar
//! segment-to-segment contact method for large deformation solid mechanics*, CMAME 193:601–629,
//! 2004). Each secondary node A carries one normal pressure p_A and one weighted gap
//!
//! g̃_A = Σ_segments ∫ Φ_A (x_m − x_s) · n dA,
//!
//! integrated over the overlaps of its faces with the main faces they face
//! ([`fieldcraft_fem::mortar`]: projection along the secondary face's normal n, clipping,
//! seven-point rule), with Φ_A the secondary face's own shape functions. The pressure field is
//! interpolated like the displacements, so a uniform pressure across non-matching meshes is
//! transmitted exactly: both sides receive the consistent nodal forces ∫ p N dA of it, which is
//! the contact patch test that node-to-surface contact fails (R. L. Taylor, P. Papadopoulos,
//! *On a patch test for contact problems in two dimensions*, 1991; T. W. McDevitt, T. A.
//! Laursen, *A mortar-finite element formulation for frictional contact problems*, IJNME
//! 48:1525–1547, 2000). A node's gap is normalised by its reference area A_A = ∫ Φ_A dA over its
//! own faces: ĝ_A = g̃_A / A_A, a length (negative: penetration).
//!
//! **Enforcement: augmented Lagrangian** (J. C. Simo, T. A. Laursen, *An augmented Lagrangian
//! treatment of contact problems involving friction*, Comput. Struct. 42(1):97–116, 1992;
//! P. Alart, A. Curnier, *A mixed formulation for frictional contact problems prone to Newton
//! like solution methods*, CMAME 92:353–375, 1991). With the node's multiplier λ_A held fixed,
//! the pressure is p_A = ⟨λ_A − ε_A ĝ_A⟩ (⟨x⟩ = max(x, 0)), the derivative of the potential
//! Π = Σ_A A_A/(2 ε_A) ⟨λ_A − ε_A ĝ_A⟩², whose forces −p_A ∂g̃_A/∂x enter the internal forces and
//! whose Hessian
//!
//! K_c = Σ_{A closed} (ε_A / A_A) ∂g̃_A ⊗ ∂g̃_A − p_A ∂²g̃_A
//!
//! the tangent: the consistent linearisation of the segmentation, the normal and the projection
//! (the second term is the geometric stiffness of the contact, which matters on curved and
//! sliding surfaces), both from the automatic differentiation of the mortar integrals
//! ([`fieldcraft_fem::dual`]). The penalty only regularises: once Newton has converged an
//! increment, the multipliers take the pressures (Uzawa's update λ_A ← p_A) and the increment is
//! solved again from its solution, until every closed node's gap is within [`GAP_TOLERANCE`] of
//! the thickness of the element under it and no open node keeps a multiplier
//! ([`Interface::augmentation_error`]; at most [`MAX_AUGMENTATIONS`] times, then with a warning).
//! The penalty per length is ε_A = s · 10 · 3Ḡ / h
//! over the secondary faces at the node (Ḡ the mean shear modulus of the element under the face,
//! h its thickness V/A, s the contact's `penalty_scale`): about ten times the stiffness of the
//! element under the node, stiff enough that a few augmentations reach the tolerance and soft
//! enough to leave the tangent well conditioned (3Ḡ rather than the constrained modulus keeps it
//! so for nearly incompressible rubber).
//!
//! **Active set.** The pressure's max() is the active set: each evaluation decides which nodes
//! are closed (p_A > 0) from the current gaps and multipliers, and Newton runs on the
//! semismooth system (Alart & Curnier 1991; A. Popp, M. W. Gee, W. A. Wall, *A finite
//! deformation mortar contact formulation using a primal–dual active set strategy*, IJNME
//! 79:1354–1391, 2009). The nonlinear solver cuts the increment back when the set alternates
//! between two states ([`CHATTER_LIMIT`] times: chattering).
//!
//! **Small and finite sliding.** Without nonlinear geometry the face pairs (the main faces within
//! a main element's thickness of each secondary face) and their mortar integrals
//! D_ai = ∫ Φ_a N_i and M_aj = ∫ Φ_a N_j are found once in the reference configuration, so that
//! g̃_a = n · (Σ_j M_aj x_j − Σ_i D_ai x_i) is linear in the positions (small sliding: exact, and
//! consistent with the small-strain elements); with nonlinear geometry they are searched and
//! integrated on the current configuration at every evaluation (finite sliding).
//!
//! **Search.** Every evaluation of finite sliding: the main faces' boxes, grown by their depth limit, go into a
//! bucket grid ([`fieldcraft_fem::contact::Grid`]); each secondary face takes the main faces in
//! the cells its box overlaps that face it (unit normals more than [`FACING`] from parallel) and
//! share no node with its element. A pair whose overlap reaches deeper than [`DEPTH_FRACTION`]
//! of the main element's thickness has passed through and is left out. The pairs are integrated
//! in parallel and summed in face order, so results do not depend on the thread count.
//!
//! **Initial overclosure.** Before the step, nodes whose gap starts negative are reported:
//! `adjust` (the default) takes the initial overclosure out of their gap (an offset of the
//! weighted gap: strain-free, the nodes are not moved), `refuse` refuses the step naming the
//! first node and its depth.
//!
//! **Output.** Per secondary node the pressure as the node's normal contact force over the area
//! it carries (∫ Φ_A over the overlaps: smoother than the multipliers, which are the coefficients
//! of the pressure field and ring at the contact's edge), the multiplier, the gap and the status;
//! per contact the force on the secondary surface and its normal part.
//!
//! **Single-surface (general) contact** is the same mortar contact with one face list as both
//! sides: a set's faces, or every exterior face of the solids. Each face is a secondary face
//! against every other one that faces it, that shares no node with its element (its neighbours)
//! and that it does not reach through more than half an element (the far side of a thin wall),
//! so two faces pressing together are paired both ways, symmetrically: self-contact of folds.
//!
//! **2D models.** In plane strain, plane stress and axisymmetric models the surfaces are element
//! edges (sets' edges; general contact defaults to every exterior edge of the 2D elements),
//! integrated by [`fieldcraft_fem::mortar::integrate_edges`] with the out-of-plane measure
//! (thickness, or 2π r) in the weights, so A_A, g̃_A and the forces are those of the whole ring
//! or slab. Each edge is oriented so that its normal points out of its element; the element's
//! thickness under it is its area over the edge's length. Points keep z = 0 and the 2D nodes have
//! no Z translation, so the tangent blocks keep their in-plane rows.
//!
//! **Scope.** Linear solid faces (tri3 and quad4 faces: tet4, wedge6 and hex8 elements) in 3D
//! models and linear edges (tri3 and quad4 elements) in 2D ones, with Coulomb friction
//! ([`Interface::friction`]). Quadratic faces and edges and arc-length steps are refused by name.
use std::collections::BTreeMap;
use fieldcraft_fem::contact::{Aabb, Grid, bounds};
use fieldcraft_fem::dual::{Dual, Hyper, Real, VARS};
use fieldcraft_fem::frame::{Vec3, dot, norm, unit};
use fieldcraft_fem::mortar::{Sample, area_vector, edge_area_vector, integrate, integrate_edges};
use fieldcraft_model::{Contact, ContactSurfaces, ElementEdge, ElementFace, ElementKind, Extent, Friction, InitialPenetration, Model, Resolved};
use fieldcraft_parallel as parallel;
use serde::Serialize;
use crate::system::{Mesh, translations};
use crate::{Error, Result};
/// Facing faces: the dot product of their unit normals is below minus this.
pub const FACING: f64 = 0.1;
/// A pair deeper than this fraction of the main element's thickness has passed through.
pub const DEPTH_FRACTION: f64 = 0.5;
/// ε = s · PENALTY_FACTOR · 3Ḡ / h (see the module docs).
pub const PENALTY_FACTOR: f64 = 10.0;
/// Augmentations stop when every closed node's gap is within this fraction of the thickness of
/// the element under it (and no open node keeps a multiplier).
pub const GAP_TOLERANCE: f64 = 1e-5;
/// Most updates of a held active set in one increment.
pub const MAX_ACTIVE_SET_UPDATES: usize = 8;
/// Most augmentations of one increment.
pub const MAX_AUGMENTATIONS: usize = 25;
/// An increment whose active set alternates between two states this many times holds it fixed
/// ([`Committed::active`]); if it still alternates once released, the increment is cut back.
pub const CHATTER_LIMIT: usize = 3;
/// Most faces over the contacts of a model.
pub const MAX_CONTACT_FACES: usize = 2_000_000;
/// Secondary faces per parallel task.
const GRAIN: usize = 16;
/// A face of a contact surface: its corners (mesh slots) and their translations.
#[derive(Debug, Clone)]
pub(crate) struct Face {
pub(crate) nodes: Vec<usize>,
/// X, Y and Z translations per corner (a 2D model's nodes have no Z).
pub(crate) dofs: Vec<[Option<usize>; 3]>,
/// The nodes of its element (never in contact with the face).
owner: Vec<usize>,
/// The penetration beyond which a point has passed through, and its element's thickness.
depth: f64,
pub(crate) thickness: f64,
/// The penalty per length its element sets (before the penalty scale).
stiffness: f64,
}
/// A contact pair as the solver runs it.
#[derive(Debug, Clone)]
pub struct Interface {
pub name: String,
secondary: Vec<Face>,
main: Vec<Face>,
/// The secondary nodes (mesh slots), sorted, and per node: its reference area A_A, its
/// penalty ε_A, the thinnest element under it and the offset taken out of its weighted gap
/// (initial overclosure).
pub nodes: Vec<usize>,
area: Vec<f64>,
penalty: Vec<f64>,
thickness: Vec<f64>,
offset: Vec<f64>,
/// Small sliding (steps without nonlinear geometry): the face pairs and their integrals,
/// fixed in the reference configuration; `None`: finite sliding, searched and integrated
/// on the current configuration at every evaluation.
fixed: Option<Vec<Vec<Fixed>>>,
/// Coulomb friction (frictionless: none).
friction: Option<Friction>,
/// A 2D model's out-of-plane measure (its surfaces are edges); `None`: solid faces.
extent: Option<Extent>,
}
/// A contact's state carried from one converged increment to the next: per secondary node the
/// augmented Lagrange multiplier, and with friction the tangential traction and whether the node
/// was slipping.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Committed {
pub multipliers: Vec<f64>,
pub traction: Vec<Vec3>,
pub slipping: Vec<bool>,
/// An active set held fixed while an increment that chattered converges: per secondary
/// node, closed (its pressure λ − ε ĝ, tension allowed) or open (none). `None`: each
/// evaluation decides, p = ⟨λ − ε ĝ⟩.
pub active: Option<Vec<bool>>,
}
impl Committed {
/// Take a converged state's friction (the multipliers are the augmentation's).
pub fn commit(&mut self, state: &State) {
self.traction.clone_from(&state.traction);
self.slipping.clone_from(&state.slipping);
}
}
/// Whether a secondary node presses on the main surface (and with friction, sticks or slips).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Open,
Closed,
Stick,
Slip,
}
impl Status {
pub fn is_closed(self) -> bool {
self != Status::Open
}
}
impl ContactResult {
/// The closed nodes.
pub fn closed(&self) -> usize {
self.status.iter().filter(|s| s.is_closed()).count()
}
}
/// One evaluation of a contact: per secondary node (in [`Interface::nodes`] order).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct State {
/// ĝ_A, `None` where no main face lies over the node.
pub gap: Vec<Option<f64>>,
pub pressure: Vec<f64>,
/// ∫ Φ_A over the overlaps (the area the pressure acts on).
pub support: Vec<f64>,
/// The force the contact puts on each secondary node, and the node's unit outward normal
/// (its faces' area vectors summed).
pub nodal_force: Vec<Vec3>,
pub normal: Vec<Vec3>,
/// Friction: the tangential traction on each secondary node (on it, against its slip), its
/// slip over the increment (relative to the main surface, normalised like the gap) and
/// whether it slips; empty without friction.
pub traction: Vec<Vec3>,
pub slip: Vec<Vec3>,
pub slipping: Vec<bool>,
}
impl State {
/// A hash of which nodes are closed (for the chattering check).
pub fn active_set(&self) -> u64 {
// FNV-1a over the closed nodes' indices: deterministic on every platform.
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for (i, _) in self.pressure.iter().enumerate().filter(|(_, p)| **p > 0.0) {
for b in (i as u64).to_le_bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x0100_0000_01b3);
}
}
h
}
}
/// A contact's result at the end of a step, per secondary node.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ContactResult {
pub name: String,
/// The secondary surface's node labels, sorted.
pub nodes: Vec<u32>,
/// The contact pressure: the normal contact force on the node over the area it carries
/// (∫ Φ_A over the overlaps), so that Σ p_A ∫Φ_A is the force across the contact.
pub pressure: Vec<f64>,
/// The mortar multipliers (the coefficients of the pressure field, Φ_A-interpolated).
pub multiplier: Vec<f64>,
/// Friction: the shear traction (the tangential contact force over the area, on the node)
/// and the slip over the last increment, per node; empty without friction.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub shear: Vec<Vec3>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub slip: Vec<Vec3>,
/// The normalised gap ĝ (negative: penetration), `None` where no main face is over the node.
pub gap: Vec<Option<f64>>,
pub status: Vec<Status>,
/// The force the contact puts on the secondary surface, and its normal part Σ p_A ∫Φ_A.
pub force: Vec3,
pub normal_force: f64,
/// The deepest penetration of a closed node.
pub penetration: f64,
/// Augmentations over the step.
pub augmentations: usize,
}
/// The contribution of one face pair to the forces and the tangent.
struct PairTerms {
/// Mesh slots of the pair's corners (secondary first) and their degrees of freedom.
nodes: Vec<usize>,
dofs: Vec<[Option<usize>; 3]>,
/// Secondary node indices (in [`Interface::nodes`]) of the secondary corners.
corners: Vec<usize>,
/// ∂g̃_a/∂x per secondary corner (24 variables; corner c of the pair at 3c, main corners from 12).
gradient: Vec<[f64; VARS]>,
/// Σ_a p_a ∂²g̃_a, packed (tangent evaluations only).
hessian: Option<Vec<f64>>,
}
/// The tangent blocks of a contact: per block its mesh slots and the dense matrix over their
/// translations (row-major, node by node).
pub type Blocks = Vec<(Vec<usize>, Vec<f64>)>;
fn position(mesh: &Mesh, u: &[f64], node: usize, dofs: &[Option<usize>; 3]) -> Vec3 {
let x = mesh.xyz.get(node).copied().unwrap_or_default();
[0, 1, 2].map(|k| x[k] + dofs[k].and_then(|d| u.get(d)).copied().unwrap_or(0.0))
}
/// The element's volume (hex8, tet4), or a 2D element's area (tri3, quad4), for the thickness
/// under a face or edge.
fn volume(kind: ElementKind, x: &[Vec3]) -> Option<f64> {
match kind {
ElementKind::Hex8 => <[Vec3; 8]>::try_from(x).ok().and_then(|a| fieldcraft_fem::uniform::Hex8::hex8(&a).ok()).map(|k| k.volume),
ElementKind::Tet4 => <[Vec3; 4]>::try_from(x).ok().and_then(|a| fieldcraft_fem::uniform::Tet4::tet4(&a).ok()).map(|k| k.volume),
ElementKind::Tri3 | ElementKind::Quad4 => {
// The shoelace formula over the corners in the XY plane.
let doubled: f64 = x.iter().zip(x.iter().cycle().skip(1)).map(|(a, b)| a[0] * b[1] - a[1] * b[0]).sum();
Some(0.5 * doubled.abs())
}
_ => None,
}
}
/// The overlap integration of a face pair (`extent`: `None`) or an edge pair of a 2D model.
fn overlap<T: Real>(extent: Option<Extent>, xs: &[[T; 3]], xm: &[[T; 3]], f: impl FnMut(&Sample<T>)) -> Option<()> {
match extent {
None => integrate(xs, xm, f),
Some(e) => integrate_edges(xs, xm, e, f),
}
}
/// A face's area vector, or an edge's ([`edge_area_vector`]) in a 2D model.
fn area_of(extent: Option<Extent>, x: &[Vec3]) -> Option<Vec3> {
match extent {
None => area_vector(x),
Some(e) => edge_area_vector(x, e),
}
}
fn size(x: &[Vec3]) -> f64 {
let b = bounds(x, 0.0);
(0..3).map(|k| b[1][k] - b[0][k]).fold(0.0, f64::max)
}
/// The faces of set `set` for definition `owner` (``contact `name` ``, ``tie `name` ``), refused
/// unless they are linear faces of analysed solid elements.
pub(crate) fn faces(model: &Model, resolved: &Resolved, mesh: &Mesh, owner: &str, set: &str) -> Result<Vec<Face>> {
face_list(model, mesh, owner, resolved.sets.get(set).map(|s| s.faces.as_slice()).unwrap_or_default())
}
/// The edges of set `set` of a 2D model for definition `owner`, refused unless they are linear
/// edges of analysed 2D elements.
fn edges(model: &Model, resolved: &Resolved, mesh: &Mesh, owner: &str, set: &str) -> Result<Vec<Face>> {
edge_list(model, mesh, owner, resolved.sets.get(set).map(|s| s.edges.as_slice()).unwrap_or_default())
}
/// The faces `list` for definition `owner`, as [`faces`].
fn face_list(model: &Model, mesh: &Mesh, owner: &str, list: &[ElementFace]) -> Result<Vec<Face>> {
list.iter()
.map(|f| {
let at = |what: String| Error::Invalid(format!("{owner}: element {} face {}: {what}", f.element, f.face));
let labels = model.face_nodes(*f).ok_or_else(|| at("no such face".into()))?;
surface_face(mesh, owner, f.element, labels, false, at)
})
.collect()
}
/// The edges `list` of a 2D model for definition `owner`, as [`edges`].
fn edge_list(model: &Model, mesh: &Mesh, owner: &str, list: &[ElementEdge]) -> Result<Vec<Face>> {
list.iter()
.map(|e| {
let at = |what: String| Error::Invalid(format!("{owner}: element {} edge {}: {what}", e.element, e.edge));
let labels = model.edge_nodes(*e).ok_or_else(|| at("no such edge".into()))?;
surface_face(mesh, owner, e.element, labels, true, at)
})
.collect()
}
/// One face (or, `edge`, one edge of a 2D element) of element `element` with nodes `labels`.
fn surface_face(mesh: &Mesh, owner: &str, element: u32, labels: Vec<u32>, edge: bool, at: impl Fn(String) -> Error) -> Result<Face> {
let el = mesh.element(element).ok_or_else(|| at("the element is not a solid or 2D element of the analysis".into()))?;
let (linear, takes) = if edge {
(labels.len() == 2 && el.kind.dimension() == 2, "the linear edges of tri3 and quad4 elements")
} else {
(matches!(labels.len(), 3 | 4) && el.kind.dimension() == 3, "the linear faces of tet4, wedge6 and hex8 elements")
};
if !linear {
let what = if edge { "edges" } else { "faces" };
return Err(Error::NotSupported(format!(
"{owner} on the {what} of {} elements (element {element}) in implicit analyses; it takes {takes}",
el.kind.name()
)));
}
let mut nodes: Vec<usize> = labels.iter().map(|l| mesh.node(*l).ok_or_else(|| at(format!("node {l} is not analysed")))).collect::<Result<_>>()?;
let mut x: Vec<Vec3> = nodes.iter().map(|n| mesh.xyz.get(*n).copied().unwrap_or_default()).collect();
if edge {
// Outward: the edge's normal (t_y, −t_x) away from its element's centroid.
let count = el.xyz.len().max(1) as f64;
let centroid: Vec3 = std::array::from_fn(|k| el.xyz.iter().map(|p| p[k]).sum::<f64>() / count);
let (Some(a), Some(b)) = (x.first(), x.get(1)) else { return Err(at("the edge has no size".into())) };
let mid = [0.5 * (a[0] + b[0]) - centroid[0], 0.5 * (a[1] + b[1]) - centroid[1]];
if (b[1] - a[1]) * mid[0] - (b[0] - a[0]) * mid[1] < 0.0 {
nodes.reverse();
x.reverse();
}
}
let dofs = nodes
.iter()
.map(|n| {
let d = translations(mesh, *n)?;
match d.as_slice() {
[a, b, c] => Ok([Some(*a), Some(*b), Some(*c)]),
[a, b] if edge => Ok([Some(*a), Some(*b), None]),
_ => Err(Error::Internal("node translations".into())),
}
})
.collect::<Result<Vec<_>>>()?;
// The face's area, or the edge's length.
let area = match x.as_slice() {
[a, b] if edge => norm([b[0] - a[0], b[1] - a[1], 0.0]),
_ => area_vector(&x).map_or(0.0, norm),
};
let thickness = volume(el.kind, &el.xyz).filter(|v| *v > 0.0 && area > 0.0).map_or(size(&x), |v| v / area);
if !(thickness > 0.0 && thickness.is_finite()) {
return Err(at(format!("the {} has no size", if edge { "edge" } else { "face" })));
}
let shear = (el.d[3][3] + el.d[4][4] + el.d[5][5]) / 3.0;
let mut owner = el.nodes.clone();
owner.sort_unstable();
Ok(Face { nodes, dofs, owner, depth: DEPTH_FRACTION * thickness, thickness, stiffness: PENALTY_FACTOR * 3.0 * shear / thickness })
}
/// A face pair of small-sliding contact: its mortar integrals in the reference configuration,
/// D_ai = ∫ Φ_a N_i (secondary corners i) and M_aj = ∫ Φ_a N_j (main corners j), and the
/// secondary face's normal there, so that g̃_a = n · (Σ_j M_aj x_j − Σ_i D_ai x_i).
#[derive(Debug, Clone)]
struct Fixed {
main: usize,
d: [[f64; 4]; 4],
m: [[f64; 4]; 4],
normal: Vec3,
support: [f64; 4],
}
impl Fixed {
/// B_a, the slip operator ([`slip_operator`]) of the reference configuration.
fn slip_jacobian(&self, a: usize) -> [[f64; VARS]; 3] {
let mut j = [[0.0; VARS]; 3];
let (Some(d), Some(m)) = (self.d.get(a), self.m.get(a)) else { return j };
let n = self.normal;
for k in 0..3 {
for l in 0..3 {
let proj = if k == l { 1.0 } else { 0.0 } - n[k] * n[l];
for i in 0..4 {
j[k][3 * i + l] = d[i] * proj;
j[k][12 + 3 * i + l] = -m[i] * proj;
}
}
}
j
}
/// ∂g̃_a/∂x over the pair's variables (secondary corners from 0, main corners from 12).
fn gradient(&self, a: usize) -> [f64; VARS] {
let mut g = [0.0; VARS];
let (Some(d), Some(m)) = (self.d.get(a), self.m.get(a)) else { return g };
for k in 0..3 {
for i in 0..4 {
g[3 * i + k] = -d[i] * self.normal[k];
g[12 + 3 * i + k] = m[i] * self.normal[k];
}
}
g
}
}
/// What a secondary face finds against the main surface: per main face, ∫ Φ_a g and ∫ Φ_a for
/// each corner a.
struct Found {
main: usize,
gap: [f64; 4],
support: [f64; 4],
}
/// The weighted gaps g̃_a of one face pair (corners `xs`, `xm`) over any scalar.
fn weighted_gaps<T: Real>(extent: Option<Extent>, xs: &[[T; 3]], xm: &[[T; 3]]) -> Option<[T; 4]> {
let mut g = [T::cst(0.0); 4];
overlap(extent, xs, xm, |p| {
let w = p.weight * p.gap();
for a in 0..4 {
g[a] = g[a] + w * p.secondary[a];
}
})?;
Some(g)
}
/// The slip operator of one face pair (corners `xs`, `xm`): per secondary corner a the matrix
/// B_a with w_a = B_a Δx, the weighted tangential slip ∫ Φ_a (I − n n)(Δx_s − Δx_m) dA of the
/// pair's corners' motions Δx (rows: its components; columns: the pair's variables, secondary
/// corners from 0, main corners from 12), with the integrals on the current configuration: the
/// rate form of the mortar slip, B_a = [∫ Φ_a N_i P, −∫ Φ_a N_j P] (McDevitt & Laursen 2000; Puso &
/// Laursen 2004), P = I − n n.
fn slip_operator(extent: Option<Extent>, xs: &[Vec3], xm: &[Vec3]) -> Option<[[[f64; VARS]; 3]; 4]> {
let mut b = [[[0.0; VARS]; 3]; 4];
overlap(extent, xs, xm, |p| {
let n = p.normal;
for a in 0..4 {
let wa = p.weight * p.secondary[a];
if wa == 0.0 {
continue;
}
for k in 0..3 {
for l in 0..3 {
let proj = wa * (if k == l { 1.0 } else { 0.0 } - n[k] * n[l]);
for c in 0..4 {
b[a][k][3 * c + l] += proj * p.secondary[c];
b[a][k][12 + 3 * c + l] -= proj * p.main[c];
}
}
}
}
})?;
Some(b)
}
/// The variables of a face pair: secondary corners at 0, main corners from 12.
fn variables<T: Real>(xs: &[Vec3], xm: &[Vec3], var: impl Fn(f64, usize) -> T) -> (Vec<[T; 3]>, Vec<[T; 3]>) {
let s = xs.iter().enumerate().map(|(i, x)| [0, 1, 2].map(|k| var(x[k], 3 * i + k))).collect();
let m = xm.iter().enumerate().map(|(j, x)| [0, 1, 2].map(|k| var(x[k], 12 + 3 * j + k))).collect();
(s, m)
}
impl Interface {
/// The contact pairs of a model, ready for the nonlinear solver (initial overclosures
/// adjusted or refused, and reported in `warnings`).
pub fn all(model: &Model, resolved: &Resolved, mesh: &Mesh, finite: bool, warnings: &mut Vec<String>) -> Result<Vec<Interface>> {
let mut out = Vec::new();
let mut total = 0usize;
for (name, contact) in model.contacts() {
let mut interface = Interface::new(model, resolved, mesh, name, contact)?;
if !finite {
interface.fixed = Some(interface.fixed_pairs(mesh)?);
}
total = total.saturating_add(interface.secondary.len() + interface.main.len());
if total > MAX_CONTACT_FACES {
return Err(Error::Limit(format!("the contacts have more than {MAX_CONTACT_FACES} faces")));
}
out.push(interface.overclosure(mesh, contact.initial_penetration, warnings)?);
}
Ok(out)
}
fn new(model: &Model, resolved: &Resolved, mesh: &Mesh, name: &str, contact: &Contact) -> Result<Interface> {
let owner = format!("contact `{name}`");
let extent = mesh.theory.map(|t| t.extent());
// A set's faces or (2D) edges, or every exterior one of the model.
let surface = |set: Option<&String>| match (set, extent) {
(Some(s), None) => faces(model, resolved, mesh, &owner, s),
(Some(s), Some(_)) => edges(model, resolved, mesh, &owner, s),
(None, None) => face_list(model, mesh, &owner, &model.exterior_faces()),
(None, Some(_)) => edge_list(model, mesh, &owner, &model.exterior_edges()),
};
let (secondary, main) = match &contact.surfaces {
ContactSurfaces::Pair { main, secondary } => (surface(Some(secondary))?, surface(Some(main))?),
// Single surface: every face against every other one that faces it and shares no node
// with its element (its neighbours), both ways.
ContactSurfaces::General { surface: set } => {
let list = surface(set.as_ref())?;
(list.clone(), list)
}
};
let mut nodes: Vec<usize> = secondary.iter().flat_map(|f| f.nodes.iter().copied()).collect();
nodes.sort_unstable();
nodes.dedup();
let (mut area, mut penalty, mut thickness) = (vec![0.0; nodes.len()], vec![0.0f64; nodes.len()], vec![f64::INFINITY; nodes.len()]);
for f in &secondary {
let x: Vec<Vec3> = f.nodes.iter().map(|n| mesh.xyz.get(*n).copied().unwrap_or_default()).collect();
// ∫ Φ_a over the face itself (the face against its own mirror image).
let mirror: Vec<Vec3> = x.iter().rev().copied().collect();
let mut own = [0.0; 4];
overlap(extent, &x, &mirror, |p| {
for a in 0..4 {
own[a] += p.weight * p.secondary[a];
}
});
for (n, share) in f.nodes.iter().zip(own) {
if let Ok(i) = nodes.binary_search(n) {
if let Some(a) = area.get_mut(i) {
*a += share;
}
if let Some(e) = penalty.get_mut(i) {
*e = e.max(contact.penalty_scale * f.stiffness);
}
if let Some(h) = thickness.get_mut(i) {
*h = h.min(f.thickness);
}
}
}
}
if let Some(i) = area.iter().position(|a| !(*a > 0.0 && a.is_finite())) {
let label = nodes.get(i).and_then(|n| mesh.labels.get(*n)).copied().unwrap_or(0);
return Err(Error::Invalid(format!("contact `{name}`: the secondary faces at node {label} have no area")));
}
let offset = vec![0.0; nodes.len()];
Ok(Interface {
name: name.to_string(),
secondary,
main,
nodes,
area,
penalty,
thickness,
offset,
fixed: None,
friction: (!contact.friction.is_frictionless()).then_some(contact.friction),
extent,
})
}
/// Adjust (offset) or refuse the nodes that start behind the main surface.
fn overclosure(mut self, mesh: &Mesh, mode: InitialPenetration, warnings: &mut Vec<String>) -> Result<Interface> {
let zero = vec![0.0; mesh.dofs()];
let (gaps, _) = self.gaps(mesh, &zero)?;
let scale = mesh.xyz.iter().flatten().fold(1.0f64, |m, c| m.max(c.abs()));
let (mut count, mut deepest) = (0usize, (0.0f64, 0u32));
for (i, g) in gaps.iter().enumerate() {
let (Some(g), Some(a)) = (g, self.area.get(i)) else { continue };
let depth = -g / a;
// Coordinates are exact to a few units in the last place: a node a hair behind a
// face it was meshed on is touching.
if depth <= 1e-9 * scale {
continue;
}
let label = self.nodes.get(i).and_then(|n| mesh.labels.get(*n)).copied().unwrap_or(0);
if mode == InitialPenetration::Refuse {
return Err(Error::Invalid(format!(
"contact `{}`: node {label} starts {depth:.4e} inside the main surface (move it out, or let the contact adjust it: initial_penetration \"adjust\")",
self.name
)));
}
if let Some(o) = self.offset.get_mut(i) {
*o = *g;
}
count += 1;
if depth > deepest.0 {
deepest = (depth, label);
}
}
if count > 0 {
warnings.push(format!(
"contact `{}`: {count} node(s) start inside the main surface (deepest node {}, {:.4e}); their initial overclosure is taken out of the gap",
self.name, deepest.1, deepest.0
));
}
Ok(self)
}
fn corners(mesh: &Mesh, u: &[f64], f: &Face) -> Vec<Vec3> {
f.nodes.iter().zip(&f.dofs).map(|(n, d)| position(mesh, u, *n, d)).collect()
}
/// The face pairs: per secondary face, the main faces it faces and their integrals.
fn pairs(&self, mesh: &Mesh, u: &[f64]) -> Result<Vec<Vec<Found>>> {
let Some(fixed) = &self.fixed else { return self.search(mesh, u, 1.0, |_, _, found| found) };
Ok(parallel::map_grained(self.secondary.len(), GRAIN, |s| {
let (Some(f), Some(list)) = (self.secondary.get(s), fixed.get(s)) else { return Vec::new() };
let xs = Interface::corners(mesh, u, f);
list.iter()
.map(|p| {
let xm = self.main.get(p.main).map(|m| Interface::corners(mesh, u, m)).unwrap_or_default();
let gap = std::array::from_fn(|a| {
let (Some(d), Some(m)) = (p.d.get(a), p.m.get(a)) else { return 0.0 };
let mut v = [0.0; 3];
for (x, c) in xm.iter().zip(m) {
(0..3).for_each(|k| v[k] += c * x[k]);
}
for (x, c) in xs.iter().zip(d) {
(0..3).for_each(|k| v[k] -= c * x[k]);
}
dot(v, p.normal)
});
Found { main: p.main, gap, support: p.support }
})
.collect()
}))
}
/// The small-sliding pairs: found and integrated once, in the reference configuration.
fn fixed_pairs(&self, mesh: &Mesh) -> Result<Vec<Vec<Fixed>>> {
let zero = vec![0.0; mesh.dofs()];
// Faces within a main element's thickness may touch later.
self.search(mesh, &zero, 2.0, |xs, xm, found| {
found
.into_iter()
.filter_map(|f| {
let xm = xm.get(f.main)?;
let (mut d, mut m, mut normal) = ([[0.0; 4]; 4], [[0.0; 4]; 4], [0.0; 3]);
overlap(self.extent, xs, xm, |p| {
normal = p.normal;
for a in 0..4 {
for i in 0..4 {
d[a][i] += p.weight * p.secondary[a] * p.secondary[i];
m[a][i] += p.weight * p.secondary[a] * p.main[i];
}
}
})?;
Some(Fixed { main: f.main, d, m, normal, support: f.support })
})
.collect()
})
}
/// The global and local search at displacements `u`: per secondary face (corners `xs`), the
/// main faces (corners `xm`, all of them) within `reach` times their depth limit that it
/// faces, with their integrals, passed through `keep`.
fn search<T: Send>(&self, mesh: &Mesh, u: &[f64], reach: f64, keep: impl Fn(&[Vec3], &[Vec<Vec3>], Vec<Found>) -> T + Sync) -> Result<Vec<T>> {
let main_x: Vec<Vec<Vec3>> = self.main.iter().map(|f| Interface::corners(mesh, u, f)).collect();
let boxes: Vec<Aabb> = self.main.iter().zip(&main_x).map(|(f, x)| bounds(x, reach * f.depth)).collect();
let grid = Grid::new(&boxes, 4 * boxes.len() + 64);
let main_normals: Vec<Option<Vec3>> = main_x.iter().map(|x| area_of(self.extent, x).filter(|a| norm(*a) > 0.0)).collect();
Ok(parallel::map_grained(self.secondary.len(), GRAIN, |i| {
let mut found = Vec::new();
let Some(f) = self.secondary.get(i) else { return keep(&[], &main_x, found) };
let xs = Interface::corners(mesh, u, f);
let Some(ns) = area_of(self.extent, &xs).filter(|a| norm(*a) > 0.0) else { return keep(&xs, &main_x, found) };
let mut candidates = Vec::new();
grid.candidates_in(&bounds(&xs, 0.0), &mut candidates);
for &c in &candidates {
let c = c as usize;
let (Some(m), Some(xm), Some(Some(nm))) = (self.main.get(c), main_x.get(c), main_normals.get(c)) else { continue };
if m.nodes.iter().any(|n| f.owner.binary_search(n).is_ok()) || f.nodes.iter().any(|n| m.owner.binary_search(n).is_ok()) {
continue;
}
if dot(ns, *nm) >= -FACING * norm(ns) * norm(*nm) {
continue;
}
let (mut gap, mut support, mut deepest, mut any) = ([0.0; 4], [0.0; 4], f64::INFINITY, false);
overlap(self.extent, &xs, xm, |p| {
any = true;
let g = p.gap();
deepest = deepest.min(g);
for a in 0..4 {
gap[a] += p.weight * g * p.secondary[a];
support[a] += p.weight * p.secondary[a];
}
});
if any && deepest >= -m.depth && gap.iter().chain(&support).all(|v| v.is_finite()) {
found.push(Found { main: c, gap, support });
}
}
keep(&xs, &main_x, found)
}))
}
/// The weighted gaps g̃_A less the offsets (`None` where no main face lies over the node), and
/// the supports ∫ Φ_A, at displacements `u`.
fn gaps(&self, mesh: &Mesh, u: &[f64]) -> Result<(Vec<Option<f64>>, Vec<f64>)> {
let pairs = self.pairs(mesh, u)?;
let (gaps, support, _) = self.sums(&pairs);
Ok((gaps, support))
}
/// Node sums of the pairs' integrals, in face order.
fn sums(&self, pairs: &[Vec<Found>]) -> (Vec<Option<f64>>, Vec<f64>, Vec<Vec<usize>>) {
let n = self.nodes.len();
let (mut gap, mut support) = (vec![None; n], vec![0.0; n]);
let mut index = Vec::with_capacity(self.secondary.len());
for (f, found) in self.secondary.iter().zip(pairs) {
let corners: Vec<usize> = f.nodes.iter().filter_map(|x| self.nodes.binary_search(x).ok()).collect();
for p in found {
for (a, i) in corners.iter().enumerate() {
if let (Some(g), Some(s)) = (gap.get_mut(*i), support.get_mut(*i)) {
*g = Some(g.unwrap_or(0.0) + p.gap.get(a).copied().unwrap_or(0.0));
*s += p.support.get(a).copied().unwrap_or(0.0);
}
}
}
index.push(corners);
}
for (g, o) in gap.iter_mut().zip(&self.offset) {
if let Some(g) = g {
*g -= o;
}
}
(gap, support, index)
}
/// The state a step starts from: no multipliers, no friction traction.
pub fn committed(&self) -> Committed {
let n = self.nodes.len();
let friction = self.friction.is_some();
Committed {
multipliers: vec![0.0; n],
traction: if friction { vec![[0.0; 3]; n] } else { Vec::new() },
slipping: vec![false; if friction { n } else { 0 }],
active: None,
}
}
/// The contact's forces at displacements `u` (added to `internal`), its tangent blocks when
/// `tangent`, and its state, from the last converged increment's state `committed` (with
/// its active set when one is held fixed) and displacements `reference`.
#[allow(clippy::too_many_arguments)]
pub fn evaluate(
&self,
mesh: &Mesh,
u: &[f64],
reference: &[f64],
committed: &Committed,
tangent: bool,
internal: &mut [f64],
blocks: &mut Blocks,
) -> Result<State> {
let multipliers = committed.multipliers.as_slice();
let pairs = self.pairs(mesh, u)?;
let (weighted, support, corner_index) = self.sums(&pairs);
let n = self.nodes.len();
let mut state = State {
gap: vec![None; n],
pressure: vec![0.0; n],
support,
nodal_force: vec![[0.0; 3]; n],
normal: vec![[0.0; 3]; n],
traction: Vec::new(),
slip: Vec::new(),
slipping: Vec::new(),
};
for (f, corners) in self.secondary.iter().zip(&corner_index) {
let Some(a) = area_of(self.extent, &Interface::corners(mesh, u, f)) else { continue };
for i in corners {
if let Some(v) = state.normal.get_mut(*i) {
(0..3).for_each(|k| v[k] += a[k]);
}
}
}
for v in &mut state.normal {
*v = unit(*v).unwrap_or(*v);
}
// The closed nodes (their penalty in the tangent): those pressing, or those an active
// set held fixed closes.
let mut engaged = vec![false; n];
for i in 0..self.nodes.len() {
let (Some(Some(g)), Some(a), Some(e)) = (weighted.get(i), self.area.get(i), self.penalty.get(i)) else { continue };
let gap = g / a;
let trial = multipliers.get(i).copied().unwrap_or(0.0) - e * gap;
let closed = match committed.active.as_ref().and_then(|s| s.get(i)) {
Some(held) => *held,
None => trial > 0.0,
};
if let (Some(slot), Some(p)) = (state.gap.get_mut(i), state.pressure.get_mut(i)) {
*slot = Some(gap);
*p = if closed { trial } else { 0.0 };
engaged[i] = closed;
}
}
// The pairs with a closed secondary corner, linearised.
let mut active: Vec<(usize, usize)> = Vec::new();
for (s, found) in pairs.iter().enumerate() {
let closed = corner_index.get(s).is_some_and(|c| c.iter().any(|i| engaged.get(*i).copied().unwrap_or(false)));
if closed {
active.extend((0..found.len()).map(|k| (s, k)));
}
}
let terms = parallel::map_grained(active.len(), GRAIN, |t| -> Result<Option<PairTerms>> {
let Some(&(s, k)) = active.get(t) else { return Ok(None) };
let (Some(f), Some(found), Some(corners)) = (self.secondary.get(s), pairs.get(s).and_then(|p| p.get(k)), corner_index.get(s)) else {
return Ok(None);
};
let Some(m) = self.main.get(found.main) else { return Ok(None) };
let (xs, xm) = (Interface::corners(mesh, u, f), Interface::corners(mesh, u, m));
let pressure: Vec<f64> = corners.iter().map(|i| state.pressure.get(*i).copied().unwrap_or(0.0)).collect();
let fixed = self.fixed.as_ref().and_then(|f| f.get(s)).and_then(|l| l.get(k));
let (gradient, hessian) = if let Some(fixed) = fixed {
// Small sliding: linear in the positions.
((0..corners.len()).map(|a| fixed.gradient(a)).collect(), None)
} else if tangent {
let (vs, vm) = variables(&xs, &xm, Hyper::var);
let Some(g) = weighted_gaps(self.extent, &vs, &vm) else { return Ok(None) };
let mut h = vec![0.0; fieldcraft_fem::dual::PACKED];
for (ga, p) in g.iter().zip(&pressure) {
h.iter_mut().zip(&ga.h).for_each(|(x, y)| *x += p * y);
}
(g.iter().take(corners.len()).map(|ga| ga.g).collect::<Vec<_>>(), Some(h))
} else {
let (vs, vm) = variables(&xs, &xm, Dual::var);
let Some(g) = weighted_gaps(self.extent, &vs, &vm) else { return Ok(None) };
(g.iter().take(corners.len()).map(|ga| ga.g).collect(), None)
};
let nodes = f.nodes.iter().chain(&m.nodes).copied().collect();
let dofs = f.dofs.iter().chain(&m.dofs).copied().collect();
Ok(Some(PairTerms { nodes, dofs, corners: corners.clone(), gradient, hessian }))
});
// Forces −p_A ∂g̃_A/∂x, and per closed node its gradient over the nodes it reaches.
let mut node_gradients: BTreeMap<usize, BTreeMap<usize, Vec3>> = BTreeMap::new();
for t in terms {
let Some(t) = t? else { continue };
for (a, i) in t.corners.iter().enumerate() {
let p = state.pressure.get(*i).copied().unwrap_or(0.0);
let Some(g) = t.gradient.get(a) else { continue };
for (c, (node, dofs)) in t.nodes.iter().zip(&t.dofs).enumerate() {
let var = if c < t.corners.len() { 3 * c } else { 12 + 3 * (c - t.corners.len()) };
let gv = [0, 1, 2].map(|k| g.get(var + k).copied().unwrap_or(0.0));
let own = t.corners.get(c).and_then(|j| state.nodal_force.get_mut(*j));
let mut on = [0.0; 3];
for k in 0..3 {
let f = -p * gv[k];
if let Some(x) = dofs[k].and_then(|d| internal.get_mut(d)) {
*x += f;
}
on[k] = -f;
}
if let Some(own) = own {
(0..3).for_each(|k| own[k] += on[k]);
}
if tangent && engaged.get(*i).copied().unwrap_or(false) {
let entry = node_gradients.entry(*i).or_default().entry(*node).or_insert([0.0; 3]);
for k in 0..3 {
entry[k] += gv[k];
}
}
}
}
if let Some(h) = &t.hessian {
// −Σ_a p_a ∂²g̃_a over the pair's nodes.
let count = t.nodes.len();
let s = t.corners.len();
let var = |c: usize, k: usize| if c < s { 3 * c + k } else { 12 + 3 * (c - s) + k };
let width = 3 * count;
let mut data = vec![0.0; width * width];
for ci in 0..count {
for ki in 0..3 {
for cj in 0..count {
for kj in 0..3 {
let at = fieldcraft_fem::dual::packed(var(ci, ki), var(cj, kj));
if let Some(x) = data.get_mut((3 * ci + ki) * width + 3 * cj + kj) {
*x = -h.get(at).copied().unwrap_or(0.0);
}
}
}
}
}
blocks.push((t.nodes.clone(), data));
}
}
// (ε_A / A_A) ∂g̃_A ⊗ ∂g̃_A per closed node.
for (i, gradient) in node_gradients {
let (Some(e), Some(a)) = (self.penalty.get(i), self.area.get(i)) else { continue };
let c = e / a;
let nodes: Vec<usize> = gradient.keys().copied().collect();
let g: Vec<f64> = gradient.values().flatten().copied().collect();
let width = g.len();
let mut data = vec![0.0; width * width];
for (r, gr) in g.iter().enumerate() {
for (s, gs) in g.iter().enumerate() {
if let Some(x) = data.get_mut(r * width + s) {
*x = c * gr * gs;
}
}
}
blocks.push((nodes, data));
}
if let Some(law) = self.friction {
self.friction(mesh, u, reference, committed, (&pairs, &corner_index, &active), law, tangent, &mut state, internal, blocks)?;
}
Ok(state)
}
/// Coulomb friction at the closed secondary nodes by a return map (Wriggers 2006, §5.2;
/// Simo & Laursen 1992): the weighted tangential slip of each node since the last converged
/// increment, w_A = Σ ∫ Φ_A (I − n n)(Δx_s − Δx_m) dA = B_A Δx ([`slip_operator`]) and
/// ĝ_T = w_A / A_A; the trial traction
/// t* = P t_n − ε_A ĝ_T (P projects the committed traction on the current tangent plane);
/// stick, t = t*, when |t*| ≤ μ p_A, else slip back to the cone, t = μ p_A t*/|t*|, μ the
/// static coefficient for a node that stuck in the last increment and the kinetic one for
/// one that slipped. The traction does the virtual work t · B_A δx, so the forces −B_Aᵀ t enter
/// the internal forces (B_A at the current configuration: the friction force is exactly μ
/// times the normal one where nodes slip). The tangent keeps the symmetric stick/slip
/// stiffness (ε_A/A_A) Bᵀ B, or (ε_A μ p_A/(A_A |t*|)) Bᵀ (I − t̂ t̂) B for slip, and leaves out
/// the coupling to the pressure (μ t̂ ⊗ ∂p_A/∂x, not symmetric) and the change of B with the
/// geometry: the factorization stays symmetric and the residual exact, so only the rate of
/// convergence can suffer while nodes slip.
#[allow(clippy::too_many_arguments)]
fn friction(
&self,
mesh: &Mesh,
u: &[f64],
reference: &[f64],
committed: &Committed,
(pairs, corner_index, active): (&[Vec<Found>], &[Vec<usize>], &[(usize, usize)]),
law: Friction,
tangent: bool,
state: &mut State,
internal: &mut [f64],
blocks: &mut Blocks,
) -> Result<()> {
let n = self.nodes.len();
state.traction = vec![[0.0; 3]; n];
state.slip = vec![[0.0; 3]; n];
state.slipping = vec![false; n];
// Per active pair: its corners' weighted slips and their Jacobians.
let terms = parallel::map_grained(active.len(), GRAIN, |t| {
let &(s, k) = active.get(t)?;
let (f, corners) = (self.secondary.get(s)?, corner_index.get(s)?);
let m = self.main.get(pairs.get(s)?.get(k)?.main)?;
let (xs, xm) = (Interface::corners(mesh, u, f), Interface::corners(mesh, u, m));
let (rs, rm) = (Interface::corners(mesh, reference, f), Interface::corners(mesh, reference, m));
let operator: Vec<[[f64; VARS]; 3]> = match self.fixed.as_ref().and_then(|f| f.get(s)).and_then(|l| l.get(k)) {
Some(fixed) => (0..corners.len()).map(|a| fixed.slip_jacobian(a)).collect(),
None => slip_operator(self.extent, &xs, &xm)?.into_iter().take(corners.len()).collect(),
};
let mut delta = [0.0; VARS];
for (i, (x, r)) in xs.iter().zip(&rs).enumerate() {
(0..3).for_each(|c| delta[3 * i + c] = x[c] - r[c]);
}
for (j, (x, r)) in xm.iter().zip(&rm).enumerate() {
(0..3).for_each(|c| delta[12 + 3 * j + c] = x[c] - r[c]);
}
let slips: Vec<(Vec3, [[f64; VARS]; 3])> =
operator.into_iter().map(|b| (b.map(|row| row.iter().zip(&delta).map(|(x, y)| x * y).sum()), b)).collect();
let nodes: Vec<usize> = f.nodes.iter().chain(&m.nodes).copied().collect();
let dofs: Vec<[Option<usize>; 3]> = f.dofs.iter().chain(&m.dofs).copied().collect();
Some((nodes, dofs, corners.clone(), slips))
});
let terms: Vec<_> = terms.into_iter().flatten().collect();
let var = |count: usize, c: usize, l: usize| if c < count { 3 * c + l } else { 12 + 3 * (c - count) + l };
// Node sums: the slip, and with the tangent J_A over the nodes the node reaches.
let mut total = vec![[0.0; 3]; n];
let mut jacobians: BTreeMap<usize, BTreeMap<usize, [[f64; 3]; 3]>> = BTreeMap::new();
for (nodes, _, corners, slips) in &terms {
for (a, i) in corners.iter().enumerate() {
let (Some((w, jac)), Some(sum)) = (slips.get(a), total.get_mut(*i)) else { continue };
(0..3).for_each(|k| sum[k] += w[k]);
if tangent && state.pressure.get(*i).is_some_and(|p| *p > 0.0) {
for (c, node) in nodes.iter().enumerate() {
let block = jacobians.entry(*i).or_default().entry(*node).or_insert([[0.0; 3]; 3]);
for k in 0..3 {
for l in 0..3 {
block[k][l] += jac[k].get(var(corners.len(), c, l)).copied().unwrap_or(0.0);
}
}
}
}
}
}
// The return map, and per node the tangent's middle matrix.
let mut middle: Vec<Option<[[f64; 3]; 3]>> = vec![None; n];
for i in 0..n {
let (Some(&p), Some(&area), Some(&eps), Some(&normal)) =
(state.pressure.get(i), self.area.get(i), self.penalty.get(i), state.normal.get(i))
else {
continue;
};
if !(p > 0.0) {
continue;
}
let old = committed.traction.get(i).copied().unwrap_or_default();
let along = dot(old, normal);
let slip = total.get(i).copied().unwrap_or_default().map(|w| w / area);
let trial: Vec3 = std::array::from_fn(|k| old[k] - along * normal[k] - eps * slip[k]);
let mu = if committed.slipping.get(i).copied().unwrap_or(false) { law.kinetic_coefficient() } else { law.static_coefficient };
let (limit, size) = (mu * p, norm(trial));
let (traction, slipping, m) = if size <= limit {
(trial, false, std::array::from_fn(|k| std::array::from_fn(|l| if k == l { eps / area } else { 0.0 })))
} else {
let dir = trial.map(|c| c / size);
let c = eps * limit / (size * area);
(
dir.map(|d| limit * d),
true,
std::array::from_fn(|k| std::array::from_fn(|l| c * (if k == l { 1.0 } else { 0.0 } - dir[k] * dir[l]))),
)
};
if let (Some(t), Some(g), Some(s), Some(mm)) =
(state.traction.get_mut(i), state.slip.get_mut(i), state.slipping.get_mut(i), middle.get_mut(i))
{
(*t, *g, *s, *mm) = (traction, slip, slipping, Some(m));
}
}
// Forces −t · ∂w/∂x.
for (_, dofs, corners, slips) in &terms {
for (a, i) in corners.iter().enumerate() {
let (Some(t), Some((_, jac))) = (state.traction.get(*i).copied(), slips.get(a)) else { continue };
if t == [0.0; 3] {
continue;
}
for (c, d) in dofs.iter().enumerate() {
let on: Vec3 = std::array::from_fn(|l| (0..3).map(|k| t[k] * jac[k].get(var(corners.len(), c, l)).copied().unwrap_or(0.0)).sum());
for l in 0..3 {
if let Some(x) = d[l].and_then(|d| internal.get_mut(d)) {
*x -= on[l];
}
}
if let Some(own) = corners.get(c).and_then(|j| state.nodal_force.get_mut(*j)) {
(0..3).for_each(|l| own[l] += on[l]);
}
}
}
}
// Jᵀ M J per closed node.
for (i, reach) in jacobians {
let Some(Some(m)) = middle.get(i) else { continue };
let nodes: Vec<usize> = reach.keys().copied().collect();
let blocks_of: Vec<&[[f64; 3]; 3]> = reach.values().collect();
let width = 3 * nodes.len();
let mut data = vec![0.0; width * width];
for (a, ja) in blocks_of.iter().enumerate() {
for (b, jb) in blocks_of.iter().enumerate() {
for l in 0..3 {
for r in 0..3 {
let mut v = 0.0;
for k in 0..3 {
for q in 0..3 {
v += ja[k][l] * m[k][q] * jb[q][r];
}
}
if let Some(x) = data.get_mut((3 * a + l) * width + 3 * b + r) {
*x = v;
}
}
}
}
}
blocks.push((nodes, data));
}
Ok(())
}
/// The result of a converged state.
pub fn result(&self, mesh: &Mesh, state: &State, augmentations: usize) -> ContactResult {
let status: Vec<Status> = state
.pressure
.iter()
.enumerate()
.map(|(i, p)| match (*p > 0.0, self.friction.is_some(), state.slipping.get(i).copied().unwrap_or(false)) {
(false, ..) => Status::Open,
(true, false, _) => Status::Closed,
(true, true, false) => Status::Stick,
(true, true, true) => Status::Slip,
})
.collect();
let penetration = state.gap.iter().zip(&status).filter(|(_, s)| s.is_closed()).filter_map(|(g, _)| *g).fold(0.0f64, |m, g| m.max(-g));
// The force on the secondary nodes pushes them back along −n; its tangential part is the
// friction's.
let normal: Vec<f64> = state.nodal_force.iter().zip(&state.normal).map(|(f, n)| -dot(*f, *n)).collect();
let per_area = |v: f64, a: f64| if a > 0.0 { v / a } else { 0.0 };
let pressure = normal.iter().zip(&state.support).map(|(f, a)| per_area(*f, *a)).collect();
let shear = if self.friction.is_some() {
state
.nodal_force
.iter()
.zip(&state.normal)
.zip(&state.support)
.map(|((f, n), a)| {
let along = dot(*f, *n);
std::array::from_fn(|k| per_area(f[k] - along * n[k], *a))
})
.collect()
} else {
Vec::new()
};
let force = state.nodal_force.iter().fold([0.0; 3], |s, f| [s[0] + f[0], s[1] + f[1], s[2] + f[2]]);
ContactResult {
name: self.name.clone(),
nodes: self.nodes.iter().map(|n| mesh.labels.get(*n).copied().unwrap_or(0)).collect(),
pressure,
multiplier: state.pressure.clone(),
shear,
slip: state.slip.clone(),
gap: state.gap.clone(),
status,
force,
normal_force: normal.iter().sum(),
penetration,
augmentations,
}
}
/// The active set held at `committed`, checked at `state` (computed with it): `None` when it
/// is consistent there (no held-closed node pulls with more than ε_A times
/// [`GAP_TOLERANCE`] of its element's thickness, and no held-open node presses by more: a
/// node within that band touches with no pressure, and either status is the solution to the
/// tolerance), else the set with the offending nodes switched (the primal–dual active set
/// update; S. Hüeber, B. I. Wohlmuth, *A primal–dual active set strategy for non-linear
/// multibody contact problems*, CMAME 194:3147–3166, 2005).
pub fn update_held(&self, committed: &Committed, state: &State) -> Option<Vec<bool>> {
let held = committed.active.as_ref()?;
let mut next = held.clone();
let mut changed = false;
for (i, closed) in next.iter_mut().enumerate() {
let (Some(Some(gap)), Some(e), Some(h)) = (state.gap.get(i), self.penalty.get(i), self.thickness.get(i)) else { continue };
let trial = committed.multipliers.get(i).copied().unwrap_or(0.0) - e * gap;
let limit = e * GAP_TOLERANCE * h;
if (*closed && trial < -limit) || (!*closed && trial > limit) {
*closed = !*closed;
changed = true;
}
}
changed.then_some(next)
}
/// How far the augmentation is from converged at `state`, computed with `multipliers`: the
/// largest gap of a closed node, or the gap equivalent λ_A / ε_A of the multiplier an open
/// node keeps, over the thickness of the element under the node.
pub fn augmentation_error(&self, multipliers: &[f64], state: &State) -> f64 {
let mut worst = 0.0f64;
for i in 0..self.nodes.len() {
let (Some(p), Some(e), Some(h)) = (state.pressure.get(i), self.penalty.get(i), self.thickness.get(i)) else { continue };
let lambda = multipliers.get(i).copied().unwrap_or(0.0);
let gap = if *p > 0.0 { state.gap.get(i).copied().flatten().unwrap_or(0.0).abs() } else { lambda.max(0.0) / e };
worst = worst.max(gap / h);
}
worst
}
}
#[cfg(test)]
mod tests {
use super::*;
use fieldcraft_model::{Elastic, Material, Section, SectionKind, Set};
/// Two separate quads facing each other, the upper one tilted and pressed into the lower
/// one, with general contact; `axisymmetric` off the axis.
fn pressed_quads(axisymmetric: bool) -> Model {
let mut m = Model::default();
let x0 = if axisymmetric { 2.0 } else { 0.0 };
let lower = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
let upper = [[0.25, 1.03], [1.2, 1.01], [1.25, 1.9], [0.3, 1.95]];
for (i, p) in lower.iter().chain(&upper).enumerate() {
m.set_node(i as u32 + 1, [x0 + p[0], p[1], 0.0]).unwrap();
}
m.set_element(1, ElementKind::Quad4, vec![1, 2, 3, 4]).unwrap();
m.set_element(2, ElementKind::Quad4, vec![5, 6, 7, 8]).unwrap();
m.add_set("all", Set { elements: vec![1, 2], ..Set::default() }).unwrap();
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: 10.0, poisson: 0.3 }), ..Material::default() }).unwrap();
let kind = if axisymmetric { SectionKind::Axisymmetric {} } else { SectionKind::PlaneStrain { thickness: 1.5 } };
m.add_section("s", Section { set: "all".into(), material: "Mat".into(), kind, formulation: Default::default() }).unwrap();
m.add_contact("c", Contact::new(ContactSurfaces::General { surface: None })).unwrap();
let step = fieldcraft_model::Step::new("Step", fieldcraft_model::Procedure::NonlinearStatic(Default::default()));
m.add_step(step, None).unwrap();
m
}
/// The contact's tangent blocks are the derivatives of its forces (central differences), in
/// plane strain and axisymmetric models.
#[test]
fn the_2d_contact_tangent_is_the_derivative_of_its_forces() {
for axisymmetric in [false, true] {
let m = pressed_quads(axisymmetric);
let resolved = m.resolve().unwrap();
let mesh = Mesh::new(&m, &resolved, 1_000_000).unwrap();
let interfaces = Interface::all(&m, &resolved, &mesh, true, &mut Vec::new()).unwrap();
let n = mesh.dofs();
// A small motion of every node, so no pair is symmetric by accident, and the upper
// quad pressed into the lower one.
let mut u: Vec<f64> = (0..n).map(|i| 0.01 * ((i * 7 % 5) as f64 - 2.0)).collect();
for node in 4..8 {
if let Some(d) = translations(&mesh, node).ok().and_then(|d| d.get(1).copied()) {
u[d] -= 0.06;
}
}
let forces = |u: &[f64], blocks: &mut Blocks| {
let mut f = vec![0.0; n];
for c in &interfaces {
let committed = c.committed();
c.evaluate(&mesh, u, &vec![0.0; n], &committed, true, &mut f, blocks).unwrap();
}
f
};
let mut blocks = Blocks::new();
forces(&u, &mut blocks);
// The dense tangent over (node, x/y) from the 3-per-node blocks.
let dof = |node: usize, k: usize| translations(&mesh, node).ok().and_then(|d| d.get(k).copied());
let mut k = vec![0.0; n * n];
for (nodes, data) in &blocks {
let w = 3 * nodes.len();
for (a, na) in nodes.iter().enumerate() {
for (b, nb) in nodes.iter().enumerate() {
for r in 0..2 {
for c in 0..2 {
if let (Some(i), Some(j)) = (dof(*na, r), dof(*nb, c)) {
k[i * n + j] += data[(3 * a + r) * w + 3 * b + c];
}
}
}
}
}
}
let scale = k.iter().fold(0.0f64, |a, v| a.max(v.abs()));
assert!(scale > 0.0, "the quads are in contact");
let h = 1e-7;
for j in 0..n {
let shifted = |s: f64| {
let mut v = u.clone();
v[j] += s;
forces(&v, &mut Blocks::new())
};
let (p, q) = (shifted(h), shifted(-h));
for i in 0..n {
let fd = (p[i] - q[i]) / (2.0 * h);
assert!((fd - k[i * n + j]).abs() < 1e-5 * scale, "axisymmetric {axisymmetric}: ({i}, {j}) {fd} vs {}", k[i * n + j]);
}
}
}
}
}