Files
OneCAD-Simulation/crates/engine/src/cmd/contact.rs
T
jyvikoandClaude Opus 5.5 1372094f8a engine: pick-driven points, cells' methods per cell, rigid walls as interactions
- geometry.bodies lists each body's vertices; geometry.datum.point by
  vertex takes the design's vertex names (labelled Vertex, not Mesh
  vertex) (#346).
- geometry.centre: the point for what is picked: a vertex, nodes, a
  hole's centre (cylindrical faces or circular edges), else a centroid
  (#351).
- mesh.cells' form: the cells first, and a method drop-down per cell
  (schema kind RefChoices) instead of JSON (#344).
- interaction.rigid_wall(.delete): rigid walls of an explicit step from
  the Interactions tab, no longer coming soon (#350).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:27:39 -04:00

562 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `interaction.contact.*`: contact definitions (pairs of surfaces, general contact) and the
//! search for surfaces that touch; `interaction.tie*`: ties between non-matching meshes;
//! `interaction.rigid_wall*`: rigid walls, which belong to an explicit dynamics step (its
//! `rigid_walls`, also edited in the step's form).
//!
//! A contact's surfaces are sets of element faces, or of element edges in a 2D model
//! ([`fieldcraft_model::contact`]). The commands
//! take the friction, penalty scale and initial-penetration choice of the model's form; `edit`
//! replaces a definition with the model's JSON form, `delete` removes it.
//!
//! `find` looks for the bodies of the mesh that touch: the solid elements are split into
//! connected parts (elements sharing a node belong to the same part), and a node of one part's
//! exterior faces within `tolerance` of an exterior face of another part makes the two parts
//! touch there. Each touching pair is reported with the faces of each side that are close to the
//! other; with `create`, each becomes two face sets and a contact pair.
use crate::schema::ProblemKind;
use std::collections::BTreeMap;
use fieldcraft_fem::contact::{Grid, bounds, closest_point};
use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, Friction, InitialPenetration, Model, Procedure, RigidWall, Set, Step, Tie};
use serde_json::{Value, json};
use super::{name, opt};
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
/// The options every contact takes, from the parameters.
fn contact(p: &Value, surfaces: ContactSurfaces) -> Result<Contact> {
Ok(Contact {
surfaces,
friction: opt::<Friction>(p, "friction")?.unwrap_or(Friction::NONE),
penalty_scale: opt::<f64>(p, "penalty_scale")?.unwrap_or(1.0),
initial_penetration: opt::<InitialPenetration>(p, "initial_penetration")?.unwrap_or_default(),
})
}
/// A required name-valued parameter.
fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
p.get(key).and_then(Value::as_str).ok_or_else(|| EngineError::param(key, ProblemKind::NotAName))
}
pub const RIGID_WALL: CommandSpec = CommandSpec {
id: "interaction.rigid_wall",
label: "Rigid Wall",
params: r#"{"step": "Impact" (an explicit dynamics step; default: the model's only one), "set": "front" (the nodes the wall stops), "point": [x, y, z] (on the wall), "normal": [nx, ny, nz] (toward the side the nodes stay on), "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)} — a fixed rigid plane the set's nodes cannot pass, added to the step's rigid walls (the same list as model.step.create's `rigid_walls`). Replies the step and the wall's index in it"#,
form: Some(super::forms::RIGID_WALL),
kind: CommandKind::Model,
requires: &[Requirement::Step, Requirement::Set],
run: rigid_wall,
};
pub const RIGID_WALL_DELETE: CommandSpec = CommandSpec {
id: "interaction.rigid_wall.delete",
label: "Delete Rigid Wall",
params: r#"{"step": "Impact", "index": 0} — removes a rigid wall from an explicit dynamics step (its index in the step's `rigid_walls`, from 0)"#,
form: Some(super::forms::RIGID_WALL_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Step],
run: rigid_wall_delete,
};
/// The explicit dynamics step `step` names, or the model's only one when it names none.
fn explicit_step(m: &Model, p: &Value) -> Result<Step> {
let explicit = |s: &&Step| matches!(s.procedure, Procedure::ExplicitDynamic(_));
let step = match p.get("step") {
None | Some(Value::Null) => {
let mut all = m.steps().iter().filter(explicit);
match (all.next(), all.next()) {
(Some(s), None) => s,
(None, _) => {
return Err(EngineError::Params(
"the model has no explicit dynamics step, where rigid walls act: create one (Steps › Step, Dynamic, explicit)".into(),
));
}
(Some(_), Some(_)) => {
return Err(EngineError::param("step", ProblemKind::Invalid("name the explicit dynamics step: the model has several".into())));
}
}
}
Some(_) => {
let n = text(p, "step")?;
let s = m.steps().iter().find(|s| s.name == n).ok_or_else(|| EngineError::Params(format!("no step `{n}`")))?;
if !explicit(&s) {
return Err(EngineError::Params(format!("step `{n}` is not an explicit dynamics step: rigid walls act only there")));
}
s
}
};
Ok(step.clone())
}
fn rigid_wall(s: &mut Session, p: &Value) -> Result<Value> {
let mut step = explicit_step(&s.model, p)?;
let set = text(p, "set")?.to_string();
if s.model.set(&set).is_none() {
return Err(EngineError::Params(format!("no set `{set}`")));
}
let wall = RigidWall {
set,
point: super::req(p, "point")?,
normal: super::req(p, "normal")?,
friction: opt::<Friction>(p, "friction")?.unwrap_or(Friction::NONE),
};
let Procedure::ExplicitDynamic(e) = &mut step.procedure else { return Err(EngineError::Params("not an explicit dynamics step".into())) };
e.rigid_walls.push(wall);
let index = e.rigid_walls.len() - 1;
let name = step.name.clone();
s.model.replace_step(step)?;
Ok(json!({"step": name, "index": index}))
}
fn rigid_wall_delete(s: &mut Session, p: &Value) -> Result<Value> {
let mut step = explicit_step(&s.model, p)?;
let index: usize = super::req(p, "index")?;
let Procedure::ExplicitDynamic(e) = &mut step.procedure else { return Err(EngineError::Params("not an explicit dynamics step".into())) };
if index >= e.rigid_walls.len() {
return Err(EngineError::param("index", ProblemKind::Invalid(format!("step `{}` has {} rigid walls", step.name, e.rigid_walls.len()))));
}
e.rigid_walls.remove(index);
s.model.replace_step(step)?;
Ok(Value::Null)
}
pub const PAIR: CommandSpec = CommandSpec {
id: "interaction.contact.pair",
label: "Contact Pair",
params: concat!(
r#"{"name": "impact", "main": "plate" (a set of element faces), "secondary": "ball" (another), "#,
r#""friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb, μ = μk + (μs − μk) e^(−decay × slip rate); default frictionless), "penalty_scale": 1 (0 < scale ≤ 1: a factor on the penalty: explicit, the stiffest that leaves the time increment alone; nonlinear static, the augmented Lagrangian's), "initial_penetration": "adjust" (nodes that start inside: explicit moves them onto the surface, strain-free; nonlinear static takes the overlap out of their gap; both report them) | "refuse" (refuse the analysis, naming the first)"#,
r#"} — contact between two surfaces: in explicit dynamics each one's nodes are kept out of the other's faces; in nonlinear static steps `secondary` is the mortar (non-mortar) side whose pressure field is enforced against `main` by an augmented Lagrangian, with Coulomb friction by a return map (static coefficient while a node sticks, kinetic once it slips) (fails if the name is taken)"#
),
form: Some(super::forms::CONTACT_PAIR),
kind: CommandKind::Model,
requires: &[Requirement::Set],
run: |s: &mut Session, p: &Value| {
let n = name(p)?;
let surfaces = ContactSurfaces::Pair { main: text(p, "main")?.to_string(), secondary: text(p, "secondary")?.to_string() };
s.model.add_contact(n, contact(p, surfaces)?)?;
Ok(json!({"name": n}))
},
};
pub const GENERAL: CommandSpec = CommandSpec {
id: "interaction.contact.general",
label: "General Contact",
params: concat!(
r#"{"name": "all", "surface": "skin" (optional: a set of element faces, each in contact with every other: self contact; default every exterior face of the solids), "#,
r#""friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb, μ = μk + (μs − μk) e^(−decay × slip rate); default frictionless), "penalty_scale": 1 (0 < scale ≤ 1: a factor on the penalty: explicit, the stiffest that leaves the time increment alone; nonlinear static, the augmented Lagrangian's), "initial_penetration": "adjust" (nodes that start inside: explicit moves them onto the surface, strain-free; nonlinear static takes the overlap out of their gap; both report them) | "refuse" (refuse the analysis, naming the first)"#,
r#"} — single-surface contact of everything that may touch (explicit dynamics, and mortar self-contact in nonlinear static steps; fails if the name is taken)"#
),
form: Some(super::forms::CONTACT_GENERAL),
kind: CommandKind::Model,
requires: &[],
run: |s: &mut Session, p: &Value| {
let n = name(p)?;
let surface = match p.get("surface") {
None | Some(Value::Null) => None,
Some(_) => Some(text(p, "surface")?.to_string()),
};
s.model.add_contact(n, contact(p, ContactSurfaces::General { surface })?)?;
Ok(json!({"name": n}))
},
};
pub const EDIT: CommandSpec = CommandSpec {
id: "interaction.contact.edit",
label: "Edit Contact",
params: r#"{"name": "impact", "surfaces": {"type": "pair", "main": "plate", "secondary": "ball"} | {"type": "general", "surface": "skin" (optional)}, "friction": {…}, "penalty_scale": 1, "initial_penetration": "adjust|refuse"} — replaces the contact of that name (the model's form; see interaction.contact.pair)"#,
form: Some(super::forms::CONTACT_EDIT),
kind: CommandKind::Model,
requires: &[Requirement::Contact],
run: |s: &mut Session, p: &Value| {
let n = name(p)?;
let mut obj = p.as_object().cloned().ok_or_else(|| EngineError::Params("the parameters of a contact are a JSON object".into()))?;
obj.remove("name");
let c: Contact = serde_json::from_value(Value::Object(obj)).map_err(|e| EngineError::Params(format!("contact: {e}")))?;
s.model.replace_contact(n, c)?;
Ok(json!({"name": n}))
},
};
pub const DELETE: CommandSpec = CommandSpec {
id: "interaction.contact.delete",
label: "Delete Contact",
params: r#"{"name": "impact"}"#,
form: Some(super::forms::CONTACT_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Contact],
run: |s: &mut Session, p: &Value| {
s.model.remove_contact(name(p)?)?;
Ok(Value::Null)
},
};
/// A tie from the parameters.
fn tie(p: &Value) -> Result<Tie> {
Ok(Tie {
main: text(p, "main")?.to_string(),
secondary: text(p, "secondary")?.to_string(),
position_tolerance: opt::<f64>(p, "position_tolerance")?,
})
}
pub const TIE: CommandSpec = CommandSpec {
id: "interaction.tie",
label: "Tie",
params: r#"{"name": "glue", "main": "plate" (a set of element faces), "secondary": "rib" (another), "position_tolerance": 0.1 (optional: how far a secondary node may lie from the main surface and still be tied; default half the main element's thickness)} — bonds the secondary surface to the main one across non-matching meshes: each secondary node follows the main surface under it (dual mortar constraints in the implicit analyses; a constant stress crosses the tie exactly); fails if the name is taken"#,
form: Some(super::forms::TIE),
kind: CommandKind::Model,
requires: &[Requirement::Set],
run: |s: &mut Session, p: &Value| {
let n = name(p)?;
s.model.add_tie(n, tie(p)?)?;
Ok(json!({"name": n}))
},
};
pub const TIE_EDIT: CommandSpec = CommandSpec {
id: "interaction.tie.edit",
label: "Edit Tie",
params: r#"{"name": "glue", "main": "plate", "secondary": "rib", "position_tolerance": 0.1} — replaces the tie of that name (see interaction.tie)"#,
form: Some(super::forms::TIE_EDIT),
kind: CommandKind::Model,
requires: &[Requirement::Tie],
run: |s: &mut Session, p: &Value| {
let n = name(p)?;
s.model.replace_tie(n, tie(p)?)?;
Ok(json!({"name": n}))
},
};
pub const TIE_DELETE: CommandSpec = CommandSpec {
id: "interaction.tie.delete",
label: "Delete Tie",
params: r#"{"name": "glue"}"#,
form: Some(super::forms::TIE_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Tie],
run: |s: &mut Session, p: &Value| {
s.model.remove_tie(name(p)?)?;
Ok(Value::Null)
},
};
pub const FIND: CommandSpec = CommandSpec {
id: "interaction.contact.find",
label: "Find Contacts",
params: concat!(
r#"{"tolerance": 0.1 (distance within which surfaces touch; default a tenth of the mean exterior face size), "create": false (true: add two face sets and a contact pair per touching pair of parts, named find-N-main, find-N-secondary and find-N), "#,
r#""friction", "penalty_scale", "initial_penetration" (of the created pairs, as interaction.contact.pair)} — the parts of the mesh (solid elements connected through their nodes) whose exterior faces come within the tolerance of each other"#
),
form: Some(super::forms::CONTACT_FIND),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
run: find,
};
/// Most touching part pairs `find` reports.
pub const MAX_FOUND: usize = fieldcraft_model::contact::MAX_CONTACTS;
/// Most point–face checks `find` makes.
pub const MAX_CHECKS: usize = 200_000_000;
/// The connected parts of the solid elements: per element label, its part (numbered from 0 in
/// the order of their lowest element label).
fn parts(model: &Model) -> BTreeMap<u32, usize> {
// Union–find over the node labels of the solid elements.
let solids: Vec<(u32, &[u32])> =
model.elements().iter().filter(|(_, e)| e.kind.dimension() == 3).map(|(l, e)| (*l, e.nodes.as_slice())).collect();
let mut index: BTreeMap<u32, usize> = BTreeMap::new();
for (_, nodes) in &solids {
for n in *nodes {
let next = index.len();
index.entry(*n).or_insert(next);
}
}
let mut parent: Vec<usize> = (0..index.len()).collect();
fn root(parent: &mut [usize], mut i: usize) -> usize {
while let Some(&p) = parent.get(i) {
if p == i {
break;
}
let grand = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(i) {
*slot = grand;
}
i = grand;
}
i
}
for (_, nodes) in &solids {
let mut it = nodes.iter().filter_map(|n| index.get(n).copied());
let Some(first) = it.next() else { continue };
for other in it {
let (a, b) = (root(&mut parent, first), root(&mut parent, other));
if a != b {
let (lo, hi) = (a.min(b), a.max(b));
if let Some(slot) = parent.get_mut(hi) {
*slot = lo;
}
}
}
}
// Number the parts in element order.
let mut numbers: BTreeMap<usize, usize> = BTreeMap::new();
let mut out = BTreeMap::new();
for (label, nodes) in &solids {
let Some(r) = nodes.first().and_then(|n| index.get(n).copied()).map(|i| root(&mut parent, i)) else { continue };
let next = numbers.len();
out.insert(*label, *numbers.entry(r).or_insert(next));
}
out
}
fn find(s: &mut Session, p: &Value) -> Result<Value> {
let model = &s.model;
let faces = model.exterior_faces();
let part_of = parts(model);
// Each exterior face's corner coordinates and part.
let mut face_xyz: Vec<Vec<[f64; 3]>> = Vec::with_capacity(faces.len());
for f in &faces {
let labels = model.face_nodes(*f).unwrap_or_default();
let corners = if labels.len() == 3 || labels.len() == 6 { 3 } else { 4 };
face_xyz.push(labels.iter().take(corners).filter_map(|l| model.node(*l)).map(|n| n.xyz).collect());
}
let size = {
let sizes: Vec<f64> = face_xyz
.iter()
.filter_map(|x| {
let b = bounds(x, 0.0);
let d = (0..3).map(|k| b[1][k] - b[0][k]).fold(0.0, f64::max);
d.is_finite().then_some(d)
})
.collect();
if sizes.is_empty() { 0.0 } else { sizes.iter().sum::<f64>() / sizes.len() as f64 }
};
let tolerance = opt::<f64>(p, "tolerance")?.unwrap_or(0.1 * size);
if !(tolerance >= 0.0 && tolerance.is_finite()) {
return Err(EngineError::Params(format!("`tolerance` must be a finite distance ≥ 0, got {tolerance}")));
}
let create = opt::<bool>(p, "create")?.unwrap_or(false);
let options = contact(p, ContactSurfaces::General { surface: None })?;
options.validate()?;
let boxes: Vec<_> = face_xyz.iter().map(|x| bounds(x, tolerance)).collect();
let grid = Grid::new(&boxes, 4 * faces.len() + 64);
let part = |c: usize| faces.get(c).and_then(|f| part_of.get(&f.element).copied());
// Each face's centroid and outward normal there.
let centroid = |x: &[[f64; 3]]| -> [f64; 3] { [0, 1, 2].map(|k| x.iter().map(|p| p[k]).sum::<f64>() / x.len().max(1) as f64) };
let normals: Vec<Option<[f64; 3]>> = face_xyz.iter().map(|x| closest_point(x, centroid(x)).map(|q| q.normal)).collect();
// (lower part, higher part) → the faces of each side that touch the other: a face whose
// centroid or a corner is within the tolerance of a face of the other part facing it
// (normals more than 60° apart from parallel).
let mut touching: BTreeMap<(usize, usize), [std::collections::BTreeSet<usize>; 2]> = BTreeMap::new();
let mut checks = 0usize;
for (c, xyz) in face_xyz.iter().enumerate() {
let (Some(k), Some(normal)) = (part(c), normals.get(c).copied().flatten()) else { continue };
for point in xyz.iter().copied().chain(std::iter::once(centroid(xyz))) {
let candidates = grid.candidates(point);
checks += candidates.len();
if checks > MAX_CHECKS {
return Err(EngineError::Params(format!(
"the tolerance {tolerance:e} puts too many faces near each other (more than {MAX_CHECKS} checks); lower it"
)));
}
for &d in candidates {
let d = d as usize;
let Some(other) = part(d).filter(|o| *o != k) else { continue };
let facing = normals.get(d).copied().flatten().is_some_and(|n| (0..3).map(|i| n[i] * normal[i]).sum::<f64>() < -0.5);
let near = facing && face_xyz.get(d).and_then(|x| closest_point(x, point)).is_some_and(|q| q.distance <= tolerance);
if near {
let key = (k.min(other), k.max(other));
touching.entry(key).or_default()[usize::from(k != key.0)].insert(c);
if touching.len() > MAX_FOUND {
return Err(EngineError::Params(format!("more than {MAX_FOUND} pairs of parts touch; lower the tolerance")));
}
}
}
}
}
let face_list = |set: &std::collections::BTreeSet<usize>| -> Vec<ElementFace> { set.iter().filter_map(|c| faces.get(*c).copied()).collect() };
let mut found = Vec::new();
let mut created = Vec::new();
for (n, ((a, b), sides)) in touching.iter().enumerate() {
let (main, secondary) = (face_list(&sides[0]), face_list(&sides[1]));
let mut entry = json!({"parts": [a, b], "faces": [main.len(), secondary.len()]});
if create {
let names = [format!("find-{}-main", n + 1), format!("find-{}-secondary", n + 1), format!("find-{}", n + 1)];
created.push((names, main, secondary));
entry["contact"] = json!(format!("find-{}", n + 1));
}
found.push(entry);
}
let part_count = part_of.values().max().map_or(0, |m| m + 1);
for ([main_name, secondary_name, contact_name], main, secondary) in created {
s.model.add_set(&main_name, Set { faces: main, ..Set::default() })?;
s.model.add_set(&secondary_name, Set { faces: secondary, ..Set::default() })?;
let surfaces = ContactSurfaces::Pair { main: main_name, secondary: secondary_name };
s.model.add_contact(&contact_name, Contact { surfaces, ..options.clone() })?;
}
Ok(json!({"parts": part_count, "exterior_faces": faces.len(), "tolerance": tolerance, "touching": found}))
}
#[cfg(test)]
mod tests {
use super::*;
/// Rigid walls go into the explicit step (the model's only one, or the one named) and come
/// out by index; a model without an explicit step, or a step of another kind, is refused.
#[test]
fn rigid_walls_belong_to_the_explicit_step() {
let mut s = Session::default();
super::super::define::tests::defined_bar(&mut s);
let wall = json!({"set": "end", "point": [3, 0, 0], "normal": [-1, 0, 0], "friction": {"static": 0.2}});
assert!(s.execute("interaction.rigid_wall", &wall).is_err(), "no explicit step yet");
let mut on_bend = wall.clone();
on_bend["step"] = json!("Bend");
assert!(s.execute("interaction.rigid_wall", &on_bend).is_err(), "a static step");
s.execute("model.step.create", &json!({"name": "Hit", "procedure": {"type": "explicit_dynamic", "end_time": 1e-4}})).unwrap();
let r = s.execute("interaction.rigid_wall", &wall).unwrap();
assert_eq!(r, json!({"step": "Hit", "index": 0}));
let walls = |s: &Session| match &s.model.step("Hit").unwrap().procedure {
Procedure::ExplicitDynamic(e) => e.rigid_walls.clone(),
_ => panic!(),
};
assert_eq!(walls(&s).len(), 1);
assert_eq!(walls(&s)[0].set, "end");
for bad in [json!({"set": "nope", "point": [0, 0, 0], "normal": [1, 0, 0]}), json!({"set": "end", "point": [0, 0, 0], "normal": [0, 0, 0]})] {
assert!(s.execute("interaction.rigid_wall", &bad).is_err(), "{bad}");
}
assert!(s.execute("interaction.rigid_wall.delete", &json!({"step": "Hit", "index": 1})).is_err());
s.execute("interaction.rigid_wall.delete", &json!({"step": "Hit", "index": 0})).unwrap();
assert!(walls(&s).is_empty());
s.execute("model.step.create", &json!({"name": "Hit2", "procedure": {"type": "explicit_dynamic", "end_time": 1e-4}})).unwrap();
assert!(s.execute("interaction.rigid_wall", &wall).is_err(), "two explicit steps: name one");
}
/// Two unit cubes of 2×2×2 hex8 each, the second resting on the first (separate nodes), and
/// a third cube far away.
fn stacked(s: &mut Session) {
let mut next_node = 1u32;
let mut next_element = 1u32;
for (z0, x0) in [(0.0, 0.0), (1.0, 0.0), (0.0, 10.0)] {
let base = next_node;
for k in 0..3 {
for j in 0..3 {
for i in 0..3 {
let xyz = [x0 + 0.5 * i as f64, 0.5 * j as f64, z0 + 0.5 * k as f64];
s.execute("mesh.node.add", &json!({"label": next_node, "xyz": xyz})).unwrap();
next_node += 1;
}
}
}
let n = |i: u32, j: u32, k: u32| base + i + 3 * j + 9 * k;
for k in 0..2 {
for j in 0..2 {
for i in 0..2 {
let nodes = [
n(i, j, k),
n(i + 1, j, k),
n(i + 1, j + 1, k),
n(i, j + 1, k),
n(i, j, k + 1),
n(i + 1, j, k + 1),
n(i + 1, j + 1, k + 1),
n(i, j + 1, k + 1),
];
s.execute("mesh.element.add", &json!({"label": next_element, "kind": "hex8", "nodes": nodes})).unwrap();
next_element += 1;
}
}
}
}
}
#[test]
fn contacts_are_created_edited_and_deleted() {
let mut s = Session::default();
stacked(&mut s);
s.execute("model.set.create", &json!({"name": "a", "faces": [{"element": 5, "face": 2}]})).unwrap();
s.execute("model.set.create", &json!({"name": "b", "faces": [{"element": 9, "face": 1}]})).unwrap();
s.execute("interaction.contact.pair", &json!({"name": "ab", "main": "a", "secondary": "b", "friction": {"static": 0.2}})).unwrap();
let c = s.model.contact("ab").unwrap();
assert_eq!(c.friction, Friction::coulomb(0.2));
assert!(s.execute("interaction.contact.pair", &json!({"name": "ab", "main": "a", "secondary": "b"})).is_err(), "taken");
assert!(s.execute("interaction.contact.pair", &json!({"name": "aa", "main": "a", "secondary": "a"})).is_err(), "self pair");
assert!(s.execute("interaction.contact.pair", &json!({"name": "x", "main": "a", "secondary": "nope"})).is_err());
assert!(s.execute("interaction.contact.pair", &json!({"name": "x", "main": "a", "secondary": "b", "penalty_scale": 2})).is_err());
assert!(s.execute("interaction.contact.pair", &json!({"name": "x", "main": "a", "secondary": "b", "friction": {"static": -1}})).is_err());
s.execute("interaction.contact.general", &json!({"name": "all"})).unwrap();
s.execute("interaction.contact.general", &json!({"name": "self", "surface": "a", "initial_penetration": "refuse"})).unwrap();
assert_eq!(s.model.contact("self").unwrap().initial_penetration, InitialPenetration::Refuse);
s.execute("interaction.contact.edit", &json!({"name": "all", "surfaces": {"type": "general", "surface": "b"}, "penalty_scale": 0.5}))
.unwrap();
assert_eq!(s.model.contact("all").unwrap().surfaces, ContactSurfaces::General { surface: Some("b".into()) });
assert!(s.execute("interaction.contact.edit", &json!({"name": "nope", "surfaces": {"type": "general"}})).is_err());
assert!(s.execute("model.set.delete", &json!({"name": "a"})).unwrap_err().to_string().contains("contact `ab`"));
s.execute("model.set.create", &json!({"name": "n", "nodes": [1, 2]})).unwrap();
s.execute("interaction.contact.general", &json!({"name": "bad", "surface": "n"})).unwrap();
let check = s.execute("model.check", &json!({})).unwrap();
assert!(check.to_string().contains("set `n` has no element faces"), "{check}");
for n in ["ab", "all", "self", "bad"] {
s.execute("interaction.contact.delete", &json!({"name": n})).unwrap();
}
assert!(s.model.contacts().is_empty());
}
#[test]
fn ties_are_created_edited_and_deleted() {
let mut s = Session::default();
stacked(&mut s);
s.execute("model.set.create", &json!({"name": "a", "faces": [{"element": 5, "face": 2}]})).unwrap();
s.execute("model.set.create", &json!({"name": "b", "faces": [{"element": 9, "face": 1}]})).unwrap();
s.execute("interaction.tie", &json!({"name": "glue", "main": "a", "secondary": "b"})).unwrap();
assert_eq!(s.model.tie("glue"), Some(&Tie::new("a", "b")));
for bad in [
json!({"name": "glue", "main": "a", "secondary": "b"}),
json!({"name": "x", "main": "a", "secondary": "a"}),
json!({"name": "x", "main": "a", "secondary": "nope"}),
json!({"name": "x", "main": "a", "secondary": "b", "position_tolerance": -1}),
json!({"name": "x", "main": "a", "secondary": "b", "position_tolerance": "far"}),
json!({"name": "x", "main": 3, "secondary": "b"}),
] {
assert!(s.execute("interaction.tie", &bad).is_err(), "{bad}");
}
s.execute("interaction.tie.edit", &json!({"name": "glue", "main": "b", "secondary": "a", "position_tolerance": 0.01})).unwrap();
assert_eq!(s.model.tie("glue").and_then(|t| t.position_tolerance), Some(0.01));
assert!(s.execute("interaction.tie.edit", &json!({"name": "nope", "main": "a", "secondary": "b"})).is_err());
assert!(s.execute("model.set.delete", &json!({"name": "a"})).unwrap_err().to_string().contains("tie `glue`"));
s.execute("interaction.tie.delete", &json!({"name": "glue"})).unwrap();
assert!(s.model.ties().is_empty() && s.execute("interaction.tie.delete", &json!({"name": "glue"})).is_err());
}
#[test]
fn find_reports_and_creates_the_touching_parts() {
let mut s = Session::default();
stacked(&mut s);
let r = s.execute("interaction.contact.find", &json!({})).unwrap();
assert_eq!(r["parts"], 3, "{r}");
assert_eq!(r["exterior_faces"], 3 * 24, "{r}");
// The two stacked cubes touch over four faces each; the far one touches nothing.
assert_eq!(r["touching"], json!([{"parts": [0, 1], "faces": [4, 4]}]), "{r}");
assert!(s.model.contacts().is_empty(), "nothing created without `create`");
let r = s.execute("interaction.contact.find", &json!({"create": true, "friction": {"static": 0.1}})).unwrap();
assert_eq!(r["touching"][0]["contact"], "find-1");
let c = s.model.contact("find-1").unwrap();
assert_eq!(c.friction, Friction::coulomb(0.1));
let main = s.model.resolve_set("find-1-main").unwrap();
assert!(main.faces.iter().all(|f| (5..=8).contains(&f.element) && f.face == 2) && main.faces.len() == 4, "{main:?}");
let secondary = s.model.resolve_set("find-1-secondary").unwrap();
assert!(secondary.faces.iter().all(|f| (9..=12).contains(&f.element) && f.face == 1) && secondary.faces.len() == 4, "{secondary:?}");
// A tight tolerance on a gap finds nothing; a second create reuses no names.
assert!(s.execute("interaction.contact.find", &json!({"create": true})).is_err(), "the names are taken");
for bad in [json!({"tolerance": -1}), json!({"tolerance": "x"}), json!({"tolerance": f64::MAX, "create": "yes"})] {
assert!(s.execute("interaction.contact.find", &bad).is_err(), "{bad}");
}
let empty = s.execute("interaction.contact.find", &json!({"tolerance": 0})).unwrap();
assert_eq!(empty["touching"].as_array().map(Vec::len), Some(1), "touching faces are at distance 0: {empty}");
}
}