model: contact pairs, general contact and Coulomb friction, mapped by the deck writers

Contact definitions on face sets: a pair (each surface's nodes against the
other's faces), general contact on a surface (self contact) or on every
exterior face of the solids; Coulomb friction with static and kinetic
coefficients and an exponential decay, a penalty scale and the choice to
adjust or refuse initially penetrating nodes, validated with caps. Rigid
walls of explicit steps gain friction. The check wants solid faces on
contact surfaces; sets used by a contact cannot be deleted.

fem::contact: the closest point of a triangle (Ericson) or a bilinear
quadrilateral (projected Newton) to a point, and a bucket grid of face
boxes for the global search, deterministic and bounded.

Commands interaction.contact.pair, .general, .edit, .delete and .find
(the touching parts of the mesh, with face sets and pairs made of them);
contacts in the model browser. Abaqus writes *SURFACE INTERACTION with
*FRICTION (exponential decay), *CONTACT PAIR and general *CONTACT; LS-DYNA
*CONTACT_AUTOMATIC_SURFACE_TO_SURFACE and _SINGLE_SURFACE with FS, FD, DC;
Nastran refuses contact by name. The implicit solver refuses contact by
name (M7); the explicit solver refuses it until the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 12:49:45 -04:00
1 parent 6c2b2509e7
commit ecf947c090
23 files changed
+1494 -29

No files matched your search

+16
View File
@@ -387,6 +387,22 @@ impl Model {
}
}
// Contacts: their surfaces are element faces of solids.
for (name, c) in &self.contacts {
for set in c.surfaces.sets() {
let faces = &set_of(set).faces;
let solid = |f: &crate::ElementFace| self.elements.get(&f.element).is_some_and(|e| e.kind.dimension() == 3);
if faces.is_empty() {
d.error(format!("contact `{name}`"), format!("set `{set}` has no element faces (a contact surface is a set of faces)"));
} else if !faces.iter().all(solid) {
d.error(
format!("contact `{name}`"),
format!("set `{set}` has faces of elements that are not solids (contact acts on the faces of solid elements so far)"),
);
}
}
}
// Nodes with rotational degrees of freedom: those of shells and beams, and reference points
// (worked out only when a boundary condition or load needs it).
let rotational = std::cell::OnceCell::new();
+361
View File
@@ -0,0 +1,361 @@
//! Contact: which surfaces may touch, and the friction between them.
//!
//! A contact is a model definition, active in every step that can take it (explicit dynamics;
//! the implicit solver refuses contact by name for now). Its surfaces are sets with element
//! faces, so they survive remeshing like a pressure load does:
//! - a **pair**: the faces of `main` against those of `secondary`, both ways (each surface's
//! nodes are kept out of the other's faces, so neither surface is privileged);
//! - **general** contact: every face against every other face of one surface (single-surface or
//! self contact, for folding and buckling), or of every exterior face of the model's solid
//! elements when no surface is named.
//!
//! **Friction** is Coulomb's law with a coefficient that falls from its static to its kinetic
//! value with the slip rate γ̇: μ = μ_k + (μ_s − μ_k) e^{−d γ̇} (the exponential decay of
//! E. Rabinowicz's observations, as LS-DYNA's and Abaqus's exponential decay friction write it).
//! The same friction applies to the planar rigid walls of an explicit step
//! ([`crate::RigidWall`]).
//!
//! **Penalty.** Explicit contact pushes penetrating nodes back with a penalty spring whose
//! stiffness the solver chooses from the masses and the stable time increment, the stiffest
//! that leaves the increment alone; `penalty_scale` (at most 1) softens it.
//!
//! **Initial penetration.** Nodes of a surface that start inside the other surface are either
//! moved onto it before the analysis, strain-free (`adjust`, the default, each one reported), or
//! the analysis is refused naming the first node, its depth and the face it is behind
//! (`refuse`).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{Error, Model, Result, context, insert_new, remove_existing, replace_existing, require};
/// Most contact definitions in a model.
pub const MAX_CONTACTS: usize = 1000;
/// Largest friction coefficient (a coefficient of 10 already locks every practical interface).
pub const MAX_FRICTION: f64 = 10.0;
/// Largest exponential decay coefficient (time per length: 1/d is the slip rate at which the
/// coefficient has gone 63% of the way from static to kinetic).
pub const MAX_DECAY: f64 = 1e15;
/// Penalty scale factors outside this range are refused (above 1 the contact springs would
/// shorten the stable time increment; see the module docs).
pub const PENALTY_SCALE_RANGE: (f64, f64) = (1e-6, 1.0);
/// Coulomb friction with an exponential decay from the static to the kinetic coefficient.
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Friction {
/// The static coefficient μ_s (0: frictionless).
#[serde(rename = "static", default)]
pub static_coefficient: f64,
/// The kinetic coefficient μ_k (by default the static one: no decay).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kinetic: Option<f64>,
/// The decay coefficient d of μ = μ_k + (μ_s − μ_k) e^{−d γ̇}, per slip rate (time per length).
#[serde(default, skip_serializing_if = "is_zero")]
pub decay: f64,
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
impl Friction {
/// No friction.
pub const NONE: Friction = Friction { static_coefficient: 0.0, kinetic: None, decay: 0.0 };
/// A constant coefficient.
pub fn coulomb(mu: f64) -> Friction {
Friction { static_coefficient: mu, ..Friction::NONE }
}
pub fn is_frictionless(&self) -> bool {
self.static_coefficient == 0.0 && self.kinetic.is_none_or(|k| k == 0.0)
}
/// The kinetic coefficient (the static one when none is given).
pub fn kinetic_coefficient(&self) -> f64 {
self.kinetic.unwrap_or(self.static_coefficient)
}
/// The coefficient at slip rate `rate` (≥ 0).
pub fn coefficient(&self, rate: f64) -> f64 {
let (s, k) = (self.static_coefficient, self.kinetic_coefficient());
if self.decay == 0.0 { s } else { k + (s - k) * (-self.decay * rate.abs()).exp() }
}
pub fn validate(&self) -> Result<()> {
for (what, v) in [("static friction coefficient", self.static_coefficient), ("kinetic friction coefficient", self.kinetic_coefficient())] {
if !(0.0..=MAX_FRICTION).contains(&v) {
return Err(Error::Invalid(format!("the {what} is in 0..={MAX_FRICTION}, got {v}")));
}
}
if !(0.0..=MAX_DECAY).contains(&self.decay) {
return Err(Error::Invalid(format!("the friction decay coefficient is in 0..={MAX_DECAY:e}, got {}", self.decay)));
}
if self.decay == 0.0 && self.kinetic_coefficient() != self.static_coefficient {
return Err(Error::Invalid("a kinetic friction coefficient other than the static one needs a decay coefficient".into()));
}
Ok(())
}
}
/// Which faces a contact puts against which.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum ContactSurfaces {
/// The faces of two sets, each surface's nodes against the other's faces.
Pair { main: String, secondary: String },
/// Every face against every other face of a set's faces (single-surface or self contact), or
/// of every exterior face of the model's solid elements when no set is named.
General {
#[serde(default, skip_serializing_if = "Option::is_none")]
surface: Option<String>,
},
}
impl ContactSurfaces {
/// The sets it names.
pub fn sets(&self) -> Vec<&str> {
match self {
ContactSurfaces::Pair { main, secondary } => vec![main.as_str(), secondary.as_str()],
ContactSurfaces::General { surface } => surface.iter().map(String::as_str).collect(),
}
}
}
/// What happens to nodes that start inside the surface they are in contact with.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InitialPenetration {
/// Move them onto the surface before the analysis (strain-free), and report them.
#[default]
Adjust,
/// Refuse the analysis, naming the first one.
Refuse,
}
/// A contact definition.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Contact {
pub surfaces: ContactSurfaces,
#[serde(default, skip_serializing_if = "Friction::is_frictionless")]
pub friction: Friction,
/// A factor on the solver's penalty stiffness.
#[serde(default = "one")]
pub penalty_scale: f64,
#[serde(default)]
pub initial_penetration: InitialPenetration,
}
fn one() -> f64 {
1.0
}
impl Contact {
/// A frictionless contact with the default penalty and initial adjustment.
pub fn new(surfaces: ContactSurfaces) -> Contact {
Contact { surfaces, friction: Friction::NONE, penalty_scale: 1.0, initial_penetration: InitialPenetration::Adjust }
}
pub fn validate(&self) -> Result<()> {
if let ContactSurfaces::Pair { main, secondary } = &self.surfaces
&& main == secondary
{
return Err(Error::Invalid(format!(
"a contact pair needs two different surfaces, got `{main}` twice (self contact is general contact on that surface)"
)));
}
self.friction.validate()?;
let (lo, hi) = PENALTY_SCALE_RANGE;
if !(lo..=hi).contains(&self.penalty_scale) {
return Err(Error::Invalid(format!("the penalty scale is in {lo:e}..={hi}, got {}", self.penalty_scale)));
}
Ok(())
}
}
impl Model {
pub fn contacts(&self) -> &BTreeMap<String, Contact> {
&self.contacts
}
pub fn contact(&self, name: &str) -> Option<&Contact> {
self.contacts.get(name)
}
fn check_contact(&self, name: &str, c: &Contact) -> Result<()> {
c.validate().map_err(|e| context("contact", name, e))?;
for set in c.surfaces.sets() {
require(&self.sets, "set", set).map_err(|e| context("contact", name, e))?;
}
Ok(())
}
pub fn add_contact(&mut self, name: &str, contact: Contact) -> Result<()> {
self.check_contact(name, &contact)?;
if self.contacts.len() >= MAX_CONTACTS {
return Err(Error::Limit(format!("a model holds at most {MAX_CONTACTS} contacts")));
}
insert_new(&mut self.contacts, "contact", name, contact)
}
pub fn replace_contact(&mut self, name: &str, contact: Contact) -> Result<()> {
self.check_contact(name, &contact)?;
replace_existing(&mut self.contacts, "contact", name, contact)
}
pub fn remove_contact(&mut self, name: &str) -> Result<()> {
remove_existing(&mut self.contacts, "contact", name).map(|_| ())
}
/// The exterior faces of the solid elements (the faces no other solid element shares), in
/// element and face order: the surface of general contact without a named set.
pub fn exterior_faces(&self) -> Vec<crate::ElementFace> {
let mut keyed: Vec<([u32; 4], crate::ElementFace)> = Vec::new();
for (label, e) in self.elements.iter().filter(|(_, e)| e.kind.dimension() == 3) {
for (i, local) in e.kind.faces().iter().enumerate() {
// A face is known by its sorted corner nodes (three or four).
let corners = if local.len() == 6 || local.len() == 3 { 3 } else { 4 };
let mut key = [u32::MAX; 4];
for (k, n) in key.iter_mut().zip(local.iter().take(corners)) {
*k = e.nodes.get(*n).copied().unwrap_or(u32::MAX);
}
key.sort_unstable();
keyed.push((key, crate::ElementFace { element: *label, face: i as u8 + 1 }));
}
}
keyed.sort_unstable();
let mut out: Vec<crate::ElementFace> = keyed
.chunk_by(|a, b| a.0 == b.0)
.filter_map(|run| match run {
[(_, face)] => Some(*face),
_ => None,
})
.collect();
out.sort_unstable();
out
}
/// The contacts that name a set, as ``contact `name` `` strings.
pub(crate) fn contact_users<'a>(&'a self, set: &'a str) -> impl Iterator<Item = String> + 'a {
self.contacts.iter().filter(move |(_, c)| c.surfaces.sets().contains(&set)).map(|(n, _)| format!("contact `{n}`"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Set;
fn model() -> Model {
let mut m = Model::default();
for s in ["top", "bottom"] {
m.add_set(s, Set { nodes: vec![1], ..Set::default() }).unwrap();
}
m
}
#[test]
fn contacts_validate_refer_to_sets_and_round_trip() {
let mut m = model();
let json = serde_json::json!({
"surfaces": {"type": "pair", "main": "top", "secondary": "bottom"},
"friction": {"static": 0.3, "kinetic": 0.2, "decay": 5.0}, "penalty_scale": 0.5
});
let c: Contact = serde_json::from_value(json).unwrap();
assert_eq!((c.friction.static_coefficient, c.friction.kinetic_coefficient(), c.initial_penetration), (0.3, 0.2, InitialPenetration::Adjust));
assert_eq!(serde_json::from_value::<Contact>(serde_json::to_value(&c).unwrap()).unwrap(), c);
m.add_contact("impact", c.clone()).unwrap();
assert!(matches!(m.remove_set("top"), Err(Error::InUse(_))), "the contact refers to the set");
assert!(m.add_contact("impact", c.clone()).is_err(), "names stay unique");
let general: Contact = serde_json::from_value(serde_json::json!({"surfaces": {"type": "general"}})).unwrap();
assert_eq!(general.surfaces, ContactSurfaces::General { surface: None });
assert!(general.friction.is_frictionless() && general.penalty_scale == 1.0);
m.add_contact("everything", general).unwrap();
m.remove_contact("impact").unwrap();
m.remove_set("top").unwrap();
assert!(m.remove_contact("impact").is_err());
assert!(serde_json::from_value::<Contact>(serde_json::json!({"surfaces": {"type": "general"}, "mu": 1})).is_err(), "typos are errors");
let base = Contact::new(ContactSurfaces::Pair { main: "bottom".into(), secondary: "top".into() });
let bad = [
Contact { friction: Friction::coulomb(-0.1), ..base.clone() },
Contact { friction: Friction::coulomb(f64::NAN), ..base.clone() },
Contact { friction: Friction { kinetic: Some(f64::INFINITY), ..Friction::coulomb(0.1) }, ..base.clone() },
Contact { friction: Friction { decay: -1.0, ..Friction::coulomb(0.1) }, ..base.clone() },
Contact { penalty_scale: 0.0, ..base.clone() },
Contact { penalty_scale: f64::NAN, ..base.clone() },
Contact { penalty_scale: 1.5, ..base.clone() },
Contact::new(ContactSurfaces::Pair { main: "bottom".into(), secondary: "bottom".into() }),
];
for b in bad {
assert!(b.validate().is_err(), "{b:?}");
}
let missing = Contact::new(ContactSurfaces::General { surface: Some("nope".into()) });
assert!(matches!(m.add_contact("x", missing), Err(Error::NotFound(_))));
}
#[test]
fn friction_decays_from_static_to_kinetic() {
let f = Friction { static_coefficient: 0.5, kinetic: Some(0.2), decay: 2.0 };
assert_eq!(f.coefficient(0.0), 0.5);
assert!((f.coefficient(1.0) - (0.2 + 0.3 * (-2.0f64).exp())).abs() < 1e-15);
assert!((f.coefficient(1e6) - 0.2).abs() < 1e-15);
assert_eq!(Friction::coulomb(0.3).coefficient(10.0), 0.3);
// A kinetic coefficient acts through the decay.
assert!(Friction { kinetic: Some(0.1), ..Friction::coulomb(0.3) }.validate().is_err());
assert!(Friction { kinetic: Some(0.3), ..Friction::coulomb(0.3) }.validate().is_ok());
assert!(Friction::NONE.is_frictionless() && !Friction::coulomb(0.1).is_frictionless());
}
#[test]
fn the_check_wants_solid_faces_on_contact_surfaces() {
let mut m = Model::default();
let corners = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]];
for (i, x) in corners.iter().enumerate() {
m.set_node(i as u32 + 1, *x).unwrap();
}
m.set_element(1, crate::ElementKind::Hex8, (1..=8).collect()).unwrap();
m.add_set("nodes", Set { nodes: vec![1, 2], ..Set::default() }).unwrap();
m.add_set("top", Set { faces: vec![crate::ElementFace { element: 1, face: 2 }], ..Set::default() }).unwrap();
m.add_contact("c", Contact::new(ContactSurfaces::Pair { main: "top".into(), secondary: "nodes".into() })).unwrap();
let errors: Vec<String> = m.check().iter().filter(|d| d.severity == crate::Severity::Error).map(ToString::to_string).collect();
assert!(errors.iter().any(|e| e.contains("contact `c`") && e.contains("set `nodes` has no element faces")), "{errors:?}");
assert!(!errors.iter().any(|e| e.contains("set `top`")), "{errors:?}");
}
#[test]
fn exterior_faces_are_those_of_one_solid() {
// Two hexahedra sharing a face, and a tetrahedron on the top of the second.
let mut m = Model::default();
for k in 0..3 {
for (i, xy) in [[0., 0.], [1., 0.], [1., 1.], [0., 1.]].iter().enumerate() {
m.set_node(4 * k + i as u32 + 1, [xy[0], xy[1], k as f64]).unwrap();
}
}
m.set_node(13, [0.5, 0.5, 3.0]).unwrap();
m.set_element(1, crate::ElementKind::Hex8, (1..=8).collect()).unwrap();
m.set_element(2, crate::ElementKind::Hex8, (5..=12).collect()).unwrap();
m.set_element(3, crate::ElementKind::Tet4, vec![9, 10, 11, 13]).unwrap();
let faces = m.exterior_faces();
let of = |e: u32| faces.iter().filter(|f| f.element == e).map(|f| f.face).collect::<Vec<_>>();
assert_eq!(of(1), [1, 3, 4, 5, 6], "all but the shared top");
assert_eq!(of(2), [2, 3, 4, 5, 6], "all but the shared bottom (its top meets the tet over half its area)");
assert_eq!(of(3), [1, 2, 3, 4], "a triangle never matches a quad");
assert!(Model::default().exterior_faces().is_empty());
}
#[test]
fn a_model_holds_a_bounded_number_of_contacts() {
let mut m = model();
for i in 0..MAX_CONTACTS {
m.add_contact(&format!("c{i}"), Contact::new(ContactSurfaces::General { surface: None })).unwrap();
}
assert!(matches!(m.add_contact("one-more", Contact::new(ContactSurfaces::General { surface: None })), Err(Error::Limit(_))));
}
}
+12 -1
View File
@@ -34,6 +34,7 @@
pub mod bcs;
pub mod check;
pub mod constraints;
pub mod contact;
pub mod discrete;
pub mod fluids;
pub mod geometry;
@@ -53,6 +54,7 @@ use serde::{Deserialize, Serialize};
pub use bcs::{BoundaryCondition, Dofs, Symmetry, TEMPERATURE_DOF, Target};
pub use check::{Diagnostic, Resolved, ResolvedSet, Severity};
pub use constraints::{Coupling, CouplingKind, ReferencePoint};
pub use contact::{Contact, ContactSurfaces, Friction, InitialPenetration};
pub use discrete::{Direction, Mass, PointMass, ResolvedDirection, ResolvedMass, ResolvedSpring, Spring};
pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition};
pub use geometry::{Classification, Entity, GeometryRef};
@@ -123,6 +125,8 @@ pub struct Model {
reference_points: BTreeMap<String, ReferencePoint>,
couplings: BTreeMap<String, Coupling>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
contacts: BTreeMap<String, Contact>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
masses: BTreeMap<String, PointMass>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
springs: BTreeMap<String, Spring>,
@@ -157,6 +161,8 @@ struct ModelData {
#[serde(default)]
couplings: BTreeMap<String, Coupling>,
#[serde(default)]
contacts: BTreeMap<String, Contact>,
#[serde(default)]
masses: BTreeMap<String, PointMass>,
#[serde(default)]
springs: BTreeMap<String, Spring>,
@@ -215,6 +221,9 @@ impl TryFrom<ModelData> for Model {
for (n, v) in d.couplings {
m.add_coupling(&n, v)?;
}
for (n, v) in d.contacts {
m.add_contact(&n, v)?;
}
for (n, v) in d.masses {
m.add_mass(&n, v)?;
}
@@ -330,6 +339,7 @@ impl Model {
sections: self.sections.clone(),
reference_points: self.reference_points.clone(),
couplings: self.couplings.clone(),
contacts: self.contacts.clone(),
masses: self.masses.clone(),
springs: self.springs.clone(),
boundary_conditions: self.boundary_conditions.clone(),
@@ -340,13 +350,14 @@ impl Model {
}
/// Counts of each kind of definition, for summaries.
pub fn definition_counts(&self) -> [(&'static str, usize); 11] {
pub fn definition_counts(&self) -> [(&'static str, usize); 12] {
[
("sets", self.sets.len()),
("materials", self.materials.len()),
("sections", self.sections.len()),
("reference_points", self.reference_points.len()),
("couplings", self.couplings.len()),
("contacts", self.contacts.len()),
("masses", self.masses.len()),
("springs", self.springs.len()),
("boundary_conditions", self.boundary_conditions.len()),
+1
View File
@@ -122,6 +122,7 @@ impl Model {
let mut users = Vec::new();
users.extend(self.sections.iter().filter(|(_, s)| s.set == set).map(|(n, _)| format!("section `{n}`")));
users.extend(self.couplings.iter().filter(|(_, c)| c.set == set).map(|(n, _)| format!("coupling `{n}`")));
users.extend(self.contact_users(set));
users.extend(self.discrete_users(|t| t.set() == Some(set)));
users
.extend(self.boundary_conditions.iter().filter(|(_, b)| b.target().set() == Some(set)).map(|(n, _)| format!("boundary condition `{n}`")));
+16 -6
View File
@@ -10,7 +10,7 @@ use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::{Error, Model, Result, check_name, context, finite, positive, require};
use crate::{Error, Friction, Model, Result, check_name, context, finite, positive, require};
/// Most steps in a model.
pub const MAX_STEPS: usize = 1000;
@@ -317,7 +317,7 @@ pub struct ExplicitDynamic {
/// the last one listed).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub initial_velocities: Vec<InitialVelocity>,
/// Fixed, frictionless rigid planes the nodes of sets cannot pass through.
/// Fixed rigid planes the nodes of sets cannot pass through, with Coulomb friction.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rigid_walls: Vec<RigidWall>,
/// Hourglass control of one-point hexahedra.
@@ -431,6 +431,7 @@ impl ExplicitDynamic {
if !(w.normal.iter().map(|c| c * c).sum::<f64>() > 0.0) {
return Err(Error::Invalid("a rigid wall's normal must not be zero".into()));
}
w.friction.validate().map_err(|e| Error::Invalid(format!("rigid wall on set `{}`: {e}", w.set)))?;
}
self.hourglass.validate()?;
self.bulk_viscosity.validate()
@@ -445,14 +446,16 @@ pub struct InitialVelocity {
pub velocity: [f64; 3],
}
/// A fixed, frictionless rigid plane through `point`. Its `normal` points to the side the nodes
/// of `set` stay on.
/// A fixed rigid plane through `point`. Its `normal` points to the side the nodes of `set` stay
/// on; nodes sliding on it feel `friction` (frictionless by default).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RigidWall {
pub set: String,
pub point: [f64; 3],
pub normal: [f64; 3],
#[serde(default, skip_serializing_if = "Friction::is_frictionless")]
pub friction: Friction,
}
/// How one-point hexahedra resist their hourglass (zero-energy) modes.
@@ -870,7 +873,7 @@ mod tests {
let json = serde_json::json!({
"type": "explicit_dynamic", "end_time": 1e-3, "history_nodes": "front",
"initial_velocities": [{"set": "front", "velocity": [-10, 0, 0]}],
"rigid_walls": [{"set": "front", "point": [0, 0, 0], "normal": [1, 0, 0]}],
"rigid_walls": [{"set": "front", "point": [0, 0, 0], "normal": [1, 0, 0], "friction": {"static": 0.2}}],
"hourglass": {"type": "stiffness"}
});
let p: Procedure = serde_json::from_value(json).unwrap();
@@ -895,7 +898,14 @@ mod tests {
ExplicitDynamic { max_increments: 0, ..base.clone() },
ExplicitDynamic { output_interval: Some(1e-9), ..base.clone() },
ExplicitDynamic { history_interval: Some(-1.0), ..base.clone() },
ExplicitDynamic { rigid_walls: vec![RigidWall { set: "front".into(), point: [0.0; 3], normal: [0.0; 3] }], ..base.clone() },
ExplicitDynamic {
rigid_walls: vec![RigidWall { set: "front".into(), point: [0.0; 3], normal: [0.0; 3], friction: Friction::NONE }],
..base.clone()
},
ExplicitDynamic {
rigid_walls: vec![RigidWall { set: "front".into(), point: [0.0; 3], normal: [1.0, 0.0, 0.0], friction: Friction::coulomb(f64::NAN) }],
..base.clone()
},
ExplicitDynamic {
initial_velocities: vec![InitialVelocity { set: "front".into(), velocity: [f64::INFINITY, 0.0, 0.0] }],
..base.clone()