forked from 1b-agent/fieldcraft-upstream
explicit: deformable contact and friction, verified by bars, sliding blocks, the patch test and Hertz
Segment-to-segment penalty contact between element faces: each secondary face and each main face facing it are projected onto the secondary face's plane, split into triangles, clipped (Sutherland–Hodgman) and integrated with a three-point rule per sub-triangle (Puso–Laursen segmentation, the penalty at the integration points as Fischer–Wriggers), so a uniform pressure gives the consistent nodal forces on both meshes and the contact patch test passes on non-matching meshes. Every contact runs both ways at half the penalty: pairs with each surface as the secondary, general and self contact face against face. A bucket grid of face boxes every increment, faces integrated in parallel and their forces added in face order: the same numbers on any thread count. The penalty per area ε = s γ min(m/A)/Δt² comes from the lumped masses, each node's directional area and the stable increment, bounded by Gershgorin's row sums so contact never shortens the increment. Coulomb friction with exponential decay by a regularised return map at the secondary nodes; rigid walls take Coulomb impulses. Contact work and friction dissipation join the energy balance; each contact reports its peak force, first and last contact, deepest penetration, friction and the nodes adjusted at the start (initial penetrations adjusted strain-free or refused with the node, face and depth); a cap on contact faces. Verified (crates/explicit/tests/contact.rs, tests/oracle/explicit-*): colliding bars (2L/c +1.2%, ρcAv₀/2, KE kept to 0.005%, contact energy back to 8e-5 of it), blocks sliding to a stop on a rigid wall and on a plate under constant and decaying friction (0.005–0.2%), the patch test (σ_zz uniform to 0.06%), two spheres colliding against Hertz (peak +2.3%, duration −0.4%; the oracle case waits for a graded sphere mesher), a free body untouched by general contact, determinism across threads. Oracle measures gain `over` (ratios). Existing fixtures do not move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
ecf947c090
commit
a30d0b5c5b
37 files changed
+2459
-55
No files matched your search
@@ -0,0 +1,822 @@
|
||||
//! Penalty contact between element faces, with Coulomb friction.
|
||||
//!
|
||||
//! **Kinematics: segment to segment, both ways.** The normal contact pressure is integrated over
|
||||
//! the overlap of each pair of facing faces: a secondary face and a main face it faces are
|
||||
//! projected onto the secondary face's plane, each split into triangles, clipped against each
|
||||
//! other (Sutherland–Hodgman), and the overlap is integrated with a three-point rule per
|
||||
//! sub-triangle; at each point the gap g = (x_s − x_m) · n_m between the two faces (n_m the main
|
||||
//! face's outward normal) gives a penalty pressure p = ε (−g) where they overlap, which acts on
|
||||
//! the secondary face's nodes with weights N_i^s and on the main face's with −N_j^m. This is the
|
||||
//! segmentation and integration of mortar contact (M. A. Puso, T. A. Laursen, *A mortar
|
||||
//! segment-to-segment contact method for large deformation solid mechanics*, CMAME
|
||||
//! 193:601–629, 2004) with the penalty applied at the integration points (K. A. Fischer, P.
|
||||
//! Wriggers, *Frictionless 2D contact formulations for finite deformations based on the mortar
|
||||
//! method*, Comput. Mech. 36:226–244, 2005): for a uniform pressure across flat faces both sides
|
||||
//! receive exactly the consistent nodal forces ∫ p N dA, whatever the meshes, so the contact
|
||||
//! patch test is passed — node-to-segment contact fails it on non-matching meshes (R. L. Taylor,
|
||||
//! P. Papadopoulos, *On a patch test for contact problems in two dimensions*, 1991). Every
|
||||
//! contact runs both ways — a pair with each surface as the secondary in turn, single-surface
|
||||
//! (general and self) contact with every face against every other one that shares no node with
|
||||
//! it — at half the penalty each, so neither side is privileged and no corner of either surface
|
||||
//! sinks into the other. Two faces are in contact where they face each other (their normals more
|
||||
//! than [`FACING`] from parallel, which keeps the faces of one body that meet at an edge apart)
|
||||
//! and the gap is negative but no deeper than half the main face's element is thick (deeper, the
|
||||
//! surfaces have passed through each other). Faces of eroded elements leave the contact.
|
||||
//!
|
||||
//! **Search.** Every increment: each surface's faces go into a bucket grid with their boxes grown
|
||||
//! by their depth limit ([`fieldcraft_fem::contact::Grid`]: D. J. Benson, J. O. Hallquist, *A
|
||||
//! single surface contact algorithm for the post-buckling analysis of shell structures*, CMAME
|
||||
//! 78:141–163, 1990); each secondary face takes the main faces listed in the cells its box
|
||||
//! overlaps. The faces are integrated in parallel, each on its own, and their forces added face by
|
||||
//! face in a fixed order, so the result does not depend on how the faces are split among threads.
|
||||
//!
|
||||
//! **Penalty.** The penalty per area comes from the masses and the stable increment Δt:
|
||||
//! ε = s γ min_i (m_i / A_i) / Δt², with m_i the lumped mass of node i and A_i the area that
|
||||
//! bounds its contact stiffness, over the nodes of the contact. The stiffness entries of node i
|
||||
//! in direction d sum to at most 2 ε Σ_f a_f |n_f,d| Σ_d′ |n_f,d′| over its faces f (a_f its
|
||||
//! share of the face, a quarter of a quadrilateral or a third of a triangle, n_f the unit normal;
|
||||
//! its weights over a face sum to one on both sides), so A_i = κ_i max_d Σ_f a_f |n_f,d| with
|
||||
//! κ_i = max_f Σ_d |n_f,d| ∈ [1, √3]: the node's area on a flat surface in any orientation, and
|
||||
//! no more than the largest of its areas facing one way at an edge or a corner. By Gershgorin's
|
||||
//! theorem the contact then adds at most 2 s γ / Δt² to the square of any frequency (Weyl's
|
||||
//! inequality adds it to the elements'); with γ = [`GAMMA`] and the
|
||||
//! default increment scale (0.9 of the critical increment, ω Δt ≤ 1.8) the step stays under the
|
||||
//! stability limit ω Δt = 2 (1.8² + 2γ ≤ 4) and contact never shortens the increment. The penalty
|
||||
//! scale s ≤ 1 only softens it. The pressure stiffness this gives is about a fifth of the
|
||||
//! smallest element's M/h (M the constrained modulus): stiffer than the element-based penalty
|
||||
//! f K A²/V with its usual f = 0.1 (J. O. Hallquist, G. L. Goudreau, D. J. Benson, *Sliding
|
||||
//! interfaces with contact-impact in large-scale Lagrangian computations*, CMAME 51:107–137,
|
||||
//! 1985), and a uniform ε keeps a uniform pressure uniform.
|
||||
//!
|
||||
//! **Friction** at the secondary nodes by a regularised return map (P. Wriggers, *Computational
|
||||
//! Contact Mechanics*, 2nd ed., Springer 2006, §5.2 and §10.3; J. C. Simo, T. A. Laursen, *An
|
||||
//! augmented Lagrangian treatment of contact problems involving friction*, Comput. Struct.
|
||||
//! 42(1):97–116, 1992): a node's normal force f_n is its share of the pressure, its tangential
|
||||
//! force an elastic predictor, the previous force moved onto the current tangent plane less
|
||||
//! k_t times the tangential motion of the node relative to the closest point of the main face that
|
||||
//! pressed it most over the increment, t* = P t_n − k_t Δu_T with k_t the node's normal stiffness ε A_i / 2; if
|
||||
//! |t*| ≤ μ f_n the node sticks (t = t*), otherwise it slips and the force returns to the cone,
|
||||
//! t = μ f_n t*/|t*|, dissipating μ f_n (|t*| − μ f_n)/k_t. The reaction goes to the main face's
|
||||
//! nodes with −N_j. The coefficient μ = μ_k + (μ_s − μ_k) e^{−d γ̇} follows the slip rate
|
||||
//! γ̇ = |Δu_T|/Δt ([`fieldcraft_model::Friction`]). A node that leaves contact forgets its
|
||||
//! tangential force.
|
||||
//!
|
||||
//! **Energy.** The contact forces enter the equations of motion like external forces; their work
|
||||
//! (the penalty energy stored, plus what friction dissipates) is integrated with the trapezoidal
|
||||
//! rule like the internal forces' (see [`crate::integrate`]), and the friction dissipation is
|
||||
//! reported too.
|
||||
//!
|
||||
//! **Initial penetration.** Before the analysis each node of a secondary surface is projected
|
||||
//! onto the faces of the main surface ([`fieldcraft_fem::contact::closest_point`]); a node behind
|
||||
//! a face it lies over (within a fiftieth of the face's size), within the depth limit and with its
|
||||
//! own faces facing it, is moved onto it (strain-free: the reference coordinates change before the
|
||||
//! elements are built) or the analysis is refused with the node, the face and the depth, as the
|
||||
//! contact's `initial_penetration` says.
|
||||
//!
|
||||
//! **Erosion.** The faces of eroded elements leave the contact; the faces their removal exposes
|
||||
//! do not join it (eroding contact is later work).
|
||||
|
||||
use fieldcraft_fem::contact::{Aabb, Grid, Projection, bounds, closest_point};
|
||||
use fieldcraft_fem::frame::{cross, dot, norm, sub};
|
||||
use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, ElementKind, Friction, InitialPenetration, Model, Resolved};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::elements::{State, add_at};
|
||||
use crate::system::NODE_DOFS;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The penalty's share of the stability budget: ε = s γ min(m/A) / Δt² (see the module docs).
|
||||
pub const GAMMA: f64 = 0.3;
|
||||
|
||||
/// A node lies over a face when its lateral offset is at most this fraction of the face's size
|
||||
/// (the initial-penetration check).
|
||||
pub const EDGE_TOLERANCE: f64 = 0.02;
|
||||
|
||||
/// A point deeper than this fraction of the thickness of a face's element has passed through it.
|
||||
pub const DEPTH_FRACTION: f64 = 0.5;
|
||||
|
||||
/// Facing faces: the dot product of their unit normals is below minus this.
|
||||
pub const FACING: f64 = 0.1;
|
||||
|
||||
/// Faces per parallel task.
|
||||
const BLOCK: usize = 64;
|
||||
|
||||
/// The three-point rule on a triangle, exact for quadratics: barycentric points (of the second
|
||||
/// and third corners), weights summing to one.
|
||||
const TRIANGLE_RULE: [([f64; 2], f64); 3] =
|
||||
[([1.0 / 6.0, 1.0 / 6.0], 1.0 / 3.0), ([2.0 / 3.0, 1.0 / 6.0], 1.0 / 3.0), ([1.0 / 6.0, 2.0 / 3.0], 1.0 / 3.0)];
|
||||
|
||||
type Vec3 = [f64; 3];
|
||||
|
||||
/// A face of a contact surface.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Face {
|
||||
/// The corner nodes (system indices), in the face's order; the last unused on a triangle.
|
||||
pub nodes: [usize; 4],
|
||||
pub count: usize,
|
||||
/// The element's label, its position in [`crate::system::System::elements`] and the face
|
||||
/// number.
|
||||
pub element: u32,
|
||||
pub index: usize,
|
||||
pub face: u8,
|
||||
/// The nodes of the face's element (a node of it is never in contact with the face).
|
||||
pub owner: Vec<usize>,
|
||||
/// The penetration beyond which a point has passed through: [`DEPTH_FRACTION`] of the
|
||||
/// element's thickness under the face.
|
||||
pub depth: f64,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
fn corners(&self) -> &[usize] {
|
||||
self.nodes.get(..self.count).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces and nodes of one surface.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Surface {
|
||||
pub faces: Vec<Face>,
|
||||
/// Its nodes, sorted (system indices).
|
||||
pub nodes: Vec<usize>,
|
||||
/// Per node (in `nodes` order), the faces that hold it.
|
||||
node_faces: Vec<Vec<usize>>,
|
||||
/// Per node, the area of the surface that bounds its contact stiffness (undeformed; see
|
||||
/// [`Interface::forces`]).
|
||||
area: Vec<f64>,
|
||||
}
|
||||
|
||||
/// A node's friction state in one pass.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct Slip {
|
||||
/// The tangential force on the node.
|
||||
force: Vec3,
|
||||
}
|
||||
|
||||
/// One pass: the faces of a surface (secondary) against the faces of a surface (main).
|
||||
#[derive(Debug, Clone)]
|
||||
struct Pass {
|
||||
secondary: usize,
|
||||
main: usize,
|
||||
/// Per node of the secondary surface.
|
||||
slip: Vec<Slip>,
|
||||
}
|
||||
|
||||
/// A contact as the solver runs it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Interface {
|
||||
pub name: String,
|
||||
pub friction: Friction,
|
||||
pub scale: f64,
|
||||
pub surfaces: Vec<Surface>,
|
||||
passes: Vec<Pass>,
|
||||
/// Nodes moved onto the other surface before the analysis.
|
||||
pub adjusted: Vec<Adjustment>,
|
||||
/// min over the contact's nodes of mass / area, once the masses are known.
|
||||
mass_per_area: Option<f64>,
|
||||
}
|
||||
|
||||
/// A node moved onto a face before the analysis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||
pub struct Adjustment {
|
||||
pub node: u32,
|
||||
pub element: u32,
|
||||
pub face: u8,
|
||||
/// How far it started inside.
|
||||
pub depth: f64,
|
||||
}
|
||||
|
||||
/// What one evaluation of the contact forces found, per contact.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct Evaluation {
|
||||
/// The normal force across the contact (the integrated pressure, both ways).
|
||||
pub normal_force: f64,
|
||||
/// The deepest penetration.
|
||||
pub penetration: f64,
|
||||
/// Integration points in contact.
|
||||
pub active: usize,
|
||||
/// Friction dissipated over the increment that led here.
|
||||
pub dissipation: f64,
|
||||
}
|
||||
|
||||
fn invalid(m: impl Into<String>) -> Error {
|
||||
Error::Invalid(m.into())
|
||||
}
|
||||
|
||||
/// The volume of a solid element of corners `x` (hex8 or tet4), for the thickness under a face.
|
||||
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),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's area vector (its outward normal times its area, flat or bilinear).
|
||||
fn area_vector(x: &[Vec3]) -> Vec3 {
|
||||
match x {
|
||||
[a, b, c] => cross(sub(*b, *a), sub(*c, *a)).map(|v| 0.5 * v),
|
||||
[a, b, c, d] => cross(sub(*c, *a), sub(*d, *b)).map(|v| 0.5 * v),
|
||||
_ => [0.0; 3],
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest side of a face's bounding box.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Where a node is: its rest position plus its displacement.
|
||||
fn position(xyz: &[Vec3], u: &[f64], node: usize) -> Vec3 {
|
||||
let x = xyz.get(node).copied().unwrap_or_default();
|
||||
[0, 1, 2].map(|k| x[k] + u.get(NODE_DOFS * node + k).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// A node's displacement increment.
|
||||
fn shift(du: &[f64], node: usize) -> Vec3 {
|
||||
[0, 1, 2].map(|k| du.get(NODE_DOFS * node + k).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
/// The surface of element faces `faces`, its nodes, their faces and areas. `node` and
|
||||
/// `element` give a label's index among the analysed nodes and elements.
|
||||
fn new(
|
||||
model: &Model,
|
||||
faces: &[ElementFace],
|
||||
xyz: &[Vec3],
|
||||
node: &impl Fn(u32) -> Option<usize>,
|
||||
element: &impl Fn(u32) -> Option<usize>,
|
||||
) -> Result<Surface> {
|
||||
let mut out = Surface::default();
|
||||
for f in faces {
|
||||
let at = |what: &str| invalid(format!("element {} face {}: {what}", f.element, f.face));
|
||||
let el = model.element(f.element).ok_or_else(|| at("the element is missing"))?;
|
||||
if !matches!(el.kind, ElementKind::Hex8 | ElementKind::Tet4) {
|
||||
return Err(Error::NotSupported(format!(
|
||||
"contact on the faces of {} elements (element {}); explicit contact acts on hex8 and tet4 faces",
|
||||
el.kind.name(),
|
||||
f.element
|
||||
)));
|
||||
}
|
||||
let labels = model.face_nodes(*f).ok_or_else(|| at("no such face"))?;
|
||||
let corners: Vec<usize> = labels.iter().map(|l| node(*l).ok_or_else(|| at("a node is not analysed"))).collect::<Result<_>>()?;
|
||||
let owner: Vec<usize> = el.nodes.iter().filter_map(|l| node(*l)).collect();
|
||||
let index = element(f.element).ok_or_else(|| at("the element is not analysed"))?;
|
||||
let x: Vec<Vec3> = corners.iter().map(|n| xyz.get(*n).copied().unwrap_or_default()).collect();
|
||||
let ex: Vec<Vec3> = owner.iter().map(|n| xyz.get(*n).copied().unwrap_or_default()).collect();
|
||||
let area = norm(area_vector(&x));
|
||||
// Thickness V/A; a face of no area or an element of no volume falls back on its size.
|
||||
let thickness = volume(el.kind, &ex).filter(|v| *v > 0.0 && area > 0.0).map_or(size(&x), |v| v / area);
|
||||
let mut nodes = [usize::MAX; 4];
|
||||
for (slot, n) in nodes.iter_mut().zip(&corners) {
|
||||
*slot = *n;
|
||||
}
|
||||
out.faces.push(Face { nodes, count: corners.len(), element: f.element, index, face: f.face, owner, depth: DEPTH_FRACTION * thickness });
|
||||
}
|
||||
out.nodes = out.faces.iter().flat_map(|f| f.corners().iter().copied()).collect();
|
||||
out.nodes.sort_unstable();
|
||||
out.nodes.dedup();
|
||||
out.node_faces = vec![Vec::new(); out.nodes.len()];
|
||||
// Per node and direction, Σ_f A_f |n_f,d| / count, and the largest Σ_d |n_d| of its faces.
|
||||
let mut directional = vec![[0.0; 3]; out.nodes.len()];
|
||||
let mut spread = vec![1.0f64; out.nodes.len()];
|
||||
for (i, f) in out.faces.iter().enumerate() {
|
||||
let x: Vec<Vec3> = f.corners().iter().map(|n| xyz.get(*n).copied().unwrap_or_default()).collect();
|
||||
let a = area_vector(&x);
|
||||
let l = norm(a);
|
||||
let share = a.map(|c| c.abs() / f.count as f64);
|
||||
let kappa = if l > 0.0 { a.iter().map(|c| c.abs()).sum::<f64>() / l } else { 1.0 };
|
||||
for n in f.corners() {
|
||||
if let Ok(k) = out.nodes.binary_search(n) {
|
||||
if let Some(list) = out.node_faces.get_mut(k) {
|
||||
list.push(i);
|
||||
}
|
||||
if let (Some(d), Some(s)) = (directional.get_mut(k), spread.get_mut(k)) {
|
||||
for c in 0..3 {
|
||||
d[c] += share[c];
|
||||
}
|
||||
*s = s.max(kappa);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.area = directional.iter().zip(&spread).map(|(d, s)| s * d.iter().copied().fold(0.0, f64::max)).collect();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The faces' corner positions at displacements `u`.
|
||||
fn positions(&self, xyz: &[Vec3], u: &[f64]) -> Vec<Vec<Vec3>> {
|
||||
self.faces.iter().map(|f| f.corners().iter().map(|n| position(xyz, u, *n)).collect()).collect()
|
||||
}
|
||||
|
||||
/// The nodes' outward normals (the sum of their faces' area vectors; not normalised).
|
||||
fn node_normals(&self, faces: &[Vec<Vec3>]) -> Vec<Vec3> {
|
||||
self.node_faces
|
||||
.iter()
|
||||
.map(|list| {
|
||||
let mut n = [0.0; 3];
|
||||
for a in list.iter().filter_map(|i| faces.get(*i)).map(|x| area_vector(x)) {
|
||||
for k in 0..3 {
|
||||
n[k] += a[k];
|
||||
}
|
||||
}
|
||||
n
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces' grid for the global search at positions `positions`.
|
||||
fn grid(surface: &Surface, positions: &[Vec<Vec3>]) -> Grid {
|
||||
let boxes: Vec<Aabb> = surface.faces.iter().zip(positions).map(|(f, x)| bounds(x, f.depth)).collect();
|
||||
Grid::new(&boxes, 4 * boxes.len() + 64)
|
||||
}
|
||||
|
||||
/// The face a node at `x` (index `node`, outward normal `normal`) starts inside, among the grid's
|
||||
/// candidates: over it, behind it within its depth limit, facing it; the nearest.
|
||||
fn inside(surface: &Surface, positions: &[Vec<Vec3>], grid: &Grid, node: usize, x: Vec3, normal: Vec3) -> Option<(usize, Projection)> {
|
||||
let mut best: Option<(usize, Projection)> = None;
|
||||
for &c in grid.candidates(x) {
|
||||
let c = c as usize;
|
||||
let (Some(face), Some(fx)) = (surface.faces.get(c), positions.get(c)) else { continue };
|
||||
if face.owner.contains(&node) {
|
||||
continue;
|
||||
}
|
||||
let Some(q) = closest_point(fx, x) else { continue };
|
||||
let over = q.lateral <= EDGE_TOLERANCE * size(fx);
|
||||
let n = norm(normal);
|
||||
let facing = n == 0.0 || dot(normal, q.normal) < -FACING * n;
|
||||
if q.gap < 0.0 && -q.gap <= face.depth && over && facing && best.is_none_or(|b| q.distance < b.1.distance) {
|
||||
best = Some((c, q));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// A point of a plane in its own 2D coordinates.
|
||||
type Point = [f64; 2];
|
||||
|
||||
fn cross2(o: Point, a: Point, b: Point) -> f64 {
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
||||
}
|
||||
|
||||
/// The signed area of a polygon (counter-clockwise positive).
|
||||
fn signed_area(p: &[Point]) -> f64 {
|
||||
let mut s = 0.0;
|
||||
for (i, a) in p.iter().enumerate() {
|
||||
let b = p.get((i + 1) % p.len()).copied().unwrap_or(*a);
|
||||
s += a[0] * b[1] - a[1] * b[0];
|
||||
}
|
||||
0.5 * s
|
||||
}
|
||||
|
||||
/// `subject` clipped by the convex counter-clockwise polygon `clip` (Sutherland–Hodgman).
|
||||
fn clip(subject: &[Point], clip: &[Point]) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = subject.to_vec();
|
||||
for (i, a) in clip.iter().enumerate() {
|
||||
let Some(b) = clip.get((i + 1) % clip.len()).copied() else { break };
|
||||
let input = std::mem::take(&mut out);
|
||||
let side = |p: Point| cross2(*a, b, p);
|
||||
for (j, p) in input.iter().enumerate() {
|
||||
let Some(q) = input.get((j + 1) % input.len()).copied() else { break };
|
||||
let (sp, sq) = (side(*p), side(q));
|
||||
if sp >= 0.0 {
|
||||
out.push(*p);
|
||||
}
|
||||
if (sp >= 0.0) != (sq >= 0.0) {
|
||||
let t = sp / (sp - sq);
|
||||
out.push([p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])]);
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The corners `t` of `points`, counter-clockwise.
|
||||
fn counter_clockwise(t: &[usize; 3], points: &[Point]) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = t.iter().filter_map(|i| points.get(*i).copied()).collect();
|
||||
if signed_area(&out) < 0.0 {
|
||||
out.reverse();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The triangles a face splits into (corner positions in its order).
|
||||
fn triangles(count: usize) -> &'static [[usize; 3]] {
|
||||
if count == 3 { &[[0, 1, 2]] } else { &[[0, 1, 2], [0, 2, 3]] }
|
||||
}
|
||||
|
||||
/// What the integration needs of each main face, worked out once per increment.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct FaceInfo {
|
||||
/// Area vector and its length.
|
||||
area: Vec3,
|
||||
length: f64,
|
||||
/// The lateral offset beyond which a point is past the face's edge.
|
||||
edge: f64,
|
||||
alive: bool,
|
||||
}
|
||||
|
||||
fn face_info(surface: &Surface, positions: &[Vec<Vec3>], alive: &dyn Fn(&Face) -> bool) -> Vec<FaceInfo> {
|
||||
surface
|
||||
.faces
|
||||
.iter()
|
||||
.zip(positions)
|
||||
.map(|(f, x)| {
|
||||
let area = area_vector(x);
|
||||
FaceInfo { area, length: norm(area), edge: EDGE_TOLERANCE * size(x), alive: alive(f) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One secondary face against the main surface: its contributions to the nodal forces and to
|
||||
/// each secondary node's normal force.
|
||||
#[derive(Debug, Default)]
|
||||
struct FaceForces {
|
||||
/// (node, force) contributions, in order.
|
||||
forces: Vec<(usize, Vec3)>,
|
||||
/// (secondary node, normal force share, main face) of each integration point.
|
||||
normal: Vec<(usize, f64, usize)>,
|
||||
penetration: f64,
|
||||
points: usize,
|
||||
total: f64,
|
||||
}
|
||||
|
||||
/// The penalty pressure integrated over the overlaps of secondary face `f` (corners `fx`) with
|
||||
/// the main faces facing it, at penalty `eps` per area.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn integrate_face(
|
||||
f: &Face,
|
||||
fx: &[Vec3],
|
||||
alive: bool,
|
||||
main: &Surface,
|
||||
mx: &[Vec<Vec3>],
|
||||
info: &[FaceInfo],
|
||||
grid: &Grid,
|
||||
eps: f64,
|
||||
found: &mut Vec<u32>,
|
||||
) -> FaceForces {
|
||||
let mut out = FaceForces::default();
|
||||
if !alive {
|
||||
return out;
|
||||
}
|
||||
let area = area_vector(fx);
|
||||
let a = norm(area);
|
||||
if !(a > 0.0 && a.is_finite()) {
|
||||
return out;
|
||||
}
|
||||
let normal = area.map(|c| c / a);
|
||||
let n = fx.len() as f64;
|
||||
let center: Vec3 = [0, 1, 2].map(|k| fx.iter().map(|p| p[k]).sum::<f64>() / n);
|
||||
let Some(first) = fx.first().copied() else { return out };
|
||||
// A basis of the face's plane, (e1, e2, normal) right-handed.
|
||||
let r = sub(first, center);
|
||||
let r = sub(r, normal.map(|c| c * dot(r, normal)));
|
||||
let rl = norm(r);
|
||||
if !(rl > 0.0) {
|
||||
return out;
|
||||
}
|
||||
let e1 = r.map(|c| c / rl);
|
||||
let e2 = cross(normal, e1);
|
||||
let flat = |x: Vec3| -> Point {
|
||||
let d = sub(x, center);
|
||||
[dot(d, e1), dot(d, e2)]
|
||||
};
|
||||
let lift = |p: Point| -> Vec3 { [0, 1, 2].map(|k| center[k] + p[0] * e1[k] + p[1] * e2[k]) };
|
||||
let own: Vec<Point> = fx.iter().map(|x| flat(*x)).collect();
|
||||
let own_triangles: Vec<Vec<Point>> = triangles(f.count).iter().map(|t| counter_clockwise(t, &own)).collect();
|
||||
grid.candidates_in(&bounds(fx, 0.0), found);
|
||||
for &c in found.iter() {
|
||||
let c = c as usize;
|
||||
let (Some(m), Some(x), Some(mi)) = (main.faces.get(c), mx.get(c), info.get(c)) else { continue };
|
||||
if !mi.alive || m.corners().iter().any(|k| f.corners().contains(k)) {
|
||||
continue;
|
||||
}
|
||||
if !(mi.length > 0.0) || dot(mi.area, normal) >= -FACING * mi.length {
|
||||
continue;
|
||||
}
|
||||
let theirs: Vec<Point> = x.iter().map(|p| flat(*p)).collect();
|
||||
for s in &own_triangles {
|
||||
for tm in triangles(m.count) {
|
||||
let t = counter_clockwise(tm, &theirs);
|
||||
let overlap = clip(&t, s);
|
||||
if overlap.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let Some(o) = overlap.first().copied() else { continue };
|
||||
for w in overlap.windows(2).skip(1) {
|
||||
let (p, q) = (w[0], w[1]);
|
||||
let tri_area = 0.5 * cross2(o, p, q);
|
||||
if !(tri_area > 0.0) {
|
||||
continue;
|
||||
}
|
||||
for (bary, weight) in TRIANGLE_RULE {
|
||||
let y = [0, 1].map(|k| o[k] + bary[0] * (p[k] - o[k]) + bary[1] * (q[k] - o[k]));
|
||||
let point = lift(y);
|
||||
let (Some(qs), Some(qm)) = (closest_point(fx, point), closest_point(x, point)) else { continue };
|
||||
let gap = dot(sub(qs.point, qm.point), qm.normal);
|
||||
// Over the main face (its foot not clamped to an edge), behind it, within
|
||||
// its depth.
|
||||
if !(gap < 0.0 && -gap <= m.depth && qm.lateral <= mi.edge) {
|
||||
continue;
|
||||
}
|
||||
let push = eps * (-gap) * weight * tri_area;
|
||||
if !push.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let force = qm.normal.map(|k| push * k);
|
||||
for (node, w) in f.corners().iter().zip(qs.weights) {
|
||||
out.forces.push((*node, force.map(|k| w * k)));
|
||||
out.normal.push((*node, w * push, c));
|
||||
}
|
||||
for (node, w) in m.corners().iter().zip(qm.weights) {
|
||||
out.forces.push((*node, force.map(|k| -w * k)));
|
||||
}
|
||||
out.penetration = out.penetration.max(-gap);
|
||||
out.points += 1;
|
||||
out.total += push;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Interface {
|
||||
/// The contact `name` of the model on the analysed nodes and elements: its surfaces, and the
|
||||
/// initial penetrations adjusted in `xyz` (or refused).
|
||||
pub fn new(
|
||||
model: &Model,
|
||||
resolved: &Resolved,
|
||||
name: &str,
|
||||
contact: &Contact,
|
||||
xyz: &mut [Vec3],
|
||||
labels: &[u32],
|
||||
elements: &[u32],
|
||||
) -> Result<Interface> {
|
||||
let context = |e: Error| match e {
|
||||
Error::Invalid(m) => invalid(format!("contact `{name}`: {m}")),
|
||||
other => other,
|
||||
};
|
||||
let node = |l: u32| labels.binary_search(&l).ok();
|
||||
let element = |l: u32| elements.binary_search(&l).ok();
|
||||
let faces_of = |set: &str| resolved.sets.get(set).map(|s| s.faces.clone()).unwrap_or_default();
|
||||
let (surfaces, passes) = match &contact.surfaces {
|
||||
ContactSurfaces::Pair { main, secondary } => {
|
||||
let main = Surface::new(model, &faces_of(main), xyz, &node, &element).map_err(context)?;
|
||||
let secondary = Surface::new(model, &faces_of(secondary), xyz, &node, &element).map_err(context)?;
|
||||
(vec![main, secondary], vec![(1, 0), (0, 1)])
|
||||
}
|
||||
ContactSurfaces::General { surface } => {
|
||||
let faces = match surface {
|
||||
Some(s) => faces_of(s),
|
||||
None => model.exterior_faces(),
|
||||
};
|
||||
(vec![Surface::new(model, &faces, xyz, &node, &element).map_err(context)?], vec![(0, 0)])
|
||||
}
|
||||
};
|
||||
let passes = passes
|
||||
.into_iter()
|
||||
.map(|(secondary, main)| Pass { secondary, main, slip: vec![Slip::default(); surfaces.get(secondary).map_or(0, |s| s.nodes.len())] })
|
||||
.collect();
|
||||
let mut interface = Interface {
|
||||
name: name.to_string(),
|
||||
friction: contact.friction,
|
||||
scale: contact.penalty_scale,
|
||||
surfaces,
|
||||
passes,
|
||||
adjusted: Vec::new(),
|
||||
mass_per_area: None,
|
||||
};
|
||||
interface.adjust(xyz, labels, contact.initial_penetration)?;
|
||||
Ok(interface)
|
||||
}
|
||||
|
||||
/// Move the nodes that start inside a face onto it, or refuse them.
|
||||
fn adjust(&mut self, xyz: &mut [Vec3], labels: &[u32], mode: InitialPenetration) -> Result<()> {
|
||||
let zero: Vec<f64> = Vec::new();
|
||||
for pass in &self.passes {
|
||||
let (Some(nodes), Some(faces)) = (self.surfaces.get(pass.secondary), self.surfaces.get(pass.main)) else { continue };
|
||||
let positions = faces.positions(xyz, &zero);
|
||||
let grid = grid(faces, &positions);
|
||||
let own = nodes.positions(xyz, &zero);
|
||||
let normals = nodes.node_normals(&own);
|
||||
// Each move is decided on the positions before any move of this pass.
|
||||
let mut moves: Vec<(usize, Vec3, Adjustment)> = Vec::new();
|
||||
for (i, &n) in nodes.nodes.iter().enumerate() {
|
||||
let x = xyz.get(n).copied().unwrap_or_default();
|
||||
let normal = normals.get(i).copied().unwrap_or_default();
|
||||
let Some((c, q)) = inside(faces, &positions, &grid, n, x, normal) else { continue };
|
||||
let Some(face) = faces.faces.get(c) else { continue };
|
||||
// Coordinates are exact to a few units in the last place: a node a hair behind a
|
||||
// face it was meshed on is touching, not inside.
|
||||
let scale = x.iter().fold(1.0f64, |m, c| m.max(c.abs()));
|
||||
if -q.gap <= 1e-9 * scale {
|
||||
continue;
|
||||
}
|
||||
let label = labels.get(n).copied().unwrap_or(0);
|
||||
let adjustment = Adjustment { node: label, element: face.element, face: face.face, depth: -q.gap };
|
||||
if mode == InitialPenetration::Refuse {
|
||||
return Err(invalid(format!(
|
||||
"contact `{}`: node {label} starts {:.4e} inside face {} of element {} (move it out, or let the contact adjust it: initial_penetration \"adjust\")",
|
||||
self.name, -q.gap, face.face, face.element
|
||||
)));
|
||||
}
|
||||
moves.push((n, q.point, adjustment));
|
||||
}
|
||||
for (n, point, a) in moves {
|
||||
if let Some(x) = xyz.get_mut(n) {
|
||||
*x = point;
|
||||
}
|
||||
self.adjusted.push(a);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The number of faces.
|
||||
pub fn faces(&self) -> usize {
|
||||
self.surfaces.iter().map(|s| s.faces.len()).sum()
|
||||
}
|
||||
|
||||
/// Add the contact forces at displacements `u` into `force` (per degree of freedom), with
|
||||
/// `du` the displacement increment that led to `u` over `dt_prev`, the stable increment
|
||||
/// `dt` and the lumped masses per degree of freedom `mass`; update the friction state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn forces(
|
||||
&mut self,
|
||||
xyz: &[Vec3],
|
||||
u: &[f64],
|
||||
du: &[f64],
|
||||
dt_prev: f64,
|
||||
dt: f64,
|
||||
mass: &[f64],
|
||||
state: &State,
|
||||
force: &mut [f64],
|
||||
) -> Evaluation {
|
||||
let mut e = Evaluation::default();
|
||||
let node_mass = |n: usize| (0..3).map(|k| mass.get(NODE_DOFS * n + k).copied().unwrap_or(0.0)).fold(f64::INFINITY, f64::min);
|
||||
let mass_per_area = *self.mass_per_area.get_or_insert_with(|| {
|
||||
self.surfaces
|
||||
.iter()
|
||||
.flat_map(|s| s.nodes.iter().zip(&s.area))
|
||||
.filter(|(_, a)| **a > 0.0)
|
||||
.map(|(n, a)| node_mass(*n) / a)
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
});
|
||||
// Every contact runs both ways, at half the penalty each.
|
||||
let eps = 0.5 * self.scale * GAMMA * mass_per_area / (dt * dt);
|
||||
if !(eps > 0.0 && eps.is_finite()) {
|
||||
for pass in &mut self.passes {
|
||||
pass.slip.fill(Slip::default());
|
||||
}
|
||||
return e;
|
||||
}
|
||||
let alive = |f: &Face| state.elements.get(f.index).is_none_or(|s| s.failed.is_none());
|
||||
let positions: Vec<Vec<Vec<Vec3>>> = self.surfaces.iter().map(|s| s.positions(xyz, u)).collect();
|
||||
let grids: Vec<Grid> = self.surfaces.iter().zip(&positions).map(|(s, p)| grid(s, p)).collect();
|
||||
let friction = self.friction;
|
||||
for pass in &mut self.passes {
|
||||
let (Some(secondary), Some(main), Some(sx), Some(mx), Some(grid)) = (
|
||||
self.surfaces.get(pass.secondary),
|
||||
self.surfaces.get(pass.main),
|
||||
positions.get(pass.secondary),
|
||||
positions.get(pass.main),
|
||||
grids.get(pass.main),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let info = face_info(main, mx, &alive);
|
||||
let blocks = fieldcraft_parallel::map_grained(secondary.faces.len().div_ceil(BLOCK), 1, |b| {
|
||||
let mut found = Vec::new();
|
||||
(b * BLOCK..((b + 1) * BLOCK).min(secondary.faces.len()))
|
||||
.map(|i| match (secondary.faces.get(i), sx.get(i)) {
|
||||
(Some(f), Some(fx)) => integrate_face(f, fx, alive(f), main, mx, &info, grid, eps, &mut found),
|
||||
_ => FaceForces::default(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
// Per secondary node: its normal force, and the main face that gave it the most.
|
||||
let mut normal = vec![0.0; secondary.nodes.len()];
|
||||
let mut partner: Vec<Option<(usize, f64)>> = vec![None; secondary.nodes.len()];
|
||||
for face in blocks.iter().flatten() {
|
||||
for (node, f) in &face.forces {
|
||||
add_at(force, *node, f);
|
||||
}
|
||||
for (node, share, c) in &face.normal {
|
||||
if let Ok(k) = secondary.nodes.binary_search(node)
|
||||
&& let (Some(slot), Some(best)) = (normal.get_mut(k), partner.get_mut(k))
|
||||
{
|
||||
*slot += share;
|
||||
if best.is_none_or(|(_, s)| *share > s) {
|
||||
*best = Some((*c, *share));
|
||||
}
|
||||
}
|
||||
}
|
||||
e.normal_force += face.total;
|
||||
e.penetration = e.penetration.max(face.penetration);
|
||||
e.active += face.points;
|
||||
}
|
||||
if friction.is_frictionless() {
|
||||
continue;
|
||||
}
|
||||
// Friction at the secondary nodes, in node order, against the point under the node
|
||||
// of the main face that pressed it most.
|
||||
for (k, (&n, slip)) in secondary.nodes.iter().zip(pass.slip.iter_mut()).enumerate() {
|
||||
let fn_ = normal.get(k).copied().unwrap_or(0.0);
|
||||
let kt = eps * secondary.area.get(k).copied().unwrap_or(0.0);
|
||||
let hit = partner
|
||||
.get(k)
|
||||
.copied()
|
||||
.flatten()
|
||||
.filter(|_| fn_ > 0.0 && kt > 0.0)
|
||||
.and_then(|(c, _)| mx.get(c).and_then(|x| closest_point(x, position(xyz, u, n))).map(|q| (c, q)));
|
||||
let Some((c, q)) = hit else {
|
||||
*slip = Slip::default();
|
||||
continue;
|
||||
};
|
||||
let Some(face) = main.faces.get(c) else { continue };
|
||||
let mut rel = shift(du, n);
|
||||
for (m, w) in face.corners().iter().zip(q.weights) {
|
||||
let d = shift(du, *m);
|
||||
for c in 0..3 {
|
||||
rel[c] -= w * d[c];
|
||||
}
|
||||
}
|
||||
let normal = q.normal;
|
||||
let rn = dot(rel, normal);
|
||||
let slide = [0, 1, 2].map(|c| rel[c] - rn * normal[c]);
|
||||
let old = slip.force;
|
||||
let on = dot(old, normal);
|
||||
let trial: Vec3 = std::array::from_fn(|c| old[c] - on * normal[c] - kt * slide[c]);
|
||||
let size = norm(trial);
|
||||
let rate = if dt_prev > 0.0 { norm(slide) / dt_prev } else { 0.0 };
|
||||
let limit = friction.coefficient(rate) * fn_;
|
||||
let tangential = if size <= limit {
|
||||
trial
|
||||
} else {
|
||||
e.dissipation += limit * (size - limit) / kt;
|
||||
trial.map(|c| c * limit / size)
|
||||
};
|
||||
slip.force = tangential;
|
||||
add_at(force, n, &tangential);
|
||||
for (m, w) in face.corners().iter().zip(q.weights) {
|
||||
add_at(force, *m, &tangential.map(|c| -w * c));
|
||||
}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
}
|
||||
|
||||
/// Every contact of the model, built on the analysed nodes and elements (with the initial
|
||||
/// adjustments made in `xyz`), and no more than `max_faces` faces in all.
|
||||
pub fn interfaces(
|
||||
model: &Model,
|
||||
resolved: &Resolved,
|
||||
xyz: &mut [Vec3],
|
||||
labels: &[u32],
|
||||
elements: &[u32],
|
||||
max_faces: usize,
|
||||
) -> Result<Vec<Interface>> {
|
||||
let mut out = Vec::with_capacity(model.contacts().len());
|
||||
let mut faces = 0usize;
|
||||
for (name, c) in model.contacts() {
|
||||
let i = Interface::new(model, resolved, name, c, xyz, labels, elements)?;
|
||||
faces = faces.saturating_add(i.faces());
|
||||
if faces > max_faces {
|
||||
return Err(Error::Limit(format!(
|
||||
"the contacts hold more than {max_faces} faces (contact `{name}` passes the cap); name smaller surfaces"
|
||||
)));
|
||||
}
|
||||
out.push(i);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clipping_two_triangles_gives_their_overlap() {
|
||||
let a = [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0]];
|
||||
let b = [[1.0, -1.0], [1.0, 3.0], [-1.0, 1.0]];
|
||||
// b clockwise: reversed it is counter-clockwise.
|
||||
let mut b2 = b.to_vec();
|
||||
if signed_area(&b2) < 0.0 {
|
||||
b2.reverse();
|
||||
}
|
||||
let o = clip(&a, &b2);
|
||||
// The overlap: the part of a with x ≤ 1 and y ≤ x + 2… here the triangle's left half,
|
||||
// area 2 − ½ = 1.5.
|
||||
assert!((signed_area(&o) - 1.5).abs() < 1e-12, "{o:?} {}", signed_area(&o));
|
||||
assert!(clip(&a, &[[5.0, 5.0], [6.0, 5.0], [5.0, 6.0]]).is_empty());
|
||||
let same = clip(&a, &a);
|
||||
assert!((signed_area(&same) - 2.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ pub struct Workspace {
|
||||
pub stress_force: Vec<f64>,
|
||||
pub hourglass_force: Vec<f64>,
|
||||
pub viscous_force: Vec<f64>,
|
||||
/// Contact forces on the model per degree of freedom (empty without contact).
|
||||
pub contact_force: Vec<f64>,
|
||||
/// Per node: the volume-weighted volumetric strain increments of its tetrahedra, and their
|
||||
/// volume.
|
||||
theta: Vec<f64>,
|
||||
@@ -86,6 +88,7 @@ impl Workspace {
|
||||
stress_force: vec![0.0; dofs],
|
||||
hourglass_force: vec![0.0; dofs],
|
||||
viscous_force: vec![0.0; dofs],
|
||||
contact_force: if sys.contacts.is_empty() { Vec::new() } else { vec![0.0; dofs] },
|
||||
theta: vec![0.0; n],
|
||||
tet_volume: vec![0.0; n],
|
||||
tet_increment: vec![None; sys.tets.len()],
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
//! 1. u^n = u^{n−1} + Δt^{n−½} v^{n−½}; t^n = t^{n−1} + Δt^{n−½};
|
||||
//! 2. internal forces f^n from u^n (stresses, hourglass stiffness) and v^{n−½} (hourglass and
|
||||
//! bulk viscosity), and the next increment Δt^{n+½} = scale · min_e Δt_e;
|
||||
//! 3. v^{n+½} = v^{n−½} + Δt^n M⁻¹(f_ext − f^n), then the constraints: rigid walls set the normal
|
||||
//! velocity of a node that would cross its wall so it lands on it (a frictionless kinematic
|
||||
//! contact condition: T. J. R. Hughes, R. L. Taylor, J. L. Sackman, A. Curnier, W.
|
||||
//! Kanoknukulchai, *A finite element method for a class of contact-impact problems*, CMAME
|
||||
//! 8(3):249–276, 1976), and prescribed degrees of freedom take their velocity. The change a
|
||||
//! constraint makes is the impulse of its reaction, r^n = M Δv / Δt^n;
|
||||
//! 4. energies: the works of the internal and external forces by the trapezoidal rule,
|
||||
//! 3. the contact forces c^n at u^n ([`crate::contact`], with the stable increment Δt^{n+½});
|
||||
//! v^{n+½} = v^{n−½} + Δt^n M⁻¹(f_ext + c^n − f^n), then the constraints: rigid walls set the
|
||||
//! normal velocity of a node that would cross its wall so it lands on it (a kinematic contact
|
||||
//! condition: T. J. R. Hughes, R. L. Taylor, J. L. Sackman, A. Curnier, W. Kanoknukulchai,
|
||||
//! *A finite element method for a class of contact-impact problems*, CMAME 8(3):249–276,
|
||||
//! 1976), with Coulomb friction as a tangential impulse at most μ times the normal one (the
|
||||
//! tangential velocity stops, or loses μ Δv_n when it slides), and prescribed degrees of
|
||||
//! freedom take their velocity. The change a constraint makes is the impulse of its reaction,
|
||||
//! r^n = M Δv / Δt^n;
|
||||
//! 4. energies: the works of the internal, contact and external forces by the trapezoidal rule,
|
||||
//! W += ½ Δu · (f^{n−1} + f^n) (exactly the strain energy for a linear elastic material),
|
||||
//! the constraints' work Δt^n r^n · ½(v^{n−½} + v^{n+½}) (exactly the kinetic energy their
|
||||
//! impulses add or remove), and the kinetic energy of v^n = v^{n−½} + ½Δt^{n−½} a^n.
|
||||
@@ -22,8 +25,9 @@
|
||||
|
||||
use fieldcraft_model::{ExplicitDynamic, Model, OutputVariable, Procedure, Step};
|
||||
|
||||
use crate::contact::{Evaluation, Interface};
|
||||
use crate::elements::{Motion, State, Workspace, internal_forces};
|
||||
use crate::result::{ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
|
||||
use crate::result::{ContactSummary, ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
|
||||
use crate::system::{NODE_DOFS, System};
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
@@ -70,8 +74,10 @@ struct Output {
|
||||
}
|
||||
|
||||
/// Velocity constraints over an increment of length `dt` (rigid walls when dt > 0, then
|
||||
/// prescribed degrees of freedom).
|
||||
fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64) {
|
||||
/// prescribed degrees of freedom); returns the kinetic energy the walls' friction took (`mass`
|
||||
/// per degree of freedom).
|
||||
fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64, mass: &[f64]) -> f64 {
|
||||
let mut dissipated = 0.0;
|
||||
if dt > 0.0 {
|
||||
for w in &sys.walls {
|
||||
for &node in &w.nodes {
|
||||
@@ -86,6 +92,9 @@ fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64) {
|
||||
*c += change * w.normal[k];
|
||||
}
|
||||
}
|
||||
if !w.friction.is_frictionless() {
|
||||
dissipated += wall_friction(v, node, w, change, mass.get(at(0)).copied().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,14 +104,75 @@ fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64) {
|
||||
*c = *p;
|
||||
}
|
||||
}
|
||||
dissipated
|
||||
}
|
||||
|
||||
/// v_out = v_in + dt M⁻¹ (f_ext − f_int).
|
||||
/// Coulomb friction of a node the wall `w` stopped with a normal velocity change `change`: its
|
||||
/// tangential velocity changes by at most μ · change (stick when that stops it); returns the
|
||||
/// kinetic energy taken, ½ m (|v_t|² − |v_t′|²).
|
||||
fn wall_friction(v: &mut [f64], node: usize, w: &crate::system::Wall, change: f64, mass: f64) -> f64 {
|
||||
let at = |k: usize| NODE_DOFS * node + k;
|
||||
let vel = [0, 1, 2].map(|k| v.get(at(k)).copied().unwrap_or(0.0));
|
||||
let vn: f64 = (0..3).map(|k| vel[k] * w.normal[k]).sum();
|
||||
let tangential = [0, 1, 2].map(|k| vel[k] - vn * w.normal[k]);
|
||||
let slide = tangential.iter().map(|c| c * c).sum::<f64>().sqrt();
|
||||
if slide == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let cap = w.friction.coefficient(slide) * change;
|
||||
let keep = if slide <= cap { 0.0 } else { 1.0 - cap / slide };
|
||||
for k in 0..3 {
|
||||
if let Some(c) = v.get_mut(at(k)) {
|
||||
*c = vn * w.normal[k] + keep * tangential[k];
|
||||
}
|
||||
}
|
||||
0.5 * mass * slide * slide * (1.0 - keep * keep)
|
||||
}
|
||||
|
||||
/// v_out = v_in + dt M⁻¹ (f_ext + f_contact − f_int).
|
||||
fn accelerate(sys: &System, ws: &Workspace, mass: &[f64], v_in: &[f64], dt: f64, v_out: &mut [f64]) {
|
||||
let internal = ws.stress_force.iter().zip(&ws.hourglass_force).zip(&ws.viscous_force);
|
||||
for ((((out, v), m), e), ((s, h), q)) in v_out.iter_mut().zip(v_in).zip(mass).zip(&sys.external).zip(internal) {
|
||||
*out = v + dt * ((e - (s + h + q)) / m);
|
||||
}
|
||||
// Contact adds its forces (a separate pass, so a model without contact runs the same sums).
|
||||
if !ws.contact_force.is_empty() {
|
||||
for ((out, m), c) in v_out.iter_mut().zip(mass).zip(&ws.contact_force) {
|
||||
*out += dt * (c / m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The contacts' forces at `u` (with the increment `du` over `dt_prev` that led there, and the
|
||||
/// stable increment `dt`) into `ws.contact_force`, and what each found.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn contact_forces(
|
||||
contacts: &mut [Interface],
|
||||
sys: &System,
|
||||
u: &[f64],
|
||||
du: &[f64],
|
||||
dt_prev: f64,
|
||||
dt: f64,
|
||||
mass: &[f64],
|
||||
state: &State,
|
||||
ws: &mut Workspace,
|
||||
) -> Vec<Evaluation> {
|
||||
ws.contact_force.fill(0.0);
|
||||
contacts.iter_mut().map(|c| c.forces(&sys.xyz, u, du, dt_prev, dt, mass, state, &mut ws.contact_force)).collect()
|
||||
}
|
||||
|
||||
/// A contact's record over the run.
|
||||
fn track(summary: &mut ContactSummary, e: &Evaluation, t: f64) {
|
||||
if e.active > 0 {
|
||||
summary.first_contact.get_or_insert(t);
|
||||
summary.last_contact = Some(t);
|
||||
}
|
||||
if e.normal_force > summary.peak_force {
|
||||
summary.peak_force = e.normal_force;
|
||||
summary.peak_time = t;
|
||||
}
|
||||
summary.max_penetration = summary.max_penetration.max(e.penetration);
|
||||
summary.friction += e.dissipation;
|
||||
}
|
||||
|
||||
/// The constraint reactions M (v_constrained − v_free) / Δt, into `r`.
|
||||
@@ -120,7 +190,7 @@ fn per_node(x: &[f64]) -> Vec<[f64; 3]> {
|
||||
pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Result<ExplicitResult> {
|
||||
let resolved = model.resolve()?;
|
||||
let (step, settings) = select_step(model, step)?;
|
||||
let sys = System::new(model, &resolved, step, settings, limits.max_dofs)?;
|
||||
let mut sys = System::new(model, &resolved, step, settings, limits)?;
|
||||
let end = settings.end_time;
|
||||
let scale = settings.dt_scale;
|
||||
let bulk = settings.bulk_viscosity;
|
||||
@@ -192,6 +262,9 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let total_mass: f64 = sys.mass.iter().sum::<f64>() + sys.discrete.mass.iter().sum::<f64>() / 3.0;
|
||||
let mut ws = Workspace::new(&sys);
|
||||
let mut previous = Workspace::new(&sys);
|
||||
let mut contacts = std::mem::take(&mut sys.contacts);
|
||||
let mut summaries: Vec<ContactSummary> =
|
||||
contacts.iter().map(|c| ContactSummary { name: c.name.clone(), adjusted: c.adjusted.clone(), ..ContactSummary::default() }).collect();
|
||||
let mut state = State::new(&sys);
|
||||
let mut eroded: Vec<ErodedElement> = Vec::new();
|
||||
let mut u = vec![0.0; n];
|
||||
@@ -205,10 +278,14 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let crit = internal_forces(&sys, &start, &mut state, &mut ws)?;
|
||||
let mut dt_half = (scale * crit).min(end);
|
||||
let initial_increment = dt_half;
|
||||
let mut found = contact_forces(&mut contacts, &sys, &u, &du, 0.0, scale * crit, &mass, &state, &mut ws);
|
||||
for (summary, e) in summaries.iter_mut().zip(&found) {
|
||||
track(summary, e, 0.0);
|
||||
}
|
||||
let mut v_half = vec![0.0; n];
|
||||
accelerate(&sys, &ws, &mass, &v0, 0.5 * dt_half, &mut v_half);
|
||||
let mut free = v_half.clone();
|
||||
constrain(&sys, &u, &mut v_half, dt_half);
|
||||
let wall_friction = constrain(&sys, &u, &mut v_half, dt_half, &mass);
|
||||
let mut reaction = vec![0.0; n];
|
||||
impulses(&mass, &free, &v_half, 0.5 * dt_half, &mut reaction);
|
||||
// The constraints' work is that of their impulses on the mean of the velocities before
|
||||
@@ -217,12 +294,18 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let impulse_work = |r: &[f64], before: &[f64], after: &[f64], dt: f64| -> f64 {
|
||||
dt * r.iter().zip(before.iter().zip(after)).map(|(r, (a, b))| r * 0.5 * (a + b)).sum::<f64>()
|
||||
};
|
||||
let mut energy = Energies { kinetic: initial_kinetic, constraint: impulse_work(&reaction, &v0, &v_half, 0.5 * dt_half), ..Energies::default() };
|
||||
let mut energy = Energies {
|
||||
kinetic: initial_kinetic,
|
||||
constraint: impulse_work(&reaction, &v0, &v_half, 0.5 * dt_half),
|
||||
friction: wall_friction,
|
||||
..Energies::default()
|
||||
};
|
||||
|
||||
let mut history = History { nodes: sys.history_nodes.iter().filter_map(|i| sys.labels.get(*i).copied()).collect(), ..History::default() };
|
||||
let mut out_frames = Vec::new();
|
||||
let sample = |history: &mut History, t: f64, dt: f64, e: Energies, u: &[f64], v: &[f64], r: &[f64]| {
|
||||
let sample = |history: &mut History, t: f64, dt: f64, e: Energies, u: &[f64], v: &[f64], r: &[f64], found: &[Evaluation]| {
|
||||
history.time.push(t);
|
||||
history.contact_force.push(found.iter().map(|e| e.normal_force).collect());
|
||||
history.increment.push(dt);
|
||||
history.energies.push(e);
|
||||
let sum = |x: &[f64], weight: &dyn Fn(usize, usize) -> f64| {
|
||||
@@ -260,7 +343,7 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let plastic = |state: &State| state.elements.iter().map(|e| e.plastic_work).sum::<f64>();
|
||||
let mut histories = Schedule { interval: settings.history_interval(), next: settings.history_interval() };
|
||||
let mut frames_due = Schedule { interval: settings.output_interval(), next: settings.output_interval() };
|
||||
sample(&mut history, 0.0, dt_half, energy, &u, &v0, &reaction);
|
||||
sample(&mut history, 0.0, dt_half, energy, &u, &v0, &reaction, &found);
|
||||
out_frames.push(frame(0.0, &u, &v0, &state));
|
||||
|
||||
let mut t = 0.0;
|
||||
@@ -294,9 +377,16 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let last = remaining <= 1e-12 * end;
|
||||
let dt_next = if last { 0.0 } else { (scale * crit).min(remaining) };
|
||||
let dt_n = 0.5 * (dt_half + dt_next);
|
||||
if !contacts.is_empty() {
|
||||
found = contact_forces(&mut contacts, &sys, &u, &du, dt_half, scale * crit, &mass, &state, &mut ws);
|
||||
for (summary, e) in summaries.iter_mut().zip(&found) {
|
||||
track(summary, e, t);
|
||||
energy.friction += e.dissipation;
|
||||
}
|
||||
}
|
||||
accelerate(&sys, &ws, &mass, &v_half, dt_n, &mut v_new);
|
||||
free.clone_from(&v_new);
|
||||
constrain(&sys, &u, &mut v_new, dt_next);
|
||||
energy.friction += constrain(&sys, &u, &mut v_new, dt_next, &mass);
|
||||
impulses(&mass, &free, &v_new, dt_n, &mut reaction);
|
||||
// One pass over the degrees of freedom: v^n = v^{n−½} + ½Δt^{n−½} a^n with the constrained
|
||||
// a^n = (v^{n+½} − v^{n−½}) / Δt^n, and the works (trapezoidal over the displacement for
|
||||
@@ -321,15 +411,22 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
energy.external += external;
|
||||
energy.constraint += dt_n * constraint;
|
||||
energy.kinetic = 0.5 * kinetic;
|
||||
if !ws.contact_force.is_empty() {
|
||||
let work: f64 = du.iter().zip(ws.contact_force.iter().zip(&previous.contact_force)).map(|(d, (c, c0))| d * (c0 + c)).sum();
|
||||
energy.contact -= 0.5 * work;
|
||||
}
|
||||
peak_internal = peak_internal.max(energy.internal.abs());
|
||||
energy.balance = energy.kinetic + energy.internal + energy.hourglass + energy.viscous - energy.external - energy.constraint - initial_kinetic;
|
||||
energy.balance = energy.kinetic + energy.internal + energy.hourglass + energy.viscous + energy.contact
|
||||
- energy.external
|
||||
- energy.constraint
|
||||
- initial_kinetic;
|
||||
// Every element eroded leaves an infinite critical increment: the nodes fly freely.
|
||||
if !(energy.balance.is_finite() && !crit.is_nan()) {
|
||||
return Err(Error::Unstable(format!("the solution is not finite at time {t:.6e} (increment {increments})")));
|
||||
}
|
||||
if histories.due(t) || last {
|
||||
energy.plastic = plastic(&state);
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction);
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction, &found);
|
||||
}
|
||||
if frames_due.due(t) || last {
|
||||
out_frames.push(frame(t, &u, &v_now, &state));
|
||||
@@ -342,7 +439,7 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
}
|
||||
if !completed {
|
||||
if history.time.last() != Some(&t) {
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction);
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction, &found);
|
||||
}
|
||||
if out_frames.last().map(|f| f.time) != Some(t) {
|
||||
out_frames.push(frame(t, &u, &v_now, &state));
|
||||
@@ -385,6 +482,7 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
energy,
|
||||
initial_kinetic,
|
||||
eroded,
|
||||
contacts: summaries,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
@@ -14,8 +14,12 @@
|
||||
//! hourglass control (viscous or stiffness), the Bonet–Burton average nodal pressure
|
||||
//! tetrahedron, von Neumann–Richtmyer/Wilkins bulk viscosity, and the element critical time
|
||||
//! increments.
|
||||
//! - **Integration** ([`integrate`]): the time loop, kinematic constraints, energy accounting
|
||||
//! with a balance check, history samples and field frames ([`result`]).
|
||||
//! - **Contact** ([`contact`]): penalty contact between element faces (pairs both ways, single
|
||||
//! surface and general contact) with Coulomb friction by a regularised return map, a bucket
|
||||
//! grid search, and the stiffness from the masses and the stable increment.
|
||||
//! - **Integration** ([`integrate`]): the time loop, kinematic constraints (rigid walls with
|
||||
//! friction), energy accounting with a balance check, history samples and field frames
|
||||
//! ([`result`]).
|
||||
//!
|
||||
//! With nonlinear geometry (the default) the elements are updated Lagrangian and the material
|
||||
//! state is integrated in rate form with an objective stress rate (Jaumann by Hughes–Winget,
|
||||
@@ -33,6 +37,7 @@
|
||||
// Small fixed-size vector kernels read as the textbook formulas with index loops.
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
pub mod contact;
|
||||
pub mod discrete;
|
||||
pub mod elements;
|
||||
pub mod integrate;
|
||||
@@ -41,7 +46,7 @@ pub mod system;
|
||||
|
||||
pub use elements::Failure;
|
||||
pub use integrate::solve_explicit;
|
||||
pub use result::{ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
|
||||
pub use result::{ContactSummary, ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
@@ -82,16 +87,24 @@ pub struct Limits {
|
||||
pub max_increments: u64,
|
||||
/// Numbers kept in field frames and histories together (8 bytes each).
|
||||
pub max_output_values: usize,
|
||||
/// Element faces of every contact surface together.
|
||||
pub max_contact_faces: usize,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub const MAX_DOFS: usize = 6_000_000;
|
||||
pub const MAX_INCREMENTS: u64 = fieldcraft_model::steps::MAX_INCREMENTS;
|
||||
pub const MAX_OUTPUT_VALUES: usize = 50_000_000;
|
||||
pub const MAX_CONTACT_FACES: usize = 10_000_000;
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Limits {
|
||||
Limits { max_dofs: Limits::MAX_DOFS, max_increments: Limits::MAX_INCREMENTS, max_output_values: Limits::MAX_OUTPUT_VALUES }
|
||||
Limits {
|
||||
max_dofs: Limits::MAX_DOFS,
|
||||
max_increments: Limits::MAX_INCREMENTS,
|
||||
max_output_values: Limits::MAX_OUTPUT_VALUES,
|
||||
max_contact_faces: Limits::MAX_CONTACT_FACES,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,21 +22,34 @@ pub struct Energies {
|
||||
pub external: f64,
|
||||
/// Work of the boundary conditions and rigid walls on the model.
|
||||
pub constraint: f64,
|
||||
/// Energy taken by contact: the work the model does against the contact forces (the penalty
|
||||
/// energy stored in the contact springs, plus what friction dissipates there).
|
||||
pub contact: f64,
|
||||
/// Friction dissipation, in contact and on rigid walls (part of `contact` and of
|
||||
/// `constraint`).
|
||||
pub friction: f64,
|
||||
/// Plastic work: the part of `internal` dissipated by plastic flow.
|
||||
pub plastic: f64,
|
||||
/// Internal energy of the eroded elements when they eroded (part of `internal`).
|
||||
pub eroded: f64,
|
||||
/// kinetic + internal + hourglass + viscous − external − constraint − the initial kinetic
|
||||
/// energy: zero when energy is conserved.
|
||||
/// kinetic + internal + hourglass + viscous + contact − external − constraint − the initial
|
||||
/// kinetic energy: zero when energy is conserved.
|
||||
pub balance: f64,
|
||||
}
|
||||
|
||||
impl Energies {
|
||||
/// The balance relative to the largest energy in the account.
|
||||
pub fn relative_error(&self, initial_kinetic: f64) -> f64 {
|
||||
let scale = [initial_kinetic, self.kinetic, self.internal + self.hourglass + self.viscous, self.external.abs(), self.constraint.abs()]
|
||||
.into_iter()
|
||||
.fold(0.0, f64::max);
|
||||
let scale = [
|
||||
initial_kinetic,
|
||||
self.kinetic,
|
||||
self.internal + self.hourglass + self.viscous,
|
||||
self.external.abs(),
|
||||
self.constraint.abs(),
|
||||
self.contact.abs(),
|
||||
]
|
||||
.into_iter()
|
||||
.fold(0.0, f64::max);
|
||||
if scale > 0.0 { self.balance.abs() / scale } else { 0.0 }
|
||||
}
|
||||
}
|
||||
@@ -84,12 +97,33 @@ pub struct History {
|
||||
pub momentum: Vec<[f64; 3]>,
|
||||
/// Sum of the forces the boundary conditions and rigid walls exert on the model.
|
||||
pub constraint_force: Vec<[f64; 3]>,
|
||||
/// Per contact (in [`ExplicitResult::contacts`] order): the normal force across it (the sum
|
||||
/// of its constraints' normal forces).
|
||||
pub contact_force: Vec<Vec<f64>>,
|
||||
/// The nodes with histories, and per sample their displacement and velocity (in this order).
|
||||
pub nodes: Vec<u32>,
|
||||
pub displacement: Vec<Vec<[f64; 3]>>,
|
||||
pub velocity: Vec<Vec<[f64; 3]>>,
|
||||
}
|
||||
|
||||
/// What a contact did over the run.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct ContactSummary {
|
||||
pub name: String,
|
||||
/// Nodes moved onto the other surface before the analysis.
|
||||
pub adjusted: Vec<crate::contact::Adjustment>,
|
||||
/// The largest normal force across it, and when.
|
||||
pub peak_force: f64,
|
||||
pub peak_time: f64,
|
||||
/// The first and the last time it carried a force (none when it never did).
|
||||
pub first_contact: Option<f64>,
|
||||
pub last_contact: Option<f64>,
|
||||
/// The deepest penetration of a node into a face.
|
||||
pub max_penetration: f64,
|
||||
/// Friction dissipated in it.
|
||||
pub friction: f64,
|
||||
}
|
||||
|
||||
/// An element that limits the stable time increment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||
pub struct ControllingElement {
|
||||
@@ -122,5 +156,7 @@ pub struct ExplicitResult {
|
||||
pub initial_kinetic: f64,
|
||||
/// Elements eroded during the run, in the order they eroded.
|
||||
pub eroded: Vec<ErodedElement>,
|
||||
/// Per contact of the model, in name order.
|
||||
pub contacts: Vec<ContactSummary>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -8,12 +8,13 @@ use fieldcraft_fem::plasticity::J2;
|
||||
use fieldcraft_fem::uniform::{Hex8, Tet4, modulus_bound};
|
||||
use fieldcraft_fem::{DofKind, DofLayout, DofSet, face};
|
||||
use fieldcraft_model::{
|
||||
Elastic, ElementKind, ExplicitDynamic, HourglassKind, Integration, Load, Model, Resolved, SectionKind, Step, StressRate, Target,
|
||||
Elastic, ElementKind, ExplicitDynamic, Friction, HourglassKind, Integration, Load, Model, Resolved, SectionKind, Step, StressRate, Target,
|
||||
};
|
||||
|
||||
use crate::contact::Interface;
|
||||
use crate::discrete::{Discrete, Rod};
|
||||
use crate::elements::{HexScales, add_at, frequency_bound, hex_scales};
|
||||
use crate::{Error, Result};
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
/// Degrees of freedom per node: the three translations, node-major (see [`System`]).
|
||||
pub const NODE_DOFS: usize = 3;
|
||||
@@ -81,13 +82,14 @@ pub struct Tet {
|
||||
pub thermal_rate: f64,
|
||||
}
|
||||
|
||||
/// A fixed, frictionless rigid plane.
|
||||
/// A fixed rigid plane.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Wall {
|
||||
pub point: [f64; 3],
|
||||
/// Unit normal, towards the side the nodes stay on.
|
||||
pub normal: [f64; 3],
|
||||
pub nodes: Vec<usize>,
|
||||
pub friction: Friction,
|
||||
}
|
||||
|
||||
/// The discrete system of an explicit dynamic step.
|
||||
@@ -117,6 +119,8 @@ pub struct System {
|
||||
/// Prescribed velocity per degree of freedom (`None` = free).
|
||||
pub prescribed: Vec<Option<f64>>,
|
||||
pub walls: Vec<Wall>,
|
||||
/// The model's contacts ([`crate::contact`]).
|
||||
pub contacts: Vec<Interface>,
|
||||
/// Velocity per degree of freedom at the start (prescribed velocities included).
|
||||
pub initial_velocity: Vec<f64>,
|
||||
/// Constant external force per degree of freedom.
|
||||
@@ -192,16 +196,12 @@ fn check_formulation(kind: ElementKind, section_name: &str, f: &fieldcraft_model
|
||||
}
|
||||
|
||||
impl System {
|
||||
pub fn new(model: &Model, resolved: &Resolved, step: &Step, settings: &ExplicitDynamic, max_dofs: usize) -> Result<System> {
|
||||
pub fn new(model: &Model, resolved: &Resolved, step: &Step, settings: &ExplicitDynamic, limits: &Limits) -> Result<System> {
|
||||
let max_dofs = limits.max_dofs;
|
||||
if let Some(name) = model.couplings().keys().next() {
|
||||
return Err(Error::NotSupported(format!("couplings (coupling `{name}`) in explicit dynamics")));
|
||||
}
|
||||
if let Some(name) = model.contacts().keys().next() {
|
||||
return Err(Error::NotSupported(format!("contact (`{name}`) in explicit dynamics")));
|
||||
}
|
||||
if let Some(w) = settings.rigid_walls.iter().find(|w| !w.friction.is_frictionless()) {
|
||||
return Err(Error::NotSupported(format!("friction on rigid walls (the wall on set `{}`)", w.set)));
|
||||
}
|
||||
|
||||
// Elements by label, with their section's material.
|
||||
let mut materials: Vec<Material> = Vec::new();
|
||||
let mut chosen: Vec<(u32, ElementKind, Vec<u32>, usize, Option<f64>)> = Vec::new();
|
||||
@@ -274,6 +274,7 @@ impl System {
|
||||
discrete_increment: None,
|
||||
prescribed: vec![None; dofs],
|
||||
walls: Vec::new(),
|
||||
contacts: Vec::new(),
|
||||
initial_velocity: vec![0.0; dofs],
|
||||
external: vec![0.0; dofs],
|
||||
history_nodes: Vec::new(),
|
||||
@@ -285,6 +286,20 @@ impl System {
|
||||
labels,
|
||||
xyz,
|
||||
};
|
||||
// Contact first: nodes that start inside a surface are moved before the elements are
|
||||
// built on the coordinates.
|
||||
sys.contacts = crate::contact::interfaces(model, resolved, &mut sys.xyz, &sys.labels, &sys.elements, limits.max_contact_faces)?;
|
||||
for c in &sys.contacts {
|
||||
if let Some(deepest) = c.adjusted.iter().max_by(|a, b| a.depth.total_cmp(&b.depth)) {
|
||||
sys.warnings.push(format!(
|
||||
"contact `{}`: {} nodes started inside the other surface and were moved onto it (the deepest, node {}, by {:.3e})",
|
||||
c.name,
|
||||
c.adjusted.len(),
|
||||
deepest.node,
|
||||
deepest.depth
|
||||
));
|
||||
}
|
||||
}
|
||||
for (index, (label, kind, nodes, mi, area)) in chosen.into_iter().enumerate() {
|
||||
let idx: Vec<usize> = nodes.iter().map(|l| sys.node(*l).ok_or_else(|| Error::Internal(format!("node {l}")))).collect::<Result<_>>()?;
|
||||
let mat = sys.materials.get(mi).ok_or_else(|| Error::Internal("material index".into()))?;
|
||||
@@ -590,7 +605,7 @@ impl System {
|
||||
return Err(invalid(format!("node {label} of set `{}` starts behind its rigid wall (gap {gap:e})", w.set)));
|
||||
}
|
||||
}
|
||||
self.walls.push(Wall { point: w.point, normal, nodes });
|
||||
self.walls.push(Wall { point: w.point, normal, nodes, friction: w.friction });
|
||||
}
|
||||
if let Some(set) = &settings.history_nodes {
|
||||
let nodes = self.nodes_of(resolved, set);
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
//! Deformable contact and friction in explicit dynamics against closed-form solutions:
|
||||
//! - two identical elastic bars colliding exchange their velocities after a contact of 2L/c, with
|
||||
//! the interface force ρ c A v₀/2 (one-dimensional wave theory: K. F. Graff, *Wave Motion in
|
||||
//! Elastic Solids*, Dover 1991, §2.1–2.3; W. Johnson, *Impact Strength of Materials*, Arnold
|
||||
//! 1972, §1.6), and keep their energy (frictionless elastic impact);
|
||||
//! - a block sliding on a plane with Coulomb friction decelerates at μg and stops after
|
||||
//! v₀²/(2μg), on a rigid wall and on a deformable plate (any dynamics text, e.g. J. L. Meriam,
|
||||
//! L. G. Kraige, *Engineering Mechanics: Dynamics*, §3/5), and with the exponential decay law
|
||||
//! after the distance the decaying coefficient gives;
|
||||
//! - two elastic spheres colliding: Hertz's contact duration and peak force (H. Hertz, *Über die
|
||||
//! Berührung fester elastischer Körper*, J. reine angew. Math. 92:156–171, 1882; K. L. Johnson,
|
||||
//! *Contact Mechanics*, Cambridge 1985, §11.4);
|
||||
//! - a contact patch test: two blocks with non-matching meshes pressed together carry a uniform
|
||||
//! stress across the interface (R. L. Taylor, P. Papadopoulos, *On a patch test for contact
|
||||
//! problems in two dimensions*, in Nonlinear Computational Mechanics, Springer 1991, 690–702;
|
||||
//! T. Y. Crisfield-style uniform-pressure form for three dimensions);
|
||||
//! - and what a contact must never do: hold back a body in free flight, depend on the thread
|
||||
//! count, or crash on hostile input.
|
||||
|
||||
use fieldcraft_explicit::{ExplicitResult, Limits, solve_explicit};
|
||||
use fieldcraft_model::*;
|
||||
|
||||
/// A model built block by block, each block of hex8 with its own nodes.
|
||||
struct Mesh {
|
||||
model: Model,
|
||||
next_node: u32,
|
||||
next_element: u32,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
fn new() -> Mesh {
|
||||
Mesh { model: Model::default(), next_node: 1, next_element: 1 }
|
||||
}
|
||||
|
||||
/// A box from `origin` of `size` with `n` hex8 along each axis, positions mapped by `map`;
|
||||
/// its elements.
|
||||
fn block_mapped(&mut self, origin: [f64; 3], size: [f64; 3], n: [usize; 3], map: impl Fn([f64; 3]) -> [f64; 3]) -> Vec<u32> {
|
||||
let base = self.next_node;
|
||||
let at = |i: usize, j: usize, k: usize| base + (i + (n[0] + 1) * (j + (n[1] + 1) * k)) as u32;
|
||||
for k in 0..=n[2] {
|
||||
for j in 0..=n[1] {
|
||||
for i in 0..=n[0] {
|
||||
let x = [0, 1, 2].map(|d| origin[d] + size[d] * [i, j, k][d] as f64 / n[d] as f64);
|
||||
self.model.set_node(at(i, j, k), map(x)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.next_node = at(n[0], n[1], n[2]) + 1;
|
||||
let mut out = Vec::new();
|
||||
for k in 0..n[2] {
|
||||
for j in 0..n[1] {
|
||||
for i in 0..n[0] {
|
||||
let nodes = vec![
|
||||
at(i, j, k),
|
||||
at(i + 1, j, k),
|
||||
at(i + 1, j + 1, k),
|
||||
at(i, j + 1, k),
|
||||
at(i, j, k + 1),
|
||||
at(i + 1, j, k + 1),
|
||||
at(i + 1, j + 1, k + 1),
|
||||
at(i, j + 1, k + 1),
|
||||
];
|
||||
self.model.set_element(self.next_element, ElementKind::Hex8, nodes).unwrap();
|
||||
out.push(self.next_element);
|
||||
self.next_element += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn block(&mut self, origin: [f64; 3], size: [f64; 3], n: [usize; 3]) -> Vec<u32> {
|
||||
self.block_mapped(origin, size, n, |x| x)
|
||||
}
|
||||
|
||||
fn xyz(&self, node: u32) -> [f64; 3] {
|
||||
self.model.node(node).unwrap().xyz
|
||||
}
|
||||
|
||||
/// The faces of `elements` whose centre satisfies `f`.
|
||||
fn faces_where(&self, elements: &[u32], f: impl Fn([f64; 3]) -> bool) -> Vec<ElementFace> {
|
||||
let mut out = Vec::new();
|
||||
for &e in elements {
|
||||
let el = self.model.element(e).unwrap();
|
||||
for (i, local) in el.kind.faces().iter().enumerate() {
|
||||
let c = [0, 1, 2].map(|d| local.iter().map(|n| self.xyz(el.nodes[*n])[d]).sum::<f64>() / local.len() as f64);
|
||||
if f(c) {
|
||||
out.push(ElementFace { element: e, face: i as u8 + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn nodes_where(&self, elements: &[u32], f: impl Fn([f64; 3]) -> bool) -> Vec<u32> {
|
||||
let mut out: Vec<u32> = elements.iter().flat_map(|e| self.model.element(*e).unwrap().nodes.clone()).filter(|n| f(self.xyz(*n))).collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
fn material(&mut self, name: &str, young: f64, poisson: f64, density: f64) {
|
||||
let m = Material { elastic: Some(Elastic::Isotropic { young, poisson }), density: Some(density), ..Material::default() };
|
||||
self.model.add_material(name, m).unwrap();
|
||||
}
|
||||
|
||||
/// A solid section of one-point hex8 of `material` on `elements`, under the set `name`.
|
||||
fn section(&mut self, name: &str, elements: &[u32], material: &str) {
|
||||
self.model.add_set(name, Set { elements: elements.to_vec(), ..Set::default() }).unwrap();
|
||||
let formulation = Formulation { integration: Integration::Reduced, ..Formulation::default() };
|
||||
self.model.add_section(name, Section { set: name.into(), material: material.into(), kind: SectionKind::Solid {}, formulation }).unwrap();
|
||||
}
|
||||
|
||||
fn face_set(&mut self, name: &str, faces: Vec<ElementFace>) {
|
||||
assert!(!faces.is_empty(), "{name}");
|
||||
self.model.add_set(name, Set { faces, ..Set::default() }).unwrap();
|
||||
}
|
||||
|
||||
fn node_set(&mut self, name: &str, nodes: Vec<u32>) {
|
||||
assert!(!nodes.is_empty(), "{name}");
|
||||
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
|
||||
}
|
||||
|
||||
fn pair(&mut self, name: &str, main: &str, secondary: &str, friction: Friction) {
|
||||
let c = Contact { friction, ..Contact::new(ContactSurfaces::Pair { main: main.into(), secondary: secondary.into() }) };
|
||||
self.model.add_contact(name, c).unwrap();
|
||||
}
|
||||
|
||||
fn step(&mut self, step: ExplicitDynamic, bcs: &[(&str, BoundaryCondition)], loads: &[(&str, Load)]) {
|
||||
let mut s = Step::new("Run", Procedure::ExplicitDynamic(step));
|
||||
for (name, bc) in bcs {
|
||||
self.model.add_boundary_condition(name, bc.clone()).unwrap();
|
||||
s.boundary_conditions.insert(name.to_string());
|
||||
}
|
||||
for (name, load) in loads {
|
||||
self.model.add_load(name, load.clone()).unwrap();
|
||||
s.loads.insert(name.to_string());
|
||||
}
|
||||
self.model.add_step(s, None).unwrap();
|
||||
}
|
||||
|
||||
fn solve(&self) -> ExplicitResult {
|
||||
let r = solve_explicit(&self.model, Some("Run"), &Limits::default()).unwrap();
|
||||
assert!(r.completed, "{:?}", r.warnings);
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
fn node_index(r: &ExplicitResult, label: u32) -> usize {
|
||||
r.nodes.binary_search(&label).unwrap()
|
||||
}
|
||||
|
||||
/// The mean velocity of some nodes in the last frame.
|
||||
fn mean_velocity(r: &ExplicitResult, nodes: &[u32]) -> [f64; 3] {
|
||||
let f = r.frames.last().unwrap();
|
||||
let mut v = [0.0; 3];
|
||||
for n in nodes {
|
||||
let x = f.velocity[node_index(r, *n)];
|
||||
for k in 0..3 {
|
||||
v[k] += x[k] / nodes.len() as f64;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
const BAR_E: f64 = 1.0e4;
|
||||
const BAR_RHO: f64 = 1.0;
|
||||
const BAR_L: f64 = 10.0;
|
||||
const BAR_W: f64 = 0.1;
|
||||
|
||||
/// Two bars of `cells` hex8, the first moving at v₀ along x into the second at rest (touching
|
||||
/// end to end, separate nodes), with `contact` between their ends: (mesh, bar A, bar B).
|
||||
fn bars(cells: usize, v0: f64, end: f64, contact: impl FnOnce(&mut Mesh, Vec<ElementFace>, Vec<ElementFace>)) -> (Mesh, Vec<u32>, Vec<u32>) {
|
||||
let mut m = Mesh::new();
|
||||
let a = m.block([0.0; 3], [BAR_L, BAR_W, BAR_W], [cells, 1, 1]);
|
||||
let b = m.block([BAR_L, 0.0, 0.0], [BAR_L, BAR_W, BAR_W], [cells, 1, 1]);
|
||||
m.material("Mat", BAR_E, 0.0, BAR_RHO);
|
||||
m.section("A", &a, "Mat");
|
||||
m.section("B", &b, "Mat");
|
||||
let end_a = m.faces_where(&a, |c| (c[0] - BAR_L).abs() < 1e-9);
|
||||
let start_b = m.faces_where(&b, |c| (c[0] - BAR_L).abs() < 1e-9);
|
||||
contact(&mut m, end_a, start_b);
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "A".into(), velocity: [v0, 0.0, 0.0] }];
|
||||
// The elastic solution: no linear bulk viscosity (it dissipates at the wave fronts).
|
||||
step.bulk_viscosity = BulkViscosity { linear: 0.0, quadratic: 1.5 };
|
||||
step.history_interval = Some(end / 4000.0);
|
||||
m.step(step, &[], &[]);
|
||||
(m, a, b)
|
||||
}
|
||||
|
||||
/// Velocity exchange, contact duration 2L/c, interface force ρ c A v₀ / 2 and the energy of a
|
||||
/// frictionless elastic impact, for a pair (both ways) and for general contact.
|
||||
#[test]
|
||||
fn colliding_bars_exchange_their_velocities() {
|
||||
let c = (BAR_E / BAR_RHO).sqrt();
|
||||
let v0 = 0.1;
|
||||
let duration = 2.0 * BAR_L / c;
|
||||
for kind in ["pair", "general"] {
|
||||
let (m, a, b) = bars(200, v0, 1.5 * duration, |m, end_a, start_b| match kind {
|
||||
"pair" => {
|
||||
m.face_set("end_a", end_a);
|
||||
m.face_set("start_b", start_b);
|
||||
m.pair("impact", "start_b", "end_a", Friction::NONE);
|
||||
}
|
||||
_ => m.model.add_contact("everything", Contact::new(ContactSurfaces::General { surface: None })).unwrap(),
|
||||
});
|
||||
let r = m.solve();
|
||||
let s = &r.contacts[0];
|
||||
let (first, last) = (s.first_contact.unwrap(), s.last_contact.unwrap());
|
||||
let plateau = BAR_RHO * c * BAR_W * BAR_W * v0 / 2.0;
|
||||
let va = mean_velocity(&r, &m.nodes_where(&a, |_| true))[0];
|
||||
let vb = mean_velocity(&r, &m.nodes_where(&b, |_| true))[0];
|
||||
let ke = r.energy.kinetic / r.initial_kinetic;
|
||||
// The force across the interface while the waves cross the bars.
|
||||
let mid: Vec<f64> = r
|
||||
.history
|
||||
.time
|
||||
.iter()
|
||||
.zip(&r.history.contact_force)
|
||||
.filter(|(t, _)| **t > 0.2 * duration && **t < 0.8 * duration)
|
||||
.map(|(_, f)| f[0])
|
||||
.collect();
|
||||
let mean_force = mid.iter().sum::<f64>() / mid.len() as f64;
|
||||
eprintln!(
|
||||
"{kind}: contact {first:.5}..{last:.5} ({:.5}) vs 2L/c = {duration:.5}; force {mean_force:.5e} (peak {:.5e}) vs ρcAv₀/2 = {plateau:.5e}; v_A {va:.5} v_B {vb:.5} vs 0, {v0}; KE/KE₀ {ke:.5}; contact energy {:.3e} of KE₀ {:.3e}; balance {:.2e}; max penetration {:.3e}; {} increments",
|
||||
last - first,
|
||||
s.peak_force,
|
||||
r.energy.contact,
|
||||
r.initial_kinetic,
|
||||
r.energy.relative_error(r.initial_kinetic),
|
||||
s.max_penetration,
|
||||
r.increments
|
||||
);
|
||||
assert!(first < 0.01 * duration, "{kind}: contact from the start, not {first}");
|
||||
assert!(((last - first) - duration).abs() < 0.015 * duration, "{kind}: contact for {} vs {duration}", last - first);
|
||||
assert!((mean_force - plateau).abs() < 0.02 * plateau, "{kind}: {mean_force} vs {plateau}");
|
||||
assert!(va.abs() < 0.02 * v0 && (vb - v0).abs() < 0.02 * v0, "{kind}: {va} {vb}");
|
||||
assert!(ke > 0.97 && ke <= 1.0 + 1e-6, "{kind}: KE ratio {ke}");
|
||||
// After the separation the contact springs are empty again: what they took they gave back.
|
||||
assert!(r.energy.contact.abs() < 0.005 * r.initial_kinetic, "{kind}: {:?}", r.energy);
|
||||
assert!(r.energy.relative_error(r.initial_kinetic) < 0.005, "{kind}: {:?}", r.energy);
|
||||
assert!(s.max_penetration < 0.01 * BAR_L / 200.0 * 10.0, "{kind}: penetration {}", s.max_penetration);
|
||||
assert_eq!(r.energy.friction, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A body in free flight with general contact on all its faces feels no contact force (no node
|
||||
/// grabs a face of its own body), and its motion is that of the same run without contact.
|
||||
#[test]
|
||||
fn general_contact_leaves_a_free_body_alone() {
|
||||
let run = |contact: bool| {
|
||||
let mut m = Mesh::new();
|
||||
let e = m.block([0.0; 3], [1.0, 0.6, 0.4], [5, 3, 2]);
|
||||
m.material("Mat", 1e4, 0.3, 1.0);
|
||||
m.section("all", &e, "Mat");
|
||||
if contact {
|
||||
m.model.add_contact("all", Contact::new(ContactSurfaces::General { surface: None })).unwrap();
|
||||
}
|
||||
let mut step = ExplicitDynamic::new(0.05);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "all".into(), velocity: [1.0, -0.5, 0.2] }];
|
||||
m.step(step, &[], &[]);
|
||||
m.solve()
|
||||
};
|
||||
let (with, without) = (run(true), run(false));
|
||||
assert!(with.history.contact_force.iter().all(|f| f[0] == 0.0));
|
||||
assert_eq!(with.contacts[0].first_contact, None);
|
||||
assert_eq!(with.frames.last().unwrap().displacement, without.frames.last().unwrap().displacement);
|
||||
assert_eq!(with.energy.contact, 0.0);
|
||||
}
|
||||
|
||||
/// What kind of plane a block slides on.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum Plane {
|
||||
RigidWall,
|
||||
/// A plate fixed at its base, in contact with the block.
|
||||
Plate,
|
||||
}
|
||||
|
||||
/// A block (1 × 1 × 0.5, 4 × 4 × 2 hex8) launched along x at v₀ on a horizontal plane under
|
||||
/// gravity g with `friction`: the distance its centre of mass travels and the time it stops.
|
||||
fn slide(plane: Plane, friction: Friction, v0: f64, g: f64, end: f64) -> (f64, f64, ExplicitResult) {
|
||||
let mut m = Mesh::new();
|
||||
let block = m.block([0.0, 0.0, 0.0], [1.0, 1.0, 0.5], [4, 4, 2]);
|
||||
m.material("Mat", 1.0e5, 0.3, 1.0);
|
||||
m.section("block", &block, "Mat");
|
||||
let bottom = m.nodes_where(&block, |x| x[2].abs() < 1e-9);
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "block".into(), velocity: [v0, 0.0, 0.0] }];
|
||||
step.history_interval = Some(end / 2000.0);
|
||||
let mut bcs = Vec::new();
|
||||
match plane {
|
||||
Plane::RigidWall => {
|
||||
m.node_set("bottom", bottom);
|
||||
step.rigid_walls = vec![RigidWall { set: "bottom".into(), point: [0.0; 3], normal: [0.0, 0.0, 1.0], friction }];
|
||||
}
|
||||
Plane::Plate => {
|
||||
let plate = m.block([-0.5, -0.5, -0.5], [4.5, 2.0, 0.5], [9, 4, 1]);
|
||||
m.section("plate", &plate, "Mat");
|
||||
let top = m.faces_where(&plate, |c| c[2].abs() < 1e-9);
|
||||
let under = m.faces_where(&block, |c| c[2].abs() < 1e-9);
|
||||
m.face_set("top", top);
|
||||
m.face_set("under", under);
|
||||
m.pair("slide", "top", "under", friction);
|
||||
m.node_set("base", m.nodes_where(&plate, |x| (x[2] + 0.5).abs() < 1e-9));
|
||||
bcs.push(("hold", BoundaryCondition::Fixed { target: Target::Set("base".into()) }));
|
||||
}
|
||||
}
|
||||
let gravity = Load::Gravity { acceleration: [0.0, 0.0, -g], set: Some("block".into()) };
|
||||
m.step(step, &bcs, &[("weight", gravity)]);
|
||||
let r = m.solve();
|
||||
let nodes = m.nodes_where(&block, |_| true);
|
||||
let f = r.frames.last().unwrap();
|
||||
let distance = nodes.iter().map(|n| f.displacement[node_index(&r, *n)][0]).sum::<f64>() / nodes.len() as f64;
|
||||
// The block's momentum per mass (the plate's is nothing along x when it is held).
|
||||
let mass = match plane {
|
||||
Plane::RigidWall => r.total_mass,
|
||||
Plane::Plate => 0.5,
|
||||
};
|
||||
let stop = r.history.time.iter().zip(&r.history.momentum).find(|(_, p)| p[0] / mass < 0.005 * v0).map_or(f64::NAN, |(t, _)| *t);
|
||||
(distance, stop, r)
|
||||
}
|
||||
|
||||
/// Coulomb friction μ: deceleration μg, stopping after v₀/(μg) at v₀²/(2μg); on a rigid wall
|
||||
/// (kinematic friction impulses) and on a deformable plate (penalty contact, return map).
|
||||
#[test]
|
||||
fn a_block_slides_to_a_stop_under_coulomb_friction() {
|
||||
let (v0, g, mu) = (3.0, 10.0, 0.3);
|
||||
let (t_stop, d_stop) = (v0 / (mu * g), v0 * v0 / (2.0 * mu * g));
|
||||
for plane in [Plane::RigidWall, Plane::Plate] {
|
||||
let (d, t, r) = slide(plane, Friction::coulomb(mu), v0, g, 1.3 * t_stop);
|
||||
eprintln!(
|
||||
"{plane:?}: stops at {t:.4} vs v₀/(μg) = {t_stop:.4}, after {d:.4} vs v₀²/(2μg) = {d_stop:.4}; friction dissipated {:.4} vs ½mv₀² = {:.4}; balance {:.2e}; {} increments",
|
||||
r.energy.friction,
|
||||
0.5 * 0.5 * v0 * v0,
|
||||
r.energy.relative_error(r.initial_kinetic),
|
||||
r.increments
|
||||
);
|
||||
assert!((t - t_stop).abs() < 0.02 * t_stop, "{plane:?}: {t} vs {t_stop}");
|
||||
assert!((d - d_stop).abs() < 0.02 * d_stop, "{plane:?}: {d} vs {d_stop}");
|
||||
// The kinetic energy went into friction (and a little into the bouncing on the plane).
|
||||
let ke0 = 0.5 * 0.5 * v0 * v0;
|
||||
assert!((r.energy.friction - ke0).abs() < 0.03 * ke0, "{plane:?}: {:?}", r.energy);
|
||||
assert!(r.energy.relative_error(r.initial_kinetic) < 0.01, "{plane:?}: {:?}", r.energy);
|
||||
}
|
||||
}
|
||||
|
||||
/// The exponential decay law μ(v) = μ_k + (μ_s − μ_k) e^{−d v}: the block decelerates at
|
||||
/// g μ(v), so it stops after t = ∫ dv / (g μ(v)) = [v₀ + ln((μ_k + Δμ e^{−d v₀}) / μ_s) / d] / (g μ_k)
|
||||
/// and travels ∫ v dv / (g μ(v)) (by quadrature).
|
||||
#[test]
|
||||
fn a_block_slides_with_a_decaying_friction_coefficient() {
|
||||
let (v0, g) = (3.0, 10.0);
|
||||
let f = Friction { static_coefficient: 0.5, kinetic: Some(0.2), decay: 1.5 };
|
||||
let (a, b, d): (f64, f64, f64) = (0.2, 0.3, 1.5);
|
||||
let t_stop = (v0 + ((a + b * (-d * v0).exp()) / (a + b)).ln() / d) / (g * a);
|
||||
let n = 20_000;
|
||||
let d_stop: f64 = (0..n)
|
||||
.map(|i| {
|
||||
let v = (i as f64 + 0.5) * v0 / n as f64;
|
||||
v / (g * f.coefficient(v)) * v0 / n as f64
|
||||
})
|
||||
.sum();
|
||||
for plane in [Plane::RigidWall, Plane::Plate] {
|
||||
let (dist, t, _) = slide(plane, f, v0, g, 1.3 * t_stop);
|
||||
eprintln!("{plane:?}, decaying μ: stops at {t:.4} vs {t_stop:.4}, after {dist:.4} vs {d_stop:.4}");
|
||||
assert!((t - t_stop).abs() < 0.03 * t_stop, "{plane:?}: {t} vs {t_stop}");
|
||||
assert!((dist - d_stop).abs() < 0.03 * d_stop, "{plane:?}: {dist} vs {d_stop}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two blocks with non-matching meshes (3 × 3 below, 2 × 2 above) pressed together slowly
|
||||
/// between symmetry planes: the stress is uniform σ_zz on both sides of the interface and the
|
||||
/// force across it is σ_zz A (a contact patch test in uniaxial stress).
|
||||
#[test]
|
||||
fn a_contact_patch_test_carries_a_uniform_stress_across_non_matching_meshes() {
|
||||
let mut m = Mesh::new();
|
||||
let lower = m.block([0.0; 3], [1.0, 1.0, 1.0], [3, 3, 2]);
|
||||
let upper = m.block([0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [2, 2, 2]);
|
||||
let (young, rho) = (1000.0, 1.0);
|
||||
m.material("Mat", young, 0.3, rho);
|
||||
m.section("lower", &lower, "Mat");
|
||||
m.section("upper", &upper, "Mat");
|
||||
let both: Vec<u32> = lower.iter().chain(&upper).copied().collect();
|
||||
m.face_set("lower_top", m.faces_where(&lower, |c| (c[2] - 1.0).abs() < 1e-9));
|
||||
m.face_set("upper_bottom", m.faces_where(&upper, |c| (c[2] - 1.0).abs() < 1e-9));
|
||||
m.pair("interface", "lower_top", "upper_bottom", Friction::NONE);
|
||||
m.node_set("x0", m.nodes_where(&both, |x| x[0].abs() < 1e-9));
|
||||
m.node_set("y0", m.nodes_where(&both, |x| x[1].abs() < 1e-9));
|
||||
m.node_set("z0", m.nodes_where(&both, |x| x[2].abs() < 1e-9));
|
||||
m.node_set("top", m.nodes_where(&both, |x| (x[2] - 2.0).abs() < 1e-9));
|
||||
let squeeze = 0.01;
|
||||
let held = |set: &str, k: usize| {
|
||||
let mut values = [None; 6];
|
||||
values[k] = Some(0.0);
|
||||
BoundaryCondition::Displacement { target: Target::Set(set.into()), values }
|
||||
};
|
||||
let mut push_values = [None; 6];
|
||||
push_values[2] = Some(-squeeze);
|
||||
let push = BoundaryCondition::Displacement { target: Target::Set("top".into()), values: push_values };
|
||||
// A hundred wave transits: quasi-static.
|
||||
let c = (young * 0.7 / (1.3 * 0.4) / rho).sqrt();
|
||||
let end = 100.0 * 2.0 / c;
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.output_interval = Some(end / 4.0);
|
||||
m.step(step, &[("x0", held("x0", 0)), ("y0", held("y0", 1)), ("z0", held("z0", 2)), ("push", push)], &[]);
|
||||
let r = m.solve();
|
||||
let f = r.frames.last().unwrap();
|
||||
let szz: Vec<f64> = f.stress.iter().map(|s| s[2]).collect();
|
||||
let mean = szz.iter().sum::<f64>() / szz.len() as f64;
|
||||
let spread = szz.iter().map(|s| (s - mean).abs()).fold(0.0, f64::max);
|
||||
let lateral = f.stress.iter().flat_map(|s| [s[0], s[1], s[3], s[4], s[5]]).map(f64::abs).fold(0.0, f64::max);
|
||||
let force = *r.history.contact_force.last().unwrap().first().unwrap();
|
||||
eprintln!(
|
||||
"patch: σ_zz mean {mean:.5} (E ε = {:.5}), spread {spread:.3e}, largest other component {lateral:.3e}; force across {force:.5} vs |σ_zz| A = {:.5}",
|
||||
-young * squeeze / 2.0,
|
||||
mean.abs()
|
||||
);
|
||||
assert!(spread < 0.02 * mean.abs(), "σ_zz {szz:?}");
|
||||
assert!(lateral < 0.02 * mean.abs(), "{lateral}");
|
||||
assert!((force - mean.abs()).abs() < 0.02 * mean.abs(), "{force} vs {mean}");
|
||||
// The interface's penalty takes some of the squeeze (uniformly): each block is strained by
|
||||
// what is left.
|
||||
let closure = r.contacts[0].max_penetration;
|
||||
let expected = young * (squeeze - closure) / 2.0;
|
||||
eprintln!("patch: interface closure {closure:.4e}, σ_zz vs E (squeeze − closure) / 2 = {expected:.5}");
|
||||
assert!((mean.abs() - expected).abs() < 0.01 * expected, "{mean} vs {expected}");
|
||||
}
|
||||
|
||||
/// The same contact run gives the same numbers, bit for bit, on one thread and on four.
|
||||
#[test]
|
||||
fn contact_results_do_not_depend_on_the_thread_count() {
|
||||
let c = (BAR_E / BAR_RHO).sqrt();
|
||||
let (m, _, _) = bars(60, 0.1, 2.0 * BAR_L / c, |m, _, _| {
|
||||
m.model.add_contact("all", Contact { friction: Friction::coulomb(0.2), ..Contact::new(ContactSurfaces::General { surface: None }) }).unwrap()
|
||||
});
|
||||
let one = fieldcraft_parallel::with_threads(1, || solve_explicit(&m.model, None, &Limits::default()).unwrap());
|
||||
let four = fieldcraft_parallel::with_threads(4, || solve_explicit(&m.model, None, &Limits::default()).unwrap());
|
||||
assert_eq!(one.frames, four.frames);
|
||||
assert_eq!(one.history, four.history);
|
||||
assert_eq!(one.energy, four.energy);
|
||||
assert_eq!(one.contacts, four.contacts);
|
||||
}
|
||||
|
||||
/// Nodes that start inside the other surface are moved onto it and reported, or refused with
|
||||
/// the node, the face and the depth; a cap on contact faces is honoured; contact on the faces
|
||||
/// of tetrahedra works too.
|
||||
#[test]
|
||||
fn initial_penetration_caps_and_tetrahedra() {
|
||||
let build = |mode: InitialPenetration, overlap: f64| {
|
||||
let mut m = Mesh::new();
|
||||
let a = m.block([0.0; 3], [1.0, 1.0, 1.0], [2, 2, 2]);
|
||||
let b = m.block([0.0, 0.0, 1.0 - overlap], [1.0, 1.0, 1.0], [3, 3, 2]);
|
||||
m.material("Mat", 1000.0, 0.3, 1.0);
|
||||
m.section("a", &a, "Mat");
|
||||
m.section("b", &b, "Mat");
|
||||
m.face_set("a_top", m.faces_where(&a, |c| (c[2] - 1.0).abs() < 1e-9));
|
||||
m.face_set("b_bottom", m.faces_where(&b, |c| (c[2] - 1.0 + overlap).abs() < 1e-9));
|
||||
let c = Contact { initial_penetration: mode, ..Contact::new(ContactSurfaces::Pair { main: "a_top".into(), secondary: "b_bottom".into() }) };
|
||||
m.model.add_contact("stack", c).unwrap();
|
||||
m.step(ExplicitDynamic::new(0.01), &[], &[]);
|
||||
m
|
||||
};
|
||||
let r = build(InitialPenetration::Adjust, 0.01).solve();
|
||||
let s = &r.contacts[0];
|
||||
// Both passes: b's 16 bottom nodes into a, and a's 9 top nodes into b (they are inside b
|
||||
// after b's nodes moved down? no: b's nodes are moved onto a's top first, then a's top nodes
|
||||
// are on b's bottom).
|
||||
assert!(!s.adjusted.is_empty(), "{s:?}");
|
||||
assert!(s.adjusted.iter().all(|a| (a.depth - 0.01).abs() < 1e-9), "{:?}", s.adjusted);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("contact `stack`") && w.contains("moved onto it")), "{:?}", r.warnings);
|
||||
// After the adjustment nothing is pushed apart: no contact force worth the name.
|
||||
assert!(s.peak_force < 1e-6, "{s:?}");
|
||||
let e = solve_explicit(&build(InitialPenetration::Refuse, 0.01).model, None, &Limits::default()).unwrap_err().to_string();
|
||||
assert!(e.contains("contact `stack`") && e.contains("starts 1.0000e-2 inside face 2 of element"), "{e}");
|
||||
// Touching exactly is not a penetration.
|
||||
assert!(build(InitialPenetration::Refuse, 0.0).solve().contacts[0].adjusted.is_empty());
|
||||
let capped = Limits { max_contact_faces: 10, ..Limits::default() };
|
||||
let e = solve_explicit(&build(InitialPenetration::Adjust, 0.0).model, None, &capped).unwrap_err().to_string();
|
||||
assert!(e.contains("limit") && e.contains("contact `stack`"), "{e}");
|
||||
}
|
||||
|
||||
/// Interval ends on [0, 1] in `n` intervals growing from the first by `ratio`.
|
||||
fn graded(n: usize, ratio: f64) -> Vec<f64> {
|
||||
let widths: Vec<f64> = (0..n).map(|k| ratio.powi(k as i32)).collect();
|
||||
let total: f64 = widths.iter().sum();
|
||||
let mut out = vec![0.0];
|
||||
let mut at = 0.0;
|
||||
for w in widths {
|
||||
at += w / total;
|
||||
out.push(at);
|
||||
}
|
||||
if let Some(last) = out.last_mut() {
|
||||
*last = 1.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A quarter (x ≥ 0, y ≥ 0) of a ball of radius `radius` about `center`, meshed as a cubed sphere
|
||||
/// of hex8: an inner cube of half-size 0.4 R with `n` uniform elements per edge, and six blocks
|
||||
/// from its faces to the sphere with `n` × `n` elements across (equiangular, clustered towards
|
||||
/// each face's centre by `lateral`) and `layers` elements through (thinner towards the surface
|
||||
/// by `radial`). Its elements and the faces on the sphere within 30° of the pole below (−z) or
|
||||
/// above (+z) the centre (`towards`).
|
||||
fn quarter_ball(
|
||||
m: &mut Mesh,
|
||||
center: [f64; 3],
|
||||
radius: f64,
|
||||
n: usize,
|
||||
layers: usize,
|
||||
lateral: f64,
|
||||
radial: f64,
|
||||
towards: f64,
|
||||
) -> (Vec<u32>, Vec<ElementFace>) {
|
||||
use std::collections::BTreeMap;
|
||||
assert!(n.is_multiple_of(2));
|
||||
let inner = 0.4 * radius;
|
||||
// Clustered lateral parameter on [−1, 1]: symmetric, finest at 0.
|
||||
let half = graded(n / 2, lateral);
|
||||
let mut clustered: Vec<f64> = half.iter().rev().map(|s| -s).collect();
|
||||
clustered.extend(half.iter().skip(1));
|
||||
let uniform: Vec<f64> = (0..=n).map(|i| -1.0 + 2.0 * i as f64 / n as f64).collect();
|
||||
// Radial blend from the cube (0) to the sphere (1), finest at the sphere.
|
||||
let through: Vec<f64> = graded(layers, radial).iter().rev().map(|s| 1.0 - s).collect();
|
||||
let mut labels: BTreeMap<[i64; 3], u32> = BTreeMap::new();
|
||||
let quantum = 1e-9 * radius;
|
||||
let mut node = |m: &mut Mesh, x: [f64; 3]| -> u32 {
|
||||
let key = x.map(|c| (c / quantum).round() as i64);
|
||||
*labels.entry(key).or_insert_with(|| {
|
||||
let l = m.next_node;
|
||||
m.model.set_node(l, [center[0] + x[0], center[1] + x[1], center[2] + x[2]]).unwrap();
|
||||
m.next_node += 1;
|
||||
l
|
||||
})
|
||||
};
|
||||
let kept = |x: &[[f64; 3]]| {
|
||||
let c = [0, 1].map(|d| x.iter().map(|p| p[d]).sum::<f64>() / x.len() as f64);
|
||||
c[0] > 0.0 && c[1] > 0.0
|
||||
};
|
||||
let mut hexes: Vec<[[f64; 3]; 8]> = Vec::new();
|
||||
// The inner cube.
|
||||
let cube = |i: usize, j: usize, k: usize| [uniform[i], uniform[j], uniform[k]].map(|s| inner * s);
|
||||
for k in 0..n {
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
hexes.push([
|
||||
cube(i, j, k),
|
||||
cube(i + 1, j, k),
|
||||
cube(i + 1, j + 1, k),
|
||||
cube(i, j + 1, k),
|
||||
cube(i, j, k + 1),
|
||||
cube(i + 1, j, k + 1),
|
||||
cube(i + 1, j + 1, k + 1),
|
||||
cube(i, j + 1, k + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The six blocks: face axis a, sign σ; (u, v) along the other two axes.
|
||||
for axis in 0..3 {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let place = |u: f64, v: f64, w: f64| -> [f64; 3] {
|
||||
let mut p = [0.0; 3];
|
||||
p[axis] = w;
|
||||
p[(axis + 1) % 3] = u;
|
||||
p[(axis + 2) % 3] = v;
|
||||
p
|
||||
};
|
||||
let point = |i: usize, j: usize, k: usize| -> [f64; 3] {
|
||||
let t = through[k];
|
||||
let p_in = place(uniform[i], uniform[j], sign).map(|c| inner * c);
|
||||
let angle = |s: f64| (s * std::f64::consts::FRAC_PI_4).tan();
|
||||
let d = place(angle(clustered[i]), angle(clustered[j]), sign);
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
let p_out = d.map(|c| radius * c / len);
|
||||
[0, 1, 2].map(|c| (1.0 - t) * p_in[c] + t * p_out[c])
|
||||
};
|
||||
for k in 0..layers {
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
hexes.push([
|
||||
point(i, j, k),
|
||||
point(i + 1, j, k),
|
||||
point(i + 1, j + 1, k),
|
||||
point(i, j + 1, k),
|
||||
point(i, j, k + 1),
|
||||
point(i + 1, j, k + 1),
|
||||
point(i + 1, j + 1, k + 1),
|
||||
point(i, j + 1, k + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut elements = Vec::new();
|
||||
for mut h in hexes.into_iter().filter(|h| kept(h)) {
|
||||
// Positive volume: the bottom face counter-clockwise seen from the top.
|
||||
let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
let (a, b, c) = (sub(h[1], h[0]), sub(h[3], h[0]), sub(h[4], h[0]));
|
||||
let det = a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]);
|
||||
if det < 0.0 {
|
||||
h = [h[4], h[5], h[6], h[7], h[0], h[1], h[2], h[3]];
|
||||
}
|
||||
let nodes: Vec<u32> = h.iter().map(|x| node(m, *x)).collect();
|
||||
m.model.set_element(m.next_element, ElementKind::Hex8, nodes).unwrap();
|
||||
elements.push(m.next_element);
|
||||
m.next_element += 1;
|
||||
}
|
||||
let pole = [0.0, 0.0, towards];
|
||||
let exterior = m.model.exterior_faces();
|
||||
let faces: Vec<ElementFace> = m
|
||||
.faces_where(&elements, |c| {
|
||||
let r = [c[0] - center[0], c[1] - center[1], c[2] - center[2]];
|
||||
let len = (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt();
|
||||
len > 0.9 * radius && (r[0] * pole[0] + r[1] * pole[1] + r[2] * pole[2]) / len > (std::f64::consts::PI / 6.0).cos()
|
||||
})
|
||||
.into_iter()
|
||||
.filter(|f| exterior.binary_search(f).is_ok())
|
||||
.collect();
|
||||
(elements, faces)
|
||||
}
|
||||
|
||||
/// Hertz's impact of two identical elastic spheres (radius R, mass m, E, ν) at relative speed v:
|
||||
/// with R* = R/2, m* = m/2, E* = E / (2(1 − ν²)), the approach reaches
|
||||
/// δ = (15 m* v² / (16 E* √R*))^{2/5}, the force F = (4/3) E* √R* δ^{3/2}, and the contact lasts
|
||||
/// 2.9432 δ / v (K. L. Johnson, *Contact Mechanics*, Cambridge 1985, §11.4).
|
||||
fn hertz(radius: f64, young: f64, poisson: f64, density: f64, v: f64) -> (f64, f64, f64) {
|
||||
let mass = 4.0 / 3.0 * std::f64::consts::PI * radius.powi(3) * density;
|
||||
let (r_eff, m_eff, e_eff) = (radius / 2.0, mass / 2.0, young / (2.0 * (1.0 - poisson * poisson)));
|
||||
let delta = (15.0 * m_eff * v * v / (16.0 * e_eff * r_eff.sqrt())).powf(0.4);
|
||||
(delta, 4.0 / 3.0 * e_eff * r_eff.sqrt() * delta.powf(1.5), 2.9432 * delta / v)
|
||||
}
|
||||
|
||||
/// Two spheres in a head-on collision (a quarter of each, between the symmetry planes x = 0 and
|
||||
/// y = 0), with a contact pair between their facing caps: the peak force and the contact
|
||||
/// duration against Hertz. `n` and `layers` set the mesh; `a_over_r` the contact radius the speed
|
||||
/// is chosen for.
|
||||
fn sphere_impact(n: usize, layers: usize, lateral: f64, radial: f64, a_over_r: f64) -> (f64, f64, f64, f64, ExplicitResult) {
|
||||
let (radius, young, poisson, density) = (1.0, 1.0, 0.3, 1.0);
|
||||
// The speed that gives a contact radius a = √(R* δ).
|
||||
let delta = a_over_r * a_over_r * radius * radius / (radius / 2.0);
|
||||
let mass = 4.0 / 3.0 * std::f64::consts::PI * density;
|
||||
let e_eff = young / (2.0 * (1.0 - poisson * poisson));
|
||||
let v = (delta.powf(2.5) * 16.0 * e_eff * (radius / 2.0f64).sqrt() / (15.0 * mass / 2.0)).sqrt();
|
||||
let (delta, force, duration) = hertz(radius, young, poisson, density, v);
|
||||
let mut m = Mesh::new();
|
||||
let (upper, upper_cap) = quarter_ball(&mut m, [0.0, 0.0, radius], radius, n, layers, lateral, radial, -1.0);
|
||||
let (lower, lower_cap) = quarter_ball(&mut m, [0.0, 0.0, -radius], radius, n, layers, lateral, radial, 1.0);
|
||||
m.material("Mat", young, poisson, density);
|
||||
m.section("upper", &upper, "Mat");
|
||||
m.section("lower", &lower, "Mat");
|
||||
m.face_set("upper_cap", upper_cap);
|
||||
m.face_set("lower_cap", lower_cap);
|
||||
m.pair("impact", "lower_cap", "upper_cap", Friction::NONE);
|
||||
let both: Vec<u32> = upper.iter().chain(&lower).copied().collect();
|
||||
m.node_set("x0", m.nodes_where(&both, |x| x[0].abs() < 1e-9));
|
||||
m.node_set("y0", m.nodes_where(&both, |x| x[1].abs() < 1e-9));
|
||||
let held = |set: &str, k: usize| {
|
||||
let mut values = [None; 6];
|
||||
values[k] = Some(0.0);
|
||||
BoundaryCondition::Displacement { target: Target::Set(set.into()), values }
|
||||
};
|
||||
let mut step = ExplicitDynamic::new(1.15 * duration);
|
||||
step.initial_velocities = vec![
|
||||
InitialVelocity { set: "upper".into(), velocity: [0.0, 0.0, -v / 2.0] },
|
||||
InitialVelocity { set: "lower".into(), velocity: [0.0, 0.0, v / 2.0] },
|
||||
];
|
||||
step.bulk_viscosity = BulkViscosity { linear: 0.0, quadratic: 1.5 };
|
||||
step.history_interval = Some(duration / 2000.0);
|
||||
step.output_interval = Some(1.15 * duration);
|
||||
m.step(step, &[("x0", held("x0", 0)), ("y0", held("y0", 1))], &[]);
|
||||
let r = m.solve();
|
||||
let s = &r.contacts[0];
|
||||
// A quarter of each sphere carries a quarter of the force.
|
||||
let peak = 4.0 * s.peak_force;
|
||||
let lasted = s.last_contact.unwrap_or(0.0) - s.first_contact.unwrap_or(0.0);
|
||||
eprintln!(
|
||||
"Hertz ({} elements, a/R = {a_over_r}): v = {v:.4e}, δ = {delta:.4e}; peak force {peak:.5e} vs {force:.5e} ({:+.2}%), duration {lasted:.4} vs {duration:.4} ({:+.2}%); max penetration {:.2e}; KE after/before {:.4}; balance {:.2e}; {} increments",
|
||||
both.len(),
|
||||
100.0 * (peak / force - 1.0),
|
||||
100.0 * (lasted / duration - 1.0),
|
||||
s.max_penetration,
|
||||
r.energy.kinetic / r.initial_kinetic,
|
||||
r.energy.relative_error(r.initial_kinetic),
|
||||
r.increments
|
||||
);
|
||||
(peak, force, lasted, duration, r)
|
||||
}
|
||||
|
||||
/// A coarse sphere impact in the main gate: Hertz's force and duration to a few percent.
|
||||
#[test]
|
||||
fn spheres_collide_as_hertz_says_coarse() {
|
||||
let (peak, force, lasted, duration, _) = sphere_impact(12, 6, 1.25, 1.25, 0.15);
|
||||
assert!((peak - force).abs() < 0.1 * force, "{peak} vs {force}");
|
||||
assert!((lasted - duration).abs() < 0.1 * duration, "{lasted} vs {duration}");
|
||||
}
|
||||
|
||||
/// The fine sphere impact: contact radius a tenth of the radius, about six elements across it.
|
||||
#[test]
|
||||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||||
fn spheres_collide_as_hertz_says() {
|
||||
let (peak, force, lasted, duration, r) = sphere_impact(24, 12, 1.23, 1.185, 0.1);
|
||||
assert!((peak - force).abs() < 0.03 * force, "{peak} vs {force}");
|
||||
assert!((lasted - duration).abs() < 0.03 * duration, "{lasted} vs {duration}");
|
||||
assert!(r.energy.kinetic > 0.95 * r.initial_kinetic);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ impl Driver {
|
||||
let resolved = model.resolve().unwrap();
|
||||
let step = model.step("Dynamic").unwrap();
|
||||
let Procedure::ExplicitDynamic(settings) = &step.procedure else { panic!() };
|
||||
let sys = System::new(model, &resolved, step, settings, 1000).unwrap();
|
||||
let sys = System::new(model, &resolved, step, settings, &Limits { max_dofs: 1000, ..Limits::default() }).unwrap();
|
||||
let (state, ws) = (State::new(&sys), Workspace::new(&sys));
|
||||
let u = vec![0.0; sys.dofs()];
|
||||
Driver { sys, state, ws, u, time: 0.0 }
|
||||
|
||||
Reference in new issue
Block a user