Files
jyvikoandClaude Opus 5.5 a226b7dcac explicit: symmetry about inclined planes (#242)
At an inclined plane's nodes the velocity along its normal is removed every
increment, from the components no prescribed velocity holds (planes meeting
at a node orthonormalized, so one pass is exact); a prescribed motion off the
plane is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:20 -04:00

511 lines
24 KiB
Rust
Raw Permalink 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.
//! The time loop: central differences with variable increments, kinematic constraints, the
//! energy account and output.
//!
//! One increment (Belytschko, Liu, Moran, Elkhodary 2014, Box 6.1, with Δt^n = ½(Δt^{n−½} +
//! Δt^{n+½})):
//! 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. 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), prescribed degrees of
//! freedom take their velocity, and a symmetry plane not normal to an axis removes its nodes'
//! velocity along its normal (from their free components: the lumped mass is the same on a
//! node's three translations, so this is the mass-weighted projection onto the constraint). 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.
//!
//! The run starts from u = 0 and the initial velocities with the half increment
//! v^{½} = v^0 + ½Δt^{½} a^0.
/// Increments between progress reports (each takes a lock).
const PROGRESS_EVERY: u64 = 64;
use fieldcraft_model::{ExplicitDynamic, Model, OutputVariable, Procedure, Step};
use crate::contact::{Evaluation, Interface};
use crate::elements::{Motion, State, Workspace, internal_forces};
use crate::result::{ContactSummary, ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
use crate::system::{NODE_DOFS, System};
use crate::{Error, Limits, Result};
/// How many elements the result names as controlling the time increment.
pub const CONTROLLING: usize = 10;
/// Relative energy balance error above which a run warns.
pub const BALANCE_TOLERANCE: f64 = 0.01;
/// Hourglass energy as a fraction of the largest internal energy of the run above which it warns
/// (the internal energy may fall back to zero, as in a rebound).
pub const HOURGLASS_TOLERANCE: f64 = 0.1;
/// The step named `name`, or the model's only explicit dynamic step.
fn select_step<'m>(model: &'m Model, name: Option<&str>) -> Result<(&'m Step, &'m ExplicitDynamic)> {
let step = model.select_step(name, Procedure::is_dynamic, "explicit dynamic")?;
match &step.procedure {
Procedure::ExplicitDynamic(e) => Ok((step, e)),
_ => Err(Error::Internal("an explicit dynamic step".into())),
}
}
/// Times at a fixed interval: whether `t` has reached the next one.
struct Schedule {
interval: f64,
next: f64,
}
impl Schedule {
fn due(&mut self, t: f64) -> bool {
if t < self.next * (1.0 - 1e-12) {
return false;
}
self.next = ((t / self.interval).floor() + 1.0) * self.interval;
true
}
}
/// What the run writes.
struct Output {
displacement: bool,
stress: bool,
strain: bool,
}
/// Velocity constraints over an increment of length `dt` (rigid walls when dt > 0, then
/// prescribed degrees of freedom, then inclined symmetry planes); 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 {
let x = sys.xyz.get(node).copied().unwrap_or_default();
let at = |k: usize| NODE_DOFS * node + k;
let gap: f64 = (0..3).map(|k| (x[k] + u.get(at(k)).copied().unwrap_or(0.0) - w.point[k]) * w.normal[k]).sum();
let normal: f64 = (0..3).map(|k| v.get(at(k)).copied().unwrap_or(0.0) * w.normal[k]).sum();
if gap + dt * normal < 0.0 {
let change = -gap / dt - normal;
for k in 0..3 {
if let Some(c) = v.get_mut(at(k)) {
*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));
}
}
}
}
}
for (c, p) in v.iter_mut().zip(&sys.prescribed) {
if let Some(p) = p {
*c = *p;
}
}
for row in &sys.planes {
row.project(v);
}
dissipated
}
/// 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`.
fn impulses(mass: &[f64], free: &[f64], constrained: &[f64], dt: f64, r: &mut [f64]) {
for (r, (m, (f, c))) in r.iter_mut().zip(mass.iter().zip(free.iter().zip(constrained))) {
*r = if c == f { 0.0 } else { m * (c - f) / dt };
}
}
fn per_node(x: &[f64]) -> Vec<[f64; 3]> {
x.as_chunks::<NODE_DOFS>().0.to_vec()
}
/// Solve an explicit dynamic step: the step named `step`, or the model's only one.
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 mut sys = System::new(model, &resolved, step, settings, limits)?;
let end = settings.end_time;
let scale = settings.dt_scale;
let bulk = settings.bulk_viscosity;
let max_increments = settings.max_increments.min(limits.max_increments);
let mut warnings = sys.warnings.clone();
// The controlling elements: the smallest critical increments at rest.
let mut critical: Vec<ControllingElement> = sys
.hexes
.iter()
.map(|h| (h.label, h.length, h.material, h.mass_factor))
.chain(sys.tets.iter().map(|t| (t.label, t.length, t.material, t.mass_factor)))
.chain(sys.rods.iter().map(|r| (r.label, r.length, r.material, 1.0)))
.map(|(label, length, m, f)| ControllingElement {
label,
// Mass scaling slows the element's waves by √f.
increment: length * f.sqrt() / sys.materials.get(m).map_or(f64::INFINITY, |m| m.wave_speed),
})
.collect();
critical.sort_by(|a, b| a.increment.total_cmp(&b.increment).then(a.label.cmp(&b.label)));
critical.truncate(CONTROLLING);
let n = sys.dofs();
let mass = sys.dof_mass();
if let Some(d) = mass.iter().position(|m| !(*m > 0.0 && m.is_finite())) {
let label = sys.labels.get(crate::discrete::node_direction(d).0).copied().unwrap_or(0);
return Err(Error::Invalid(format!(
"node {label} has no mass in direction {}; explicit dynamics needs a mass at every node (give the nodes only springs or dashpots use a point mass)",
crate::discrete::node_direction(d).1 + 1
)));
}
// Springs and dashpots have their own stable increment on the lumped mass.
let elements_smallest = critical.first().map(|c| c.increment);
if let Some((l, name)) = &sys.discrete_increment
&& elements_smallest.is_none_or(|e| *l < e)
{
warnings.push(format!("spring or dashpot `{name}` limits the time increment to {l:.3e} (before the scale factor)"));
}
let smallest = elements_smallest
.into_iter()
.chain(sys.discrete_increment.as_ref().map(|l| l.0))
.reduce(f64::min)
.filter(|d| *d > 0.0 && d.is_finite())
.ok_or_else(|| Error::Invalid("no element has a finite, positive critical time increment (check the material constants)".into()))?;
let estimate = end / (scale * smallest);
if estimate > max_increments as f64 {
let first = critical.first().map_or(0, |c| c.label);
return Err(Error::Limit(format!(
"the step needs about {estimate:.3e} increments of {:.3e} (element {first} controls it), more than the cap of {max_increments}",
scale * smallest
)));
}
// Output sizes.
let output = Output {
displacement: step.output.contains(&OutputVariable::Displacement),
stress: step.output.contains(&OutputVariable::Stress),
strain: step.output.contains(&OutputVariable::Strain),
};
let (nodes, elements) = (sys.labels.len(), sys.elements.len());
let frames = (end / settings.output_interval()).ceil() + 2.0;
let samples = (end / settings.history_interval()).ceil() + 2.0;
let per_frame = (if output.displacement { 6 * nodes } else { 0 }) + 6 * elements * (usize::from(output.stress) + usize::from(output.strain));
let per_sample = 16 + 6 * sys.history_nodes.len();
let values = frames * per_frame as f64 + samples * per_sample as f64;
if values > limits.max_output_values as f64 {
return Err(Error::Limit(format!(
"the output would hold {values:.3e} numbers; the limit is {} (lengthen the output or history interval, or request less output)",
limits.max_output_values
)));
}
// Point masses count with their mean over the three directions.
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];
let mut du = vec![0.0; n];
let v0 = sys.initial_velocity.clone();
let initial_kinetic = 0.5 * v0.iter().zip(&mass).map(|(v, m)| m * v * v).sum::<f64>();
let inelastic = sys.materials.iter().any(|m| m.j2.as_ref().is_some_and(|j| j.flow.is_some()));
// Start: forces at rest with the initial velocities, then the half increment.
let start = Motion { u: &u, du: &du, v: &v0, dt: 0.0, time: 0.0, bulk: &bulk };
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();
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
// and after the increment, Δt r · ½(v^{n−½} + v^{n+½}): exact for the kinetic energy a
// kinematic constraint removes or adds (a node stopped by a wall loses ½ m v²).
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),
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], 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| {
let mut s = [0.0; 3];
for (node, c) in x.as_chunks::<NODE_DOFS>().0.iter().enumerate() {
for k in 0..3 {
s[k] += weight(node, k) * c[k];
}
}
s
};
history.momentum.push(sum(v, &|node, k| mass.get(crate::discrete::dof(node, k)).copied().unwrap_or(0.0)));
history.constraint_force.push(sum(r, &|_, _| 1.0));
let pick = |x: &[f64]| sys.history_nodes.iter().map(|i| [0, 1, 2].map(|k| x.get(NODE_DOFS * i + k).copied().unwrap_or(0.0))).collect();
history.displacement.push(pick(u));
history.velocity.push(pick(v));
};
let frame = |t: f64, u: &[f64], v: &[f64], state: &State| {
let each = |on: bool, f: &dyn Fn(&crate::elements::ElementState) -> f64| -> Vec<f64> {
if on { state.elements.iter().map(f).collect() } else { Vec::new() }
};
let inelastic = output.stress && inelastic;
Frame {
time: t,
displacement: if output.displacement { per_node(u) } else { Vec::new() },
velocity: if output.displacement { per_node(v) } else { Vec::new() },
stress: if output.stress { state.elements.iter().map(|e| e.point.stress).collect() } else { Vec::new() },
strain: if output.strain { state.elements.iter().map(|e| e.strain).collect() } else { Vec::new() },
plastic_strain: each(inelastic, &|e| e.point.plastic_strain),
damage: each(inelastic, &|e| e.point.damage),
temperature: each(inelastic, &|e| e.point.temperature),
failed: if output.stress { state.elements.iter().map(|e| e.failed.is_some()).collect() } else { Vec::new() },
}
};
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, &found);
out_frames.push(frame(0.0, &u, &v0, &state));
let mut t = 0.0;
let mut increments = 0u64;
let mut peak_internal: f64 = 0.0;
let mut v_now = v0;
let mut completed = true;
let mut v_new = vec![0.0; n];
fieldcraft_model::progress::stage("Time", Some(end), "");
loop {
if increments.is_multiple_of(PROGRESS_EVERY) {
fieldcraft_model::progress::count(t);
fieldcraft_model::progress::check()?;
}
if increments >= max_increments {
completed = false;
warnings.push(format!("stopped at time {t:.6e} after {increments} increments (the cap); the step ends at {end:.6e}"));
break;
}
for ((x, dx), v) in u.iter_mut().zip(du.iter_mut()).zip(&v_half) {
*dx = dt_half * v;
*x += *dx;
}
t += dt_half;
increments += 1;
std::mem::swap(&mut ws, &mut previous);
let motion = Motion { u: &u, du: &du, v: &v_half, dt: dt_half, time: t, bulk: &bulk };
let crit = internal_forces(&sys, &motion, &mut state, &mut ws)?;
for &(index, failure) in &state.eroded {
let element = state.elements.get(index);
let internal = element.map_or(0.0, |e| e.energy);
energy.eroded += internal;
eroded.push(ErodedElement { label: sys.elements.get(index).copied().unwrap_or(0), time: t, failure, internal_energy: internal });
}
let remaining = end - t;
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);
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
// the internal and external forces).
let weight = 0.5 * dt_half / dt_n;
let [mut internal, mut hourglass, mut viscous, mut external, mut constraint, mut kinetic] = [0.0; 6];
let forces = ws.stress_force.iter().zip(&ws.hourglass_force).zip(&ws.viscous_force);
let before = previous.stress_force.iter().zip(&previous.hourglass_force).zip(&previous.viscous_force);
let per_dof = v_now.iter_mut().zip(&v_half).zip(&v_new).zip(&mass).zip(&du).zip(&sys.external).zip(&reaction);
for ((((((((now, vh), vn), m), dx), e), r), ((s, h), q)), ((s0, h0), q0)) in per_dof.zip(forces).zip(before) {
*now = vh + weight * (vn - vh);
internal += dx * (s0 + s);
hourglass += dx * (h0 + h);
viscous += dx * (q0 + q);
external += dx * e;
constraint += r * 0.5 * (vh + vn);
kinetic += m * *now * *now;
}
energy.internal += 0.5 * internal;
energy.hourglass += 0.5 * hourglass;
energy.viscous += 0.5 * viscous;
energy.external += external;
energy.constraint += dt_n * constraint;
energy.kinetic = 0.5 * kinetic;
if !sys.added_mass.is_empty() {
let per_node = v_now.as_chunks::<NODE_DOFS>().0.iter().zip(&sys.added_mass);
energy.added_kinetic = 0.5 * per_node.map(|(v, m)| m * (v[0] * v[0] + v[1] * v[1] + v[2] * v[2])).sum::<f64>();
}
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.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, &found);
}
if frames_due.due(t) || last {
out_frames.push(frame(t, &u, &v_now, &state));
}
std::mem::swap(&mut v_half, &mut v_new);
dt_half = dt_next;
if last {
break;
}
}
if !completed {
if history.time.last() != Some(&t) {
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));
}
}
energy.plastic = plastic(&state);
if !eroded.is_empty() {
warnings.push(format!(
"{} elements eroded (their material failed); their internal energy at erosion was {:.4e}",
eroded.len(),
energy.eroded
));
}
let error = energy.relative_error(initial_kinetic);
if error > BALANCE_TOLERANCE {
warnings.push(format!("the energy balance is off by {:.2}% of the largest energy (kinetic, internal or work)", 100.0 * error));
}
// Round-off hourglass energy in a model at rest or in rigid motion is not worth a warning.
let floor =
1e-9 * [initial_kinetic, energy.kinetic, peak_internal, energy.external.abs(), energy.constraint.abs()].into_iter().fold(0.0, f64::max);
if energy.hourglass.abs() > HOURGLASS_TOLERANCE * peak_internal && energy.hourglass.abs() > floor {
warnings.push(format!(
"hourglass energy is {:.1}% of the largest internal energy; refine the mesh or change the hourglass control",
100.0 * energy.hourglass.abs() / peak_internal.max(f64::MIN_POSITIVE)
));
}
Ok(ExplicitResult {
step: step.name.clone(),
nodes: sys.labels.clone(),
elements: sys.elements.clone(),
frames: out_frames,
history,
controlling: critical,
initial_increment,
increments,
time: t,
completed,
total_mass,
energy,
initial_kinetic,
eroded,
contacts: summaries,
mass_scaling: sys.mass_scaling.clone(),
warnings,
})
}