#![allow(dead_code)]
#![allow(clippy::too_many_arguments)]
use std::f64::consts::PI;
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn norm3(a: [f64; 3]) -> f64 {
dot3(a, a).sqrt()
}
fn normalize3(a: [f64; 3]) -> [f64; 3] {
let n = norm3(a);
if n < 1e-30 {
[0.0; 3]
} else {
[a[0] / n, a[1] / n, a[2] / n]
}
}
fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
fn mat3_identity() -> [f64; 9] {
[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
}
fn mat3_mul_vec(r: [f64; 9], v: [f64; 3]) -> [f64; 3] {
[
r[0] * v[0] + r[1] * v[1] + r[2] * v[2],
r[3] * v[0] + r[4] * v[1] + r[5] * v[2],
r[6] * v[0] + r[7] * v[1] + r[8] * v[2],
]
}
fn mat3_mul(a: [f64; 9], b: [f64; 9]) -> [f64; 9] {
let mut c = [0.0_f64; 9];
for i in 0..3 {
for j in 0..3 {
for k in 0..3 {
c[i * 3 + j] += a[i * 3 + k] * b[k * 3 + j];
}
}
}
c
}
fn mat3_transpose(a: [f64; 9]) -> [f64; 9] {
[a[0], a[3], a[6], a[1], a[4], a[7], a[2], a[5], a[8]]
}
pub fn rodrigues(axis: [f64; 3], theta: f64) -> [f64; 9] {
let n = normalize3(axis);
let (nx, ny, nz) = (n[0], n[1], n[2]);
let (s, c) = (theta.sin(), theta.cos());
let t = 1.0 - c;
[
c + nx * nx * t,
nx * ny * t - nz * s,
nx * nz * t + ny * s,
ny * nx * t + nz * s,
c + ny * ny * t,
ny * nz * t - nx * s,
nz * nx * t - ny * s,
nz * ny * t + nx * s,
c + nz * nz * t,
]
}
#[derive(Debug, Clone, Copy)]
pub struct DirectorTriad {
pub d1: [f64; 3],
pub d2: [f64; 3],
pub d3: [f64; 3],
}
impl DirectorTriad {
pub fn new(d1: [f64; 3], d2: [f64; 3], d3: [f64; 3]) -> Self {
Self { d1, d2, d3 }
}
pub fn canonical() -> Self {
Self {
d1: [1.0, 0.0, 0.0],
d2: [0.0, 1.0, 0.0],
d3: [0.0, 0.0, 1.0],
}
}
pub fn to_rotation_matrix(&self) -> [f64; 9] {
[
self.d1[0], self.d2[0], self.d3[0], self.d1[1], self.d2[1], self.d3[1], self.d1[2],
self.d2[2], self.d3[2],
]
}
pub fn rotate(&self, r: [f64; 9]) -> Self {
Self {
d1: mat3_mul_vec(r, self.d1),
d2: mat3_mul_vec(r, self.d2),
d3: mat3_mul_vec(r, self.d3),
}
}
}
pub fn darboux_vector(frame_a: &DirectorTriad, frame_b: &DirectorTriad, ds: f64) -> [f64; 3] {
let ra = frame_a.to_rotation_matrix();
let rb = frame_b.to_rotation_matrix();
let ra_t = mat3_transpose(ra);
let dr = mat3_mul(ra_t, rb);
let wx = (dr[7] - dr[5]) / (2.0 * ds);
let wy = (dr[2] - dr[6]) / (2.0 * ds);
let wz = (dr[3] - dr[1]) / (2.0 * ds);
[wx, wy, wz]
}
pub fn stretch_shear_strain(frame: &DirectorTriad, dr_ds: [f64; 3]) -> [f64; 3] {
let ra = frame.to_rotation_matrix();
let ra_t = mat3_transpose(ra);
let gamma = mat3_mul_vec(ra_t, dr_ds);
[gamma[0], gamma[1], gamma[2] - 1.0]
}
#[derive(Debug, Clone, Copy)]
pub struct CosseratRodProperties {
pub ei1: f64,
pub ei2: f64,
pub gj: f64,
pub ea: f64,
pub ga1: f64,
pub ga2: f64,
pub rho_a: f64,
}
impl CosseratRodProperties {
pub fn circular(radius: f64, young_modulus: f64, shear_modulus: f64, density: f64) -> Self {
let r2 = radius * radius;
let area = PI * r2;
let i_bending = PI * r2 * r2 / 4.0;
let j_polar = PI * r2 * r2 / 2.0;
Self {
ei1: young_modulus * i_bending,
ei2: young_modulus * i_bending,
gj: shear_modulus * j_polar,
ea: young_modulus * area,
ga1: shear_modulus * area,
ga2: shear_modulus * area,
rho_a: density * area,
}
}
pub fn rectangular(
width: f64,
height: f64,
young_modulus: f64,
shear_modulus: f64,
density: f64,
) -> Self {
let area = width * height;
let i1 = width * height * height * height / 12.0;
let i2 = height * width * width * width / 12.0;
let (a, b) = if width >= height {
(width, height)
} else {
(height, width)
};
let j = a
* b
* b
* b
* (1.0 / 3.0 - 0.21 * b / a * (1.0 - b * b * b * b / (12.0 * a * a * a * a)));
Self {
ei1: young_modulus * i1,
ei2: young_modulus * i2,
gj: shear_modulus * j,
ea: young_modulus * area,
ga1: shear_modulus * area,
ga2: shear_modulus * area,
rho_a: density * area,
}
}
}
pub fn kirchhoff_energy_density(
props: &CosseratRodProperties,
kappa: [f64; 3],
kappa0: [f64; 3],
) -> f64 {
let dk1 = kappa[0] - kappa0[0];
let dk2 = kappa[1] - kappa0[1];
let dtau = kappa[2] - kappa0[2];
0.5 * (props.ei1 * dk1 * dk1 + props.ei2 * dk2 * dk2 + props.gj * dtau * dtau)
}
pub fn simo_reissner_energy_density(
props: &CosseratRodProperties,
gamma: [f64; 3],
kappa: [f64; 3],
) -> f64 {
let e_shear_stretch = 0.5
* (props.ga1 * gamma[0] * gamma[0]
+ props.ga2 * gamma[1] * gamma[1]
+ props.ea * gamma[2] * gamma[2]);
let e_bend_twist = 0.5
* (props.ei1 * kappa[0] * kappa[0]
+ props.ei2 * kappa[1] * kappa[1]
+ props.gj * kappa[2] * kappa[2]);
e_shear_stretch + e_bend_twist
}
pub fn internal_moment(
props: &CosseratRodProperties,
kappa: [f64; 3],
kappa0: [f64; 3],
) -> [f64; 3] {
[
props.ei1 * (kappa[0] - kappa0[0]),
props.ei2 * (kappa[1] - kappa0[1]),
props.gj * (kappa[2] - kappa0[2]),
]
}
pub fn internal_force(props: &CosseratRodProperties, gamma: [f64; 3]) -> [f64; 3] {
[
props.ga1 * gamma[0],
props.ga2 * gamma[1],
props.ea * gamma[2],
]
}
#[derive(Debug, Clone)]
pub struct CosseratNode {
pub position: [f64; 3],
pub frame: DirectorTriad,
pub velocity: [f64; 3],
pub omega: [f64; 3],
pub is_pinned: bool,
pub ext_force: [f64; 3],
pub ext_moment: [f64; 3],
}
impl CosseratNode {
pub fn new(position: [f64; 3]) -> Self {
Self {
position,
frame: DirectorTriad::canonical(),
velocity: [0.0; 3],
omega: [0.0; 3],
is_pinned: false,
ext_force: [0.0; 3],
ext_moment: [0.0; 3],
}
}
pub fn new_pinned(position: [f64; 3]) -> Self {
let mut node = Self::new(position);
node.is_pinned = true;
node
}
pub fn apply_force(&mut self, force: [f64; 3]) {
self.ext_force = add3(self.ext_force, force);
}
pub fn apply_moment(&mut self, moment: [f64; 3]) {
self.ext_moment = add3(self.ext_moment, moment);
}
pub fn clear_loads(&mut self) {
self.ext_force = [0.0; 3];
self.ext_moment = [0.0; 3];
}
}
#[derive(Debug, Clone)]
pub struct DiscreteCosseratRod {
pub nodes: Vec<CosseratNode>,
pub intrinsic_kappa: Vec<[f64; 3]>,
pub rest_lengths: Vec<f64>,
pub props: CosseratRodProperties,
}
impl DiscreteCosseratRod {
pub fn straight(num_segments: usize, total_length: f64, props: CosseratRodProperties) -> Self {
let seg_len = total_length / num_segments as f64;
let mut nodes = Vec::with_capacity(num_segments + 1);
for i in 0..=num_segments {
let z = i as f64 * seg_len;
nodes.push(CosseratNode::new([0.0, 0.0, z]));
}
nodes[0].is_pinned = true;
let intrinsic_kappa = vec![[0.0; 3]; num_segments];
let rest_lengths = vec![seg_len; num_segments];
Self {
nodes,
intrinsic_kappa,
rest_lengths,
props,
}
}
pub fn num_segments(&self) -> usize {
self.nodes.len().saturating_sub(1)
}
pub fn darboux(&self, i: usize) -> [f64; 3] {
let ds = self.rest_lengths[i];
darboux_vector(&self.nodes[i].frame, &self.nodes[i + 1].frame, ds)
}
pub fn elastic_energy(&self) -> f64 {
let mut energy = 0.0;
for i in 0..self.num_segments() {
let kappa = self.darboux(i);
let kappa0 = self.intrinsic_kappa[i];
let ds = self.rest_lengths[i];
energy += kirchhoff_energy_density(&self.props, kappa, kappa0) * ds;
}
energy
}
pub fn apply_gravity(&mut self, g: [f64; 3]) {
let n_seg = self.num_segments() as f64;
let total_len: f64 = self.rest_lengths.iter().sum();
let seg_mass = self.props.rho_a * total_len / n_seg;
for node in &mut self.nodes {
if !node.is_pinned {
let gravity_force = scale3(g, seg_mass);
node.apply_force(gravity_force);
}
}
}
pub fn step(&mut self, dt: f64, damping: f64) {
let n_seg = self.num_segments();
let mut elastic_moments = vec![[0.0_f64; 3]; self.nodes.len()];
let mut elastic_forces = vec![[0.0_f64; 3]; self.nodes.len()];
for i in 0..n_seg {
let kappa = self.darboux(i);
let kappa0 = self.intrinsic_kappa[i];
let ds = self.rest_lengths[i];
let moment_local = internal_moment(&self.props, kappa, kappa0);
let r_i = self.nodes[i].frame.to_rotation_matrix();
let moment_global = mat3_mul_vec(r_i, moment_local);
let half_m = scale3(moment_global, 0.5);
elastic_moments[i] = add3(elastic_moments[i], half_m);
elastic_moments[i + 1] = add3(elastic_moments[i + 1], half_m);
let pos_i = self.nodes[i].position;
let pos_j = self.nodes[i + 1].position;
let dr = sub3(pos_j, pos_i);
let dr_norm = norm3(dr);
if dr_norm > 1e-30 {
let stretch = (dr_norm - ds) / ds;
let f_mag = self.props.ea * stretch;
let f_dir = scale3(normalize3(dr), f_mag);
elastic_forces[i] = add3(elastic_forces[i], f_dir);
elastic_forces[i + 1] = sub3(elastic_forces[i + 1], f_dir);
}
}
let inv_mass =
1.0 / (self.props.rho_a * self.rest_lengths.iter().sum::<f64>() / n_seg as f64);
for i in 0..self.nodes.len() {
if self.nodes[i].is_pinned {
continue;
}
let total_force = add3(add3(self.nodes[i].ext_force, elastic_forces[i]), [0.0; 3]);
let accel = scale3(total_force, inv_mass);
let v_new = scale3(
add3(self.nodes[i].velocity, scale3(accel, dt)),
1.0 - damping * dt,
);
self.nodes[i].velocity = v_new;
self.nodes[i].position = add3(self.nodes[i].position, scale3(v_new, dt));
let total_moment = add3(self.nodes[i].ext_moment, elastic_moments[i]);
let alpha = scale3(total_moment, inv_mass);
let omega_new = scale3(
add3(self.nodes[i].omega, scale3(alpha, dt)),
1.0 - damping * dt,
);
self.nodes[i].omega = omega_new;
let omega_mag = norm3(omega_new);
if omega_mag > 1e-30 {
let rot = rodrigues(omega_new, omega_mag * dt);
self.nodes[i].frame = self.nodes[i].frame.rotate(rot);
}
self.nodes[i].clear_loads();
}
}
}
pub fn euler_buckling_load(ei: f64, length: f64, effective_length_factor: f64) -> f64 {
let le = effective_length_factor * length;
PI * PI * ei / (le * le)
}
pub fn slenderness_ratio(effective_length: f64, radius_of_gyration: f64) -> f64 {
effective_length / radius_of_gyration
}
pub fn radius_of_gyration(second_moment_area: f64, area: f64) -> f64 {
(second_moment_area / area).sqrt()
}
pub fn tendon_bending_moment(tension: f64, eccentricity: f64) -> f64 {
tension * eccentricity
}
pub fn tendon_induced_curvature(tension: f64, eccentricity: f64, ei: f64) -> f64 {
tension * eccentricity / ei
}
#[derive(Debug, Clone)]
pub struct TendonActuatedRod {
pub rod: DiscreteCosseratRod,
pub tendon_eccentricities: Vec<[f64; 2]>,
pub tendon_tensions: Vec<f64>,
}
impl TendonActuatedRod {
pub fn new(rod: DiscreteCosseratRod, tendon_eccentricities: Vec<[f64; 2]>) -> Self {
let n = tendon_eccentricities.len();
Self {
rod,
tendon_eccentricities,
tendon_tensions: vec![0.0; n],
}
}
pub fn set_tension(&mut self, idx: usize, tension: f64) {
if idx < self.tendon_tensions.len() {
self.tendon_tensions[idx] = tension;
}
}
pub fn actuation_moment(&self) -> [f64; 2] {
let mut m1 = 0.0;
let mut m2 = 0.0;
for (i, ecc) in self.tendon_eccentricities.iter().enumerate() {
let t = self.tendon_tensions[i];
m1 += t * ecc[1]; m2 += t * ecc[0]; }
[m1, m2]
}
pub fn step(&mut self, dt: f64, damping: f64) {
let act_m = self.actuation_moment();
let n_nodes = self.rod.nodes.len();
for i in 0..n_nodes {
if !self.rod.nodes[i].is_pinned {
let r = self.rod.nodes[i].frame.to_rotation_matrix();
let m_local = [act_m[0], act_m[1], 0.0];
let m_global = mat3_mul_vec(r, m_local);
self.rod.nodes[i].apply_moment(m_global);
}
}
self.rod.step(dt, damping);
}
}
#[derive(Debug, Clone)]
pub struct ActiveCosseratRod {
pub rod: DiscreteCosseratRod,
pub target_kappa: Vec<[f64; 3]>,
pub tau_activation: f64,
}
impl ActiveCosseratRod {
pub fn new(rod: DiscreteCosseratRod, tau_activation: f64) -> Self {
let n_seg = rod.num_segments();
let target_kappa = rod.intrinsic_kappa.clone();
Self {
rod,
target_kappa: target_kappa.into_iter().take(n_seg).collect(),
tau_activation,
}
}
pub fn set_target_curvature(&mut self, idx: usize, kappa_target: [f64; 3]) {
if idx < self.target_kappa.len() {
self.target_kappa[idx] = kappa_target;
}
}
pub fn update_activation(&mut self, dt: f64) {
for i in 0..self.rod.intrinsic_kappa.len() {
let kappa0 = self.rod.intrinsic_kappa[i];
let target = self.target_kappa[i];
let dkappa = scale3(sub3(target, kappa0), dt / self.tau_activation);
self.rod.intrinsic_kappa[i] = add3(kappa0, dkappa);
}
}
pub fn step(&mut self, dt: f64, gravity: [f64; 3], damping: f64) {
self.update_activation(dt);
self.rod.apply_gravity(gravity);
self.rod.step(dt, damping);
}
}
#[derive(Debug, Clone)]
pub struct CosseratPlate {
pub rows: usize,
pub cols: usize,
pub positions: Vec<[f64; 3]>,
pub frames: Vec<DirectorTriad>,
pub dx: f64,
pub dy: f64,
pub bending_stiffness: f64,
pub membrane_stiffness: f64,
}
impl CosseratPlate {
pub fn flat(
rows: usize,
cols: usize,
lx: f64,
ly: f64,
bending_stiffness: f64,
membrane_stiffness: f64,
) -> Self {
let dx = lx / (cols.saturating_sub(1).max(1)) as f64;
let dy = ly / (rows.saturating_sub(1).max(1)) as f64;
let mut positions = Vec::with_capacity(rows * cols);
let frames = vec![DirectorTriad::canonical(); rows * cols];
for j in 0..rows {
for i in 0..cols {
positions.push([i as f64 * dx, j as f64 * dy, 0.0]);
}
}
Self {
rows,
cols,
positions,
frames,
dx,
dy,
bending_stiffness,
membrane_stiffness,
}
}
pub fn idx(&self, row: usize, col: usize) -> usize {
row * self.cols + col
}
pub fn bending_energy(&self) -> f64 {
let mut energy = 0.0;
for j in 1..self.rows.saturating_sub(1) {
for i in 1..self.cols.saturating_sub(1) {
let p = self.positions[self.idx(j, i)];
let px = self.positions[self.idx(j, i + 1)];
let pm = self.positions[self.idx(j, i - 1)];
let py = self.positions[self.idx(j + 1, i)];
let pn = self.positions[self.idx(j - 1, i)];
let kappa_xx = (px[2] - 2.0 * p[2] + pm[2]) / (self.dx * self.dx);
let kappa_yy = (py[2] - 2.0 * p[2] + pn[2]) / (self.dy * self.dy);
let kappa_sum = kappa_xx + kappa_yy;
energy += 0.5 * self.bending_stiffness * kappa_sum * kappa_sum * self.dx * self.dy;
}
}
energy
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WrinkleState {
Taut,
Wrinkled,
Slack,
}
pub fn wrinkling_state(sigma1: f64, sigma2: f64) -> WrinkleState {
if sigma1 < 0.0 && sigma2 < 0.0 {
WrinkleState::Slack
} else if sigma2 < 0.0 {
WrinkleState::Wrinkled
} else {
WrinkleState::Taut
}
}
pub fn wrinkle_wavelength(bending_stiffness_d: f64, compressive_force_per_width: f64) -> f64 {
if compressive_force_per_width < 1e-30 {
f64::INFINITY
} else {
PI * (bending_stiffness_d / compressive_force_per_width).powf(0.25)
}
}
pub fn wrinkle_amplitude(excess_strain: f64, wavelength: f64) -> f64 {
if excess_strain <= 0.0 {
0.0
} else {
wavelength * (excess_strain / PI).sqrt()
}
}
pub fn constant_curvature_position(kappa: f64, s: f64) -> [f64; 3] {
if kappa.abs() < 1e-30 {
[s, 0.0, 0.0]
} else {
[
(kappa * s).sin() / kappa,
(1.0 - (kappa * s).cos()) / kappa,
0.0,
]
}
}
pub fn helix_position(kappa: f64, tau_helix: f64, s: f64) -> [f64; 3] {
if kappa.abs() < 1e-30 {
return [0.0, 0.0, s];
}
let _rho = 1.0 / kappa;
let denom = kappa * kappa + tau_helix * tau_helix;
let r_helix = kappa / denom;
let pitch_over_2pi = tau_helix / denom;
let theta = s * (kappa * kappa + tau_helix * tau_helix).sqrt();
[
r_helix * theta.cos(),
r_helix * theta.sin(),
pitch_over_2pi * theta,
]
}
pub fn rotation_vector_from_matrix(r: [f64; 9]) -> [f64; 3] {
let trace = r[0] + r[4] + r[8];
let cos_phi = ((trace - 1.0) / 2.0).clamp(-1.0, 1.0);
let phi = cos_phi.acos();
if phi.abs() < 1e-12 {
return [0.0; 3];
}
let s = 1.0 / (2.0 * phi.sin());
let wx = (r[7] - r[5]) * s;
let wy = (r[2] - r[6]) * s;
let wz = (r[3] - r[1]) * s;
[wx * phi, wy * phi, wz * phi]
}
pub fn compose_rotation_vectors(theta1: [f64; 3], theta2: [f64; 3]) -> [f64; 3] {
let phi1 = norm3(theta1);
let phi2 = norm3(theta2);
let r1 = if phi1 < 1e-30 {
mat3_identity()
} else {
rodrigues(theta1, phi1)
};
let r2 = if phi2 < 1e-30 {
mat3_identity()
} else {
rodrigues(theta2, phi2)
};
let r_combined = mat3_mul(r2, r1);
rotation_vector_from_matrix(r_combined)
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
#[test]
fn test_canonical_frame_orthonormal() {
let frame = DirectorTriad::canonical();
assert!((dot3(frame.d1, frame.d2)).abs() < EPS, "d1 ⊥ d2");
assert!((dot3(frame.d2, frame.d3)).abs() < EPS, "d2 ⊥ d3");
assert!((dot3(frame.d1, frame.d3)).abs() < EPS, "d1 ⊥ d3");
assert!((norm3(frame.d1) - 1.0).abs() < EPS, "d1 unit");
assert!((norm3(frame.d3) - 1.0).abs() < EPS, "d3 unit");
}
#[test]
fn test_rodrigues_zero_angle() {
let r = rodrigues([0.0, 0.0, 1.0], 0.0);
let v = [1.0, 2.0, 3.0];
let rv = mat3_mul_vec(r, v);
for i in 0..3 {
assert!(
(rv[i] - v[i]).abs() < EPS,
"Rodrigues(θ=0) should give identity: {rv:?} vs {v:?}"
);
}
}
#[test]
fn test_rodrigues_90_about_z() {
let r = rodrigues([0.0, 0.0, 1.0], PI / 2.0);
let v = [1.0, 0.0, 0.0];
let rv = mat3_mul_vec(r, v);
assert!(
(rv[0]).abs() < 1e-10,
"x→y: x component should be 0, got {}",
rv[0]
);
assert!(
(rv[1] - 1.0).abs() < 1e-10,
"x→y: y component should be 1, got {}",
rv[1]
);
assert!(
(rv[2]).abs() < 1e-10,
"x→y: z component should be 0, got {}",
rv[2]
);
}
#[test]
fn test_darboux_zero_for_same_frames() {
let frame = DirectorTriad::canonical();
let kappa = darboux_vector(&frame, &frame, 0.1);
for i in 0..3 {
assert!(
kappa[i].abs() < EPS,
"Darboux vector should be zero for identical frames, got {kappa:?}"
);
}
}
#[test]
fn test_stretch_shear_zero_for_straight() {
let frame = DirectorTriad::canonical();
let dr_ds = [0.0, 0.0, 1.0]; let gamma = stretch_shear_strain(&frame, dr_ds);
for i in 0..3 {
assert!(
gamma[i].abs() < EPS,
"No strain for undeformed straight rod: gamma={gamma:?}"
);
}
}
#[test]
fn test_kirchhoff_energy_zero_at_rest() {
let props = CosseratRodProperties::circular(0.01, 200e9, 80e9, 7800.0);
let kappa0 = [0.0, 0.0, 0.0];
let energy = kirchhoff_energy_density(&props, kappa0, kappa0);
assert!(
energy.abs() < EPS,
"Kirchhoff energy at rest should be zero, got {energy}"
);
}
#[test]
fn test_kirchhoff_energy_increases_with_deformation() {
let props = CosseratRodProperties::circular(0.01, 200e9, 80e9, 7800.0);
let kappa0 = [0.0; 3];
let kappa1 = [1.0, 0.0, 0.0];
let kappa2 = [2.0, 0.0, 0.0];
let e1 = kirchhoff_energy_density(&props, kappa1, kappa0);
let e2 = kirchhoff_energy_density(&props, kappa2, kappa0);
assert!(
e2 > e1,
"Higher curvature → higher elastic energy: {e1} vs {e2}"
);
}
#[test]
fn test_internal_moment_zero_at_rest() {
let props = CosseratRodProperties::circular(0.01, 200e9, 80e9, 7800.0);
let m = internal_moment(&props, [0.0; 3], [0.0; 3]);
for i in 0..3 {
assert!(m[i].abs() < EPS, "Moment should be zero at rest, got {m:?}");
}
}
#[test]
fn test_internal_force_sign() {
let props = CosseratRodProperties::circular(0.01, 200e9, 80e9, 7800.0);
let gamma_tension = [0.0, 0.0, 0.1]; let f = internal_force(&props, gamma_tension);
assert!(
f[2] > 0.0,
"Positive stretch → positive axial force, got {}",
f[2]
);
}
#[test]
fn test_discrete_rod_creation() {
let props = CosseratRodProperties::circular(0.01, 200e9, 80e9, 7800.0);
let rod = DiscreteCosseratRod::straight(10, 1.0, props);
assert_eq!(rod.nodes.len(), 11, "10 segments → 11 nodes");
assert_eq!(rod.num_segments(), 10);
}
#[test]
fn test_discrete_rod_first_pinned() {
let props = CosseratRodProperties::circular(0.01, 200e9, 80e9, 7800.0);
let rod = DiscreteCosseratRod::straight(5, 0.5, props);
assert!(rod.nodes[0].is_pinned, "First node should be pinned");
}
#[test]
fn test_buckling_load_decreases_with_length() {
let ei = 1000.0;
let p1 = euler_buckling_load(ei, 1.0, 1.0);
let p2 = euler_buckling_load(ei, 2.0, 1.0);
assert!(p2 < p1, "Longer rod buckles at lower load: {p1} vs {p2}");
}
#[test]
fn test_buckling_load_boundary_conditions() {
let ei = 1000.0;
let l = 1.0;
let p_pinned = euler_buckling_load(ei, l, 1.0);
let p_cantilever = euler_buckling_load(ei, l, 2.0);
assert!(
(p_pinned - 4.0 * p_cantilever).abs() < 1e-6,
"Cantilever buckling = pinned/4: {p_pinned} vs {p_cantilever}"
);
}
#[test]
fn test_tendon_moment_scales_with_eccentricity() {
let m1 = tendon_bending_moment(100.0, 0.01);
let m2 = tendon_bending_moment(100.0, 0.02);
assert!(
(m2 - 2.0 * m1).abs() < EPS,
"Tendon moment scales with eccentricity"
);
}
#[test]
fn test_tendon_curvature_positive() {
let kappa = tendon_induced_curvature(100.0, 0.01, 1000.0);
assert!(
kappa > 0.0,
"Positive tension → positive curvature, got {kappa}"
);
}
#[test]
fn test_wrinkling_taut() {
assert_eq!(wrinkling_state(100.0, 50.0), WrinkleState::Taut);
}
#[test]
fn test_wrinkling_wrinkled() {
assert_eq!(wrinkling_state(100.0, -10.0), WrinkleState::Wrinkled);
}
#[test]
fn test_wrinkling_slack() {
assert_eq!(wrinkling_state(-10.0, -50.0), WrinkleState::Slack);
}
#[test]
fn test_wrinkle_wavelength_increases_with_stiffness() {
let lam1 = wrinkle_wavelength(1e-4, 1.0);
let lam2 = wrinkle_wavelength(1e-3, 1.0);
assert!(
lam2 > lam1,
"Higher D → longer wrinkle wavelength: {lam1} vs {lam2}"
);
}
#[test]
fn test_wrinkle_amplitude_zero_no_strain() {
let a = wrinkle_amplitude(0.0, 0.01);
assert!(
a.abs() < EPS,
"Zero excess strain → zero wrinkle amplitude, got {a}"
);
}
#[test]
fn test_constant_curvature_origin() {
let pos = constant_curvature_position(1.0, 0.0);
for i in 0..3 {
assert!(
pos[i].abs() < EPS,
"Arc at s=0 should be at origin, got {pos:?}"
);
}
}
#[test]
fn test_constant_curvature_straight() {
let pos = constant_curvature_position(0.0, 2.5);
assert!(
(pos[0] - 2.5).abs() < EPS,
"Zero curvature → straight line at x=s: {pos:?}"
);
}
#[test]
fn test_cosserat_plate_node_count() {
let plate = CosseratPlate::flat(4, 5, 1.0, 0.8, 1e-3, 1e5);
assert_eq!(plate.positions.len(), 20, "4×5 plate has 20 nodes");
assert_eq!(plate.rows, 4);
assert_eq!(plate.cols, 5);
}
#[test]
fn test_plate_bending_energy_flat() {
let plate = CosseratPlate::flat(4, 4, 1.0, 1.0, 1e-3, 1e5);
let e = plate.bending_energy();
assert!(
e.abs() < EPS,
"Flat plate bending energy should be zero, got {e}"
);
}
#[test]
fn test_active_rod_activation_converges() {
let props = CosseratRodProperties::circular(0.005, 100e9, 40e9, 1000.0);
let rod = DiscreteCosseratRod::straight(5, 0.5, props);
let mut active = ActiveCosseratRod::new(rod, 0.1);
let target = [1.0, 0.0, 0.0];
active.set_target_curvature(2, target);
for _ in 0..100 {
active.update_activation(0.01);
}
let kappa = active.rod.intrinsic_kappa[2];
assert!(
(kappa[0] - 1.0).abs() < 0.01,
"Active curvature should converge to target: {kappa:?}"
);
}
#[test]
fn test_rotation_vector_from_identity() {
let id = mat3_identity();
let rv = rotation_vector_from_matrix(id);
for i in 0..3 {
assert!(
rv[i].abs() < EPS,
"Identity rotation vector should be zero, got {rv:?}"
);
}
}
#[test]
fn test_compose_rotations_90_90() {
let theta90 = [0.0, 0.0, PI / 2.0];
let composed = compose_rotation_vectors(theta90, theta90);
assert!(
(composed[2].abs() - PI).abs() < 1e-10,
"90°+90° about z should give 180°, got {composed:?}"
);
}
#[test]
fn test_circular_rod_isotropic() {
let props = CosseratRodProperties::circular(0.01, 200e9, 80e9, 7800.0);
assert!(
(props.ei1 - props.ei2).abs() < EPS,
"Circular rod should have EI1=EI2: {} vs {}",
props.ei1,
props.ei2
);
}
#[test]
fn test_elastic_energy_zero_straight() {
let props = CosseratRodProperties::circular(0.01, 200e9, 80e9, 7800.0);
let rod = DiscreteCosseratRod::straight(5, 1.0, props);
let energy = rod.elastic_energy();
assert!(
energy.abs() < EPS,
"Straight rod at rest should have zero elastic energy, got {energy}"
);
}
#[test]
fn test_rod_step_gravity() {
let props = CosseratRodProperties::circular(0.005, 1e9, 0.4e9, 1000.0);
let mut rod = DiscreteCosseratRod::straight(4, 0.4, props);
let initial_pos = rod.nodes[4].position;
let g = [0.0, -9.81, 0.0];
let dt = 1e-3;
for _ in 0..50 {
rod.apply_gravity(g);
rod.step(dt, 0.01);
}
let final_y = rod.nodes[4].position[1];
assert!(
final_y < initial_pos[1],
"Free end should fall under gravity: initial_y={}, final_y={final_y}",
initial_pos[1]
);
}
}