use std::{
fs::{self, File},
io::{BufWriter, Write},
};
use grass_app::prelude::*;
use grass_scheduler::prelude::*;
use serde::Deserialize;
use soil_derive::AtomData;
use dirt_atom::DemAtom;
use dirt_schedule::{CONTACT_ANALYSIS, CONTACT_FORCE};
use soil_core::Neighbor;
use soil_core::{
register_atom_data, Atom, AtomData, CommResource, Config, Input, Optional,
ParticleSimScheduleSet, ParticlesWith, Read, RunState, Write as ParticleWrite,
};
use soil_print::{DumpRegistry, Thermo};
fn default_file_prefix() -> String {
"contact".to_string()
}
#[derive(Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct ContactAnalysisConfig {
#[serde(default)]
pub interval: usize,
#[serde(default)]
pub coordination: bool,
#[serde(default)]
pub rattlers: bool,
#[serde(default)]
pub fabric_tensor: bool,
#[serde(default = "default_file_prefix")]
pub file_prefix: String,
}
impl Default for ContactAnalysisConfig {
fn default() -> Self {
Self {
interval: 0,
coordination: false,
rattlers: false,
fabric_tensor: false,
file_prefix: default_file_prefix(),
}
}
}
pub fn contact_analysis_config_warning(config: &ContactAnalysisConfig) -> Option<String> {
if config.rattlers && !config.coordination {
return Some(
"WARNING: [contact_analysis] rattlers = true requires coordination = true; \
n_rattlers and rattler_fraction will not be emitted."
.to_string(),
);
}
None
}
fn missing_dump_registry_diagnostic() -> String {
"ContactAnalysisPlugin setup error: [contact_analysis] coordination = true registers the \
per-atom dump scalar `coordination`, but DumpRegistry is missing. Add CorePlugins (or \
soil_print::PrintPlugin) before ContactAnalysisPlugin."
.to_string()
}
#[derive(AtomData)]
pub struct ContactAnalysis {
#[forward]
#[zero]
pub coordination: Vec<f64>,
}
impl ContactAnalysis {
pub fn new() -> Self {
ContactAnalysis {
coordination: Vec::new(),
}
}
}
impl Default for ContactAnalysis {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct ContactRecord {
pub i_tag: u32,
pub j_tag: u32,
pub overlap: f64,
pub cx: f64,
pub cy: f64,
pub cz: f64,
pub nx: f64,
pub ny: f64,
pub nz: f64,
}
pub struct ContactOutput {
pub records: Vec<ContactRecord>,
}
impl ContactOutput {
pub fn new() -> Self {
ContactOutput {
records: Vec::with_capacity(1024),
}
}
}
impl Default for ContactOutput {
fn default() -> Self {
Self::new()
}
}
#[derive(Default)]
struct FabricTensorAccum {
fxx: f64,
fyy: f64,
fzz: f64,
fxy: f64,
fxz: f64,
fyz: f64,
nc: f64,
}
pub struct ContactAnalysisPlugin;
impl Plugin for ContactAnalysisPlugin {
fn default_config(&self) -> Option<&str> {
Some(
r#"[contact_analysis]
# Dump per-contact data every N steps (0 = disabled)
interval = 0
# Compute per-atom coordination number
coordination = true
# Detect rattler particles (< 4 contacts in 3D)
rattlers = false
# Compute fabric tensor and output to thermo
fabric_tensor = false
# File prefix for contact CSV output
file_prefix = "contact""#,
)
}
fn build(&self, app: &mut App) {
let config = Config::load::<ContactAnalysisConfig>(app, "contact_analysis");
if let Some(msg) = contact_analysis_config_warning(&config) {
eprintln!("{}", msg);
}
app.add_resource(ContactOutput::new());
app.add_resource(FabricTensorAccum::default());
if config.coordination {
if app
.get_mut_resource(std::any::TypeId::of::<DumpRegistry>())
.is_none()
{
panic!("{}", missing_dump_registry_diagnostic());
}
register_atom_data!(app, ContactAnalysis::new());
let dump_reg = app
.get_mut_resource(std::any::TypeId::of::<DumpRegistry>())
.unwrap_or_else(|| panic!("{}", missing_dump_registry_diagnostic()));
dump_reg
.borrow_mut()
.downcast_mut::<DumpRegistry>()
.expect("DumpRegistry should downcast — internal type mismatch")
.register_scalar("coordination", |atoms, registry| {
let ca = registry.expect::<ContactAnalysis>("coordination dump");
let nlocal = atoms.nlocal as usize;
ca.coordination[..nlocal].to_vec()
});
app.add_update_system(
push_coordination_to_thermo.after(CONTACT_ANALYSIS),
ParticleSimScheduleSet::PostForce,
);
}
app.add_update_system(
contact_analysis_requires_hertz_mindlin_contact_add_granular_default_plugins
.requires(CONTACT_FORCE),
ParticleSimScheduleSet::Force,
);
app.add_update_system(
compute_contact_analysis.label(CONTACT_ANALYSIS),
ParticleSimScheduleSet::PostForce,
);
if config.interval > 0 {
app.add_update_system(
dump_contact_records,
ParticleSimScheduleSet::PostFinalIntegration,
);
}
if config.fabric_tensor {
app.add_update_system(
push_fabric_tensor_to_thermo.after(CONTACT_ANALYSIS),
ParticleSimScheduleSet::PostForce,
);
}
}
fn try_build(&self, app: &mut App) -> Result<(), AppError> {
let config = Config::try_load::<ContactAnalysisConfig>(app, "contact_analysis")
.map_err(|error| AppError::message(error.to_string()))?;
if config.coordination
&& app
.get_mut_resource(std::any::TypeId::of::<DumpRegistry>())
.is_none()
{
return Err(AppError::message(missing_dump_registry_diagnostic()));
}
self.build(app);
Ok(())
}
}
fn contact_analysis_requires_hertz_mindlin_contact_add_granular_default_plugins() {}
#[allow(clippy::too_many_arguments)]
fn compute_contact_analysis(
atoms: Res<Atom>,
neighbor: Res<Neighbor>,
particles: ParticlesWith<'_, (Read<DemAtom>, Optional<ParticleWrite<ContactAnalysis>>)>,
config: Res<ContactAnalysisConfig>,
run_state: Res<RunState>,
mut contact_output: ResMut<ContactOutput>,
mut fabric: ResMut<FabricTensorAccum>,
) {
particles.with(|(dem, mut analysis)| {
let newton = neighbor.newton;
let nlocal = atoms.nlocal as usize;
let has_coordination = config.coordination;
let has_fabric = config.fabric_tensor;
let collect_records = config.interval > 0 && run_state.total_cycle % config.interval == 0;
contact_output.records.clear();
fabric.fxx = 0.0;
fabric.fyy = 0.0;
fabric.fzz = 0.0;
fabric.fxy = 0.0;
fabric.fxz = 0.0;
fabric.fyz = 0.0;
fabric.nc = 0.0;
let mut ca = if has_coordination {
analysis.take()
} else {
None
};
if let Some(ref mut ca) = ca {
while ca.coordination.len() < atoms.len() {
ca.coordination.push(0.0);
}
}
for (i, j) in neighbor.pairs(nlocal) {
let r1 = dem.radius[i];
let r2 = dem.radius[j];
let dx = atoms.pos[j][0] as f64 - atoms.pos[i][0] as f64;
let dy = atoms.pos[j][1] as f64 - atoms.pos[i][1] as f64;
let dz = atoms.pos[j][2] as f64 - atoms.pos[i][2] as f64;
let dist_sq = dx * dx + dy * dy + dz * dz;
let sum_r = r1 + r2;
if dist_sq >= sum_r * sum_r {
continue;
}
let distance = dist_sq.sqrt();
if distance == 0.0 {
continue;
}
let delta = sum_r - distance;
if delta <= 0.0 {
continue;
}
if let Some(ref mut ca) = ca {
ca.coordination[i] += 1.0;
if newton && j < nlocal {
ca.coordination[j] += 1.0;
}
}
let inv_dist = 1.0 / distance;
let nx = dx * inv_dist;
let ny = dy * inv_dist;
let nz = dz * inv_dist;
if has_fabric {
let vs = if newton { 1.0 } else { 0.5 };
fabric.fxx += nx * nx * vs;
fabric.fyy += ny * ny * vs;
fabric.fzz += nz * nz * vs;
fabric.fxy += nx * ny * vs;
fabric.fxz += nx * nz * vs;
fabric.fyz += ny * nz * vs;
fabric.nc += vs;
}
if collect_records && (newton || i < j) {
let alpha = r1 - 0.5 * delta;
let cx = atoms.pos[i][0] as f64 + alpha * nx;
let cy = atoms.pos[i][1] as f64 + alpha * ny;
let cz = atoms.pos[i][2] as f64 + alpha * nz;
contact_output.records.push(ContactRecord {
i_tag: atoms.tag[i],
j_tag: atoms.tag[j],
overlap: delta,
cx,
cy,
cz,
nx,
ny,
nz,
});
}
}
});
}
fn push_coordination_to_thermo(
atoms: Res<Atom>,
particles: ParticlesWith<'_, Optional<Read<ContactAnalysis>>>,
config: Res<ContactAnalysisConfig>,
comm: Res<CommResource>,
mut thermo: ResMut<Thermo>,
run_state: Res<RunState>,
) {
if thermo.interval == 0 || run_state.total_cycle % thermo.interval != 0 {
return;
}
particles.with(|ca| {
let Some(ca) = ca else {
return;
};
let nlocal = atoms.nlocal as usize;
let mut sum = 0.0;
let mut max_val: f64 = 0.0;
let mut min_val: f64 = f64::MAX;
let mut n_rattlers: usize = 0;
for i in 0..nlocal {
let c = ca.coordination[i];
sum += c;
if c > max_val {
max_val = c;
}
if c < min_val {
min_val = c;
}
if c < 4.0 {
n_rattlers += 1;
}
}
if nlocal == 0 {
min_val = 0.0;
}
let global_sum = comm.all_reduce_sum_f64(sum);
let global_max = -comm.all_reduce_min_f64(-max_val);
let global_min = comm.all_reduce_min_f64(min_val);
let global_atoms = atoms.natoms as f64;
let avg = if global_atoms > 0.0 {
global_sum / global_atoms
} else {
0.0
};
thermo.set("coord_avg", avg);
thermo.set("coord_max", global_max);
thermo.set("coord_min", global_min);
if config.rattlers {
let global_rattlers = comm.all_reduce_sum_f64(n_rattlers as f64);
thermo.set("n_rattlers", global_rattlers);
thermo.set(
"rattler_fraction",
if global_atoms > 0.0 {
global_rattlers / global_atoms
} else {
0.0
},
);
}
});
}
fn push_fabric_tensor_to_thermo(
fabric: Res<FabricTensorAccum>,
comm: Res<CommResource>,
mut thermo: ResMut<Thermo>,
run_state: Res<RunState>,
) {
if thermo.interval == 0 || run_state.total_cycle % thermo.interval != 0 {
return;
}
let global_fxx = comm.all_reduce_sum_f64(fabric.fxx);
let global_fyy = comm.all_reduce_sum_f64(fabric.fyy);
let global_fzz = comm.all_reduce_sum_f64(fabric.fzz);
let global_fxy = comm.all_reduce_sum_f64(fabric.fxy);
let global_fxz = comm.all_reduce_sum_f64(fabric.fxz);
let global_fyz = comm.all_reduce_sum_f64(fabric.fyz);
let global_nc = comm.all_reduce_sum_f64(fabric.nc);
if global_nc > 0.0 {
let inv_nc = 1.0 / global_nc;
thermo.set("fabric_xx", global_fxx * inv_nc);
thermo.set("fabric_yy", global_fyy * inv_nc);
thermo.set("fabric_zz", global_fzz * inv_nc);
thermo.set("fabric_xy", global_fxy * inv_nc);
thermo.set("fabric_xz", global_fxz * inv_nc);
thermo.set("fabric_yz", global_fyz * inv_nc);
} else {
thermo.set("fabric_xx", 0.0);
thermo.set("fabric_yy", 0.0);
thermo.set("fabric_zz", 0.0);
thermo.set("fabric_xy", 0.0);
thermo.set("fabric_xz", 0.0);
thermo.set("fabric_yz", 0.0);
}
thermo.set("contacts", global_nc);
}
fn dump_contact_records(
contact_output: Res<ContactOutput>,
config: Res<ContactAnalysisConfig>,
run_state: Res<RunState>,
comm: Res<CommResource>,
input: Res<Input>,
) {
if config.interval == 0 {
return;
}
let step = run_state.total_cycle;
if step % config.interval != 0 {
return;
}
let rank = comm.rank();
let base_dir = match input.output_dir.as_deref() {
Some(dir) => format!("{}/contact", dir),
None => "contact".to_string(),
};
if let Err(e) = dump_contact_csv(
&contact_output.records,
&base_dir,
&config.file_prefix,
step,
rank,
) {
eprintln!("WARNING: Contact dump failed at step {}: {}", step, e);
}
}
fn dump_contact_csv(
records: &[ContactRecord],
base_dir: &str,
prefix: &str,
step: usize,
rank: i32,
) -> std::io::Result<()> {
fs::create_dir_all(base_dir)?;
let filename = format!("{}/{}_{:06}_rank{}.csv", base_dir, prefix, step, rank);
let file = File::create(&filename)?;
let mut w = BufWriter::new(file);
writeln!(w, "i_tag,j_tag,overlap,cx,cy,cz,nx,ny,nz")?;
for r in records {
writeln!(
w,
"{},{},{},{},{},{},{},{},{}",
r.i_tag, r.j_tag, r.overlap, r.cx, r.cy, r.cz, r.nx, r.ny, r.nz
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use grass_app::App;
use soil_core::Neighbor;
use soil_core::{Atom, AtomDataRegistry};
use std::panic::{catch_unwind, AssertUnwindSafe};
fn panic_message(panic: Box<dyn std::any::Any + Send>) -> String {
if let Some(msg) = panic.downcast_ref::<String>() {
msg.clone()
} else if let Some(msg) = panic.downcast_ref::<&str>() {
msg.to_string()
} else {
"<non-string panic>".to_string()
}
}
fn build_neighbor_list(atoms: &Atom) -> Neighbor {
let nlocal = atoms.nlocal as usize;
let ntotal = atoms.len();
let mut neighbor = Neighbor::new();
neighbor.neighbor_offsets = vec![0u32; nlocal + 1];
neighbor.neighbor_indices.clear();
for i in 0..nlocal {
neighbor.neighbor_offsets[i] = neighbor.neighbor_indices.len() as u32;
for j in (i + 1)..ntotal {
let dx = atoms.pos[j][0] as f64 - atoms.pos[i][0] as f64;
let dy = atoms.pos[j][1] as f64 - atoms.pos[i][1] as f64;
let dz = atoms.pos[j][2] as f64 - atoms.pos[i][2] as f64;
let dist_sq = dx * dx + dy * dy + dz * dz;
let cut = atoms.cutoff_radius[i] as f64 + atoms.cutoff_radius[j] as f64;
if dist_sq < cut * cut * 1.5 {
neighbor.neighbor_indices.push(j as u32);
}
}
}
neighbor.neighbor_offsets[nlocal] = neighbor.neighbor_indices.len() as u32;
neighbor
}
fn make_dem_atom(n: usize) -> DemAtom {
let mut dem = DemAtom::new();
for _ in 0..n {
dem.radius.push(0.5);
dem.density.push(2500.0);
dem.inv_inertia.push(1.0);
dem.quaternion.push([1.0, 0.0, 0.0, 0.0]);
dem.omega.push([0.0; 3]);
dem.ang_mom.push([0.0; 3]);
dem.torque.push([0.0; 3]);
dem.body_id.push(0.0);
}
dem
}
fn count_coordination(
atoms: &Atom,
neighbor: &Neighbor,
dem: &DemAtom,
coordination: &mut [f64],
) {
let nlocal = atoms.nlocal as usize;
for (i, j) in neighbor.pairs(nlocal) {
let r1 = dem.radius[i];
let r2 = dem.radius[j];
let dx = atoms.pos[j][0] as f64 - atoms.pos[i][0] as f64;
let dy = atoms.pos[j][1] as f64 - atoms.pos[i][1] as f64;
let dz = atoms.pos[j][2] as f64 - atoms.pos[i][2] as f64;
let dist_sq = dx * dx + dy * dy + dz * dz;
let sum_r = r1 + r2;
if dist_sq >= sum_r * sum_r {
continue;
}
let distance = dist_sq.sqrt();
if distance == 0.0 {
continue;
}
let delta = sum_r - distance;
if delta <= 0.0 {
continue;
}
coordination[i] += 1.0;
if j < nlocal {
coordination[j] += 1.0;
}
}
}
#[test]
fn test_two_touching_particles_coordination() {
let mut atoms = Atom::new();
unsafe {
atoms.push_test_atom(1, [0.0, 0.0, 0.0], 0.5, 1.0);
atoms.push_test_atom(2, [0.9, 0.0, 0.0], 0.5, 1.0);
}
atoms.nlocal = 2;
atoms.natoms = 2;
let dem = make_dem_atom(2);
let neighbor = build_neighbor_list(&atoms);
let mut coordination = vec![0.0; 2];
count_coordination(&atoms, &neighbor, &dem, &mut coordination);
assert_eq!(coordination[0], 1.0, "atom 0 should have coord=1");
assert_eq!(coordination[1], 1.0, "atom 1 should have coord=1");
}
#[test]
fn test_isolated_particle_coordination() {
let mut atoms = Atom::new();
unsafe {
atoms.push_test_atom(1, [0.0, 0.0, 0.0], 0.5, 1.0);
atoms.push_test_atom(2, [5.0, 0.0, 0.0], 0.5, 1.0);
}
atoms.nlocal = 2;
atoms.natoms = 2;
let dem = make_dem_atom(2);
let neighbor = build_neighbor_list(&atoms);
let mut coordination = vec![0.0; 2];
count_coordination(&atoms, &neighbor, &dem, &mut coordination);
assert_eq!(coordination[0], 0.0, "atom 0 should have coord=0");
assert_eq!(coordination[1], 0.0, "atom 1 should have coord=0");
}
#[test]
fn test_four_particle_chain_coordination() {
let mut atoms = Atom::new();
unsafe {
atoms.push_test_atom(1, [0.0, 0.0, 0.0], 0.5, 1.0);
atoms.push_test_atom(2, [0.9, 0.0, 0.0], 0.5, 1.0);
atoms.push_test_atom(3, [1.8, 0.0, 0.0], 0.5, 1.0);
atoms.push_test_atom(4, [2.7, 0.0, 0.0], 0.5, 1.0);
}
atoms.nlocal = 4;
atoms.natoms = 4;
let dem = make_dem_atom(4);
let neighbor = build_neighbor_list(&atoms);
let mut coordination = vec![0.0; 4];
count_coordination(&atoms, &neighbor, &dem, &mut coordination);
assert_eq!(coordination[0], 1.0, "end atom 0: coord=1");
assert_eq!(coordination[1], 2.0, "middle atom 1: coord=2");
assert_eq!(coordination[2], 2.0, "middle atom 2: coord=2");
assert_eq!(coordination[3], 1.0, "end atom 3: coord=1");
}
#[test]
fn test_rattler_detection() {
let mut atoms = Atom::new();
unsafe {
atoms.push_test_atom(1, [0.0, 0.0, 0.0], 0.5, 1.0); atoms.push_test_atom(2, [0.9, 0.0, 0.0], 0.5, 1.0); atoms.push_test_atom(3, [-0.9, 0.0, 0.0], 0.5, 1.0); atoms.push_test_atom(4, [0.0, 0.9, 0.0], 0.5, 1.0); atoms.push_test_atom(5, [0.0, -0.9, 0.0], 0.5, 1.0); }
atoms.nlocal = 5;
atoms.natoms = 5;
let dem = make_dem_atom(5);
let neighbor = build_neighbor_list(&atoms);
let mut coordination = vec![0.0; 5];
count_coordination(&atoms, &neighbor, &dem, &mut coordination);
assert_eq!(coordination[0], 4.0, "center should have coord=4");
assert_eq!(coordination[1], 1.0, "outer should have coord=1");
let n_rattlers = coordination.iter().filter(|&&c| c < 4.0).count();
assert_eq!(n_rattlers, 4, "4 outer particles are rattlers");
}
#[test]
fn test_contact_record_csv_output() {
let records = vec![ContactRecord {
i_tag: 1,
j_tag: 2,
overlap: 0.1,
cx: 0.45,
cy: 0.0,
cz: 0.0,
nx: 1.0,
ny: 0.0,
nz: 0.0,
}];
let dir = std::env::temp_dir().join("dem_contact_test");
let _ = fs::remove_dir_all(&dir);
let result = dump_contact_csv(&records, dir.to_str().unwrap(), "contact", 1000, 0);
assert!(result.is_ok(), "CSV dump should succeed");
let content = fs::read_to_string(dir.join("contact_001000_rank0.csv")).unwrap();
assert!(content.starts_with("i_tag,j_tag,overlap,"));
assert!(content.contains("1,2,0.1,0.45,0,0,1,0,0"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_fabric_tensor_isotropic() {
let normals: Vec<[f64; 3]> = vec![
[1.0, 0.0, 0.0],
[-1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, -1.0, 0.0],
[0.0, 0.0, 1.0],
[0.0, 0.0, -1.0],
];
let nc = normals.len() as f64;
let mut fxx = 0.0;
let mut fyy = 0.0;
let mut fzz = 0.0;
let mut fxy = 0.0;
let mut fxz = 0.0;
let mut fyz = 0.0;
for n in &normals {
fxx += n[0] * n[0];
fyy += n[1] * n[1];
fzz += n[2] * n[2];
fxy += n[0] * n[1];
fxz += n[0] * n[2];
fyz += n[1] * n[2];
}
let inv_nc = 1.0 / nc;
assert!(
(fxx * inv_nc - 1.0 / 3.0).abs() < 1e-10,
"F_xx should be 1/3"
);
assert!(
(fyy * inv_nc - 1.0 / 3.0).abs() < 1e-10,
"F_yy should be 1/3"
);
assert!(
(fzz * inv_nc - 1.0 / 3.0).abs() < 1e-10,
"F_zz should be 1/3"
);
assert!((fxy * inv_nc).abs() < 1e-10, "F_xy should be 0");
assert!((fxz * inv_nc).abs() < 1e-10, "F_xz should be 0");
assert!((fyz * inv_nc).abs() < 1e-10, "F_yz should be 0");
}
#[test]
fn test_contact_analysis_config_defaults() {
let config = ContactAnalysisConfig::default();
assert_eq!(config.interval, 0);
assert!(!config.coordination);
assert!(!config.rattlers);
assert!(!config.fabric_tensor);
assert_eq!(config.file_prefix, "contact");
}
#[test]
fn test_rattlers_without_coordination_warns() {
let config = ContactAnalysisConfig {
rattlers: true,
coordination: false,
..ContactAnalysisConfig::default()
};
let msg = contact_analysis_config_warning(&config)
.expect("rattlers without coordination must produce a diagnostic");
assert!(
msg.contains("rattlers = true"),
"must name the enabled setting: {msg}"
);
assert!(
msg.contains("coordination = true"),
"must name the required setting: {msg}"
);
assert!(
msg.contains("n_rattlers") && msg.contains("rattler_fraction"),
"must name the missing outputs: {msg}"
);
}
#[test]
fn test_rattlers_with_coordination_is_clean() {
let config = ContactAnalysisConfig {
rattlers: true,
coordination: true,
..ContactAnalysisConfig::default()
};
assert!(
contact_analysis_config_warning(&config).is_none(),
"rattlers with coordination enabled is valid and must not warn"
);
}
#[test]
fn coordination_without_print_plugin_reports_setup_diagnostic() {
let mut app = App::new();
app.add_resource(Config::from_str(
r#"
[contact_analysis]
coordination = true
"#,
));
let err = catch_unwind(AssertUnwindSafe(|| {
app.add_plugins(ContactAnalysisPlugin);
}))
.expect_err("missing DumpRegistry should fail during plugin setup");
let msg = panic_message(err);
assert!(
msg.contains("ContactAnalysisPlugin setup error"),
"diagnostic should name setup failure: {msg}"
);
assert!(
msg.contains("DumpRegistry"),
"diagnostic should name missing resource: {msg}"
);
assert!(
msg.contains("CorePlugins") && msg.contains("PrintPlugin"),
"diagnostic should name the plugins that fix the setup: {msg}"
);
assert!(
!msg.contains("DumpRegistry not found"),
"old raw expect panic should not surface: {msg}"
);
}
#[test]
fn missing_contact_label_reports_setup_diagnostic() {
let mut app = App::new();
app.add_plugins(ContactAnalysisPlugin);
let err = catch_unwind(AssertUnwindSafe(|| {
app.organize_systems();
}))
.expect_err("missing hertz_mindlin_contact label should fail schedule validation");
let msg = panic_message(err);
assert!(
msg.contains("requires label \"hertz_mindlin_contact\""),
"diagnostic should name the missing contact label: {msg}"
);
assert!(
msg.contains("granular_default_plugins"),
"diagnostic should point toward GranularDefaultPlugins in the setup checker name: {msg}"
);
}
fn labeled_contact_force_for_ordering_test() {}
#[test]
fn contact_label_in_force_phase_allows_schedule_to_organize() {
let mut app = App::new();
app.add_resource(Atom::default());
app.add_resource(Neighbor::default());
let mut registry = AtomDataRegistry::default();
registry.try_register(DemAtom::new(), 0).unwrap();
app.add_resource(registry);
app.add_resource(RunState::new());
app.add_update_system(
labeled_contact_force_for_ordering_test.label(CONTACT_FORCE),
ParticleSimScheduleSet::Force,
);
app.add_plugins(ContactAnalysisPlugin);
app.organize_systems();
}
}