#[inline]
fn v3_add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
#[inline]
fn v3_sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn v3_scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
#[inline]
fn v3_dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
fn v3_norm(a: [f64; 3]) -> f64 {
v3_dot(a, a).sqrt()
}
#[inline]
fn v3_normalize(a: [f64; 3]) -> [f64; 3] {
let n = v3_norm(a);
if n < 1e-15 {
[0.0, 0.0, 0.0]
} else {
v3_scale(a, 1.0 / n)
}
}
#[derive(Debug, Clone)]
pub struct HairNode {
pub position: [f64; 3],
pub velocity: [f64; 3],
pub force: [f64; 3],
pub inv_mass: f64,
}
impl HairNode {
pub fn new(position: [f64; 3], mass: f64) -> Self {
let inv_mass = if mass > 0.0 { 1.0 / mass } else { 0.0 };
Self {
position,
velocity: [0.0; 3],
force: [0.0; 3],
inv_mass,
}
}
pub fn new_static(position: [f64; 3]) -> Self {
Self {
position,
velocity: [0.0; 3],
force: [0.0; 3],
inv_mass: 0.0,
}
}
pub fn is_static(&self) -> bool {
self.inv_mass == 0.0
}
}
#[derive(Debug, Clone)]
pub struct HairStrand {
pub nodes: Vec<HairNode>,
pub rest_lengths: Vec<f64>,
pub rest_curvature: Vec<[f64; 3]>,
pub bend_stiffness: f64,
pub twist_stiffness: f64,
pub stretch_stiffness: f64,
pub drag_coeff: f64,
}
impl HairStrand {
pub fn new_straight(
root: [f64; 3],
tip: [f64; 3],
num_segments: usize,
mass_per_node: f64,
bend_stiffness: f64,
twist_stiffness: f64,
stretch_stiffness: f64,
) -> Self {
assert!(num_segments >= 1, "At least one segment required");
let n_nodes = num_segments + 1;
let mut nodes = Vec::with_capacity(n_nodes);
let seg_length = {
let d = v3_sub(tip, root);
v3_norm(d) / num_segments as f64
};
for i in 0..n_nodes {
let t = i as f64 / num_segments as f64;
let pos = [
root[0] + t * (tip[0] - root[0]),
root[1] + t * (tip[1] - root[1]),
root[2] + t * (tip[2] - root[2]),
];
if i == 0 {
nodes.push(HairNode::new_static(pos));
} else {
nodes.push(HairNode::new(pos, mass_per_node));
}
}
let rest_lengths = vec![seg_length; num_segments];
let rest_curvature = vec![[0.0; 3]; n_nodes];
Self {
nodes,
rest_lengths,
rest_curvature,
bend_stiffness,
twist_stiffness,
stretch_stiffness,
drag_coeff: 0.0,
}
}
pub fn num_nodes(&self) -> usize {
self.nodes.len()
}
pub fn num_segments(&self) -> usize {
self.nodes.len().saturating_sub(1)
}
pub fn total_rest_length(&self) -> f64 {
self.rest_lengths.iter().sum()
}
}
pub fn bending_energy(strand: &HairStrand) -> f64 {
let nodes = &strand.nodes;
let n = nodes.len();
if n < 3 {
return 0.0;
}
let mut energy = 0.0;
for i in 1..n - 1 {
let e0 = v3_sub(nodes[i].position, nodes[i - 1].position);
let e1 = v3_sub(nodes[i + 1].position, nodes[i].position);
let t0 = v3_normalize(e0);
let t1 = v3_normalize(e1);
let l0 = strand.rest_lengths[i - 1];
let l1 = strand.rest_lengths[i];
let l_avg = 0.5 * (l0 + l1);
let kappa = v3_scale(v3_sub(t1, t0), 1.0 / l_avg.max(1e-15));
let dkappa = v3_sub(kappa, strand.rest_curvature[i]);
let kappa_sq = v3_dot(dkappa, dkappa);
energy += 0.5 * strand.bend_stiffness * kappa_sq * l_avg;
}
energy
}
pub fn twisting_energy(strand: &HairStrand) -> f64 {
let nodes = &strand.nodes;
let n = nodes.len();
if n < 3 {
return 0.0;
}
let mut energy = 0.0;
for i in 1..n - 1 {
let e0 = v3_sub(nodes[i].position, nodes[i - 1].position);
let e1 = v3_sub(nodes[i + 1].position, nodes[i].position);
let t0 = v3_normalize(e0);
let t1 = v3_normalize(e1);
let t_mid = v3_normalize(v3_add(t0, t1));
let dt = v3_sub(t1, t0);
let twist_scalar = v3_dot(dt, t_mid);
let l = strand.rest_lengths[i];
energy += 0.5 * strand.twist_stiffness * twist_scalar * twist_scalar / l.max(1e-15);
}
energy
}
pub fn stretch_energy(strand: &HairStrand) -> f64 {
let nodes = &strand.nodes;
let n = nodes.len();
if n < 2 {
return 0.0;
}
let mut energy = 0.0;
for i in 0..n - 1 {
let e = v3_sub(nodes[i + 1].position, nodes[i].position);
let length = v3_norm(e);
let l0 = strand.rest_lengths[i];
let strain = length - l0;
energy += 0.5 * strand.stretch_stiffness * strain * strain / l0.max(1e-15);
}
energy
}
pub fn root_constraint(strand: &mut HairStrand) {
if !strand.nodes.is_empty() {
strand.nodes[0].velocity = [0.0; 3];
strand.nodes[0].force = [0.0; 3];
}
}
pub fn wind_force(strand: &mut HairStrand, wind_velocity: [f64; 3]) {
let n = strand.nodes.len();
if n < 2 {
return;
}
let cd = strand.drag_coeff;
for i in 0..n {
if strand.nodes[i].is_static() {
continue;
}
let seg_len = if i == 0 {
strand.rest_lengths[0]
} else if i == n - 1 {
*strand
.rest_lengths
.last()
.expect("collection should not be empty")
} else {
0.5 * (strand.rest_lengths[i - 1] + strand.rest_lengths[i])
};
let v_rel = v3_sub(wind_velocity, strand.nodes[i].velocity);
let drag = v3_scale(v_rel, cd * seg_len);
strand.nodes[i].force = v3_add(strand.nodes[i].force, drag);
}
}
pub fn discrete_elastic_rod(strand: &HairStrand) -> Vec<[f64; 3]> {
let n = strand.nodes.len();
let mut forces = vec![[0.0_f64; 3]; n];
if n < 3 {
return forces;
}
let eps = 1e-7;
let e0 = bending_energy(strand) + stretch_energy(strand) + twisting_energy(strand);
for (i, force) in forces.iter_mut().enumerate() {
if strand.nodes[i].is_static() {
continue;
}
for (k, f_k) in force.iter_mut().enumerate() {
let mut perturbed = strand.clone();
perturbed.nodes[i].position[k] += eps;
let e1 = bending_energy(&perturbed)
+ stretch_energy(&perturbed)
+ twisting_energy(&perturbed);
*f_k = -(e1 - e0) / eps;
}
}
forces
}
pub fn hair_hair_collision(
strand_a: &mut HairStrand,
strand_b: &mut HairStrand,
contact_radius: f64,
repulsion_stiffness: f64,
) {
let na = strand_a.nodes.len();
let nb = strand_b.nodes.len();
for i in 0..na {
for j in 0..nb {
let d = v3_sub(strand_b.nodes[j].position, strand_a.nodes[i].position);
let dist = v3_norm(d);
if dist < contact_radius && dist > 1e-15 {
let penetration = contact_radius - dist;
let dir = v3_scale(d, 1.0 / dist);
let impulse = v3_scale(dir, repulsion_stiffness * penetration);
if !strand_a.nodes[i].is_static() {
strand_a.nodes[i].force = v3_sub(strand_a.nodes[i].force, impulse);
}
if !strand_b.nodes[j].is_static() {
strand_b.nodes[j].force = v3_add(strand_b.nodes[j].force, impulse);
}
}
}
}
}
pub fn style_target(strand: &mut HairStrand, target_positions: &[[f64; 3]]) {
assert_eq!(
target_positions.len(),
strand.nodes.len(),
"target_positions must match node count"
);
let n = strand.nodes.len();
for i in 0..n {
if i == 0 || i == n - 1 {
strand.rest_curvature[i] = [0.0; 3];
continue;
}
let e0 = v3_sub(target_positions[i], target_positions[i - 1]);
let e1 = v3_sub(target_positions[i + 1], target_positions[i]);
let t0 = v3_normalize(e0);
let t1 = v3_normalize(e1);
let l0 = v3_norm(e0).max(1e-15);
let l1 = v3_norm(e1).max(1e-15);
let l_avg = 0.5 * (l0 + l1);
let kappa = v3_scale(v3_sub(t1, t0), 1.0 / l_avg);
strand.rest_curvature[i] = kappa;
}
}
pub fn step_strand(strand: &mut HairStrand, gravity: [f64; 3], dt: f64) {
let elastic_forces = discrete_elastic_rod(strand);
let _n = strand.nodes.len();
let damping = 0.98_f64;
for (i, (node, el_force)) in strand
.nodes
.iter_mut()
.zip(elastic_forces.iter())
.enumerate()
{
let _ = i;
if node.is_static() {
continue;
}
let m_inv = node.inv_mass;
let mass = 1.0 / m_inv.max(1e-15);
let grav_f = v3_scale(gravity, mass);
let total_force = v3_add(v3_add(node.force, *el_force), grav_f);
let accel = v3_scale(total_force, m_inv);
let new_vel = v3_scale(v3_add(node.velocity, v3_scale(accel, dt)), damping);
node.velocity = new_vel;
node.position = v3_add(node.position, v3_scale(node.velocity, dt));
node.force = [0.0; 3];
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-10;
fn make_strand(num_segs: usize) -> HairStrand {
HairStrand::new_straight(
[0.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
num_segs,
0.001,
1e-3,
1e-4,
1000.0,
)
}
#[test]
fn test_strand_node_count() {
let s = make_strand(4);
assert_eq!(s.num_nodes(), 5, "4 segments → 5 nodes");
}
#[test]
fn test_strand_segment_count() {
let s = make_strand(4);
assert_eq!(s.num_segments(), 4, "should have 4 segments");
}
#[test]
fn test_root_is_static() {
let s = make_strand(4);
assert!(s.nodes[0].is_static(), "root node should be static");
}
#[test]
fn test_non_root_dynamic() {
let s = make_strand(4);
for i in 1..s.num_nodes() {
assert!(!s.nodes[i].is_static(), "node {i} should be dynamic");
}
}
#[test]
fn test_total_rest_length() {
let s = make_strand(5);
let len = s.total_rest_length();
assert!(
(len - 1.0).abs() < 1e-10,
"Total rest length should be 1.0, got {len}"
);
}
#[test]
fn test_bending_energy_straight_is_zero() {
let s = make_strand(5);
let e = bending_energy(&s);
assert!(
e.abs() < 1e-12,
"Straight strand bending energy should be 0, got {e}"
);
}
#[test]
fn test_twisting_energy_straight_is_zero() {
let s = make_strand(5);
let e = twisting_energy(&s);
assert!(
e.abs() < 1e-12,
"Straight strand twist energy should be 0, got {e}"
);
}
#[test]
fn test_stretch_energy_zero_undeformed() {
let s = make_strand(5);
let e = stretch_energy(&s);
assert!(
e.abs() < 1e-12,
"Undeformed stretch energy should be 0, got {e}"
);
}
#[test]
fn test_bending_energy_increases_on_displacement() {
let mut s = make_strand(4);
let e0 = bending_energy(&s);
s.nodes[2].position[0] += 0.1; let e1 = bending_energy(&s);
assert!(
e1 > e0,
"Bending energy should increase after displacement: e0={e0}, e1={e1}"
);
}
#[test]
fn test_stretch_energy_increases_on_displacement() {
let mut s = make_strand(4);
let e0 = stretch_energy(&s);
s.nodes[4].position[1] += 0.2; let e1 = stretch_energy(&s);
assert!(
e1 > e0,
"Stretch energy should increase after extension: e0={e0}, e1={e1}"
);
}
#[test]
fn test_root_constraint_zeroes_velocity() {
let mut s = make_strand(4);
s.nodes[0].velocity = [1.0, 2.0, 3.0];
root_constraint(&mut s);
assert_eq!(
s.nodes[0].velocity,
[0.0, 0.0, 0.0],
"Root velocity should be zeroed"
);
}
#[test]
fn test_wind_force_zero_drag() {
let mut s = make_strand(4);
s.drag_coeff = 0.0;
wind_force(&mut s, [10.0, 0.0, 0.0]);
for node in &s.nodes {
assert_eq!(node.force, [0.0; 3], "Zero drag → zero wind force");
}
}
#[test]
fn test_wind_force_nonzero_drag() {
let mut s = make_strand(4);
s.drag_coeff = 0.01;
wind_force(&mut s, [1.0, 0.0, 0.0]);
let total_fx: f64 = s.nodes[1..].iter().map(|n| n.force[0]).sum();
assert!(
total_fx > EPS,
"Wind should produce force in x-direction, got {total_fx}"
);
}
#[test]
fn test_wind_force_not_on_static_node() {
let mut s = make_strand(4);
s.drag_coeff = 0.1;
wind_force(&mut s, [10.0, 0.0, 0.0]);
assert_eq!(
s.nodes[0].force, [0.0; 3],
"Static node should not receive wind force"
);
}
#[test]
fn test_discrete_elastic_rod_force_size() {
let s = make_strand(4);
let forces = discrete_elastic_rod(&s);
assert_eq!(
forces.len(),
s.num_nodes(),
"Force count should equal node count"
);
}
#[test]
fn test_discrete_elastic_rod_static_zero_force() {
let s = make_strand(4);
let forces = discrete_elastic_rod(&s);
let f_root = v3_norm(forces[0]);
assert!(
f_root < EPS,
"Static root node should have zero force, got {f_root}"
);
}
#[test]
fn test_hair_collision_no_force_when_far() {
let mut s1 = make_strand(3);
let mut s2 = HairStrand::new_straight(
[10.0, 0.0, 0.0],
[10.0, 1.0, 0.0],
3,
0.001,
1e-3,
1e-4,
1000.0,
);
hair_hair_collision(&mut s1, &mut s2, 0.1, 1000.0);
for n in &s1.nodes {
assert_eq!(n.force, [0.0; 3], "No collision → zero force on s1");
}
}
#[test]
fn test_hair_collision_repulsion() {
let mut s1 = make_strand(1);
let mut s2 = make_strand(1);
s2.nodes[1].position = [0.01, 1.0, 0.0];
hair_hair_collision(&mut s1, &mut s2, 0.1, 1000.0);
let f_x = s1.nodes[1].force[0];
assert!(
f_x < 0.0 || f_x.abs() > EPS,
"Collision should produce repulsive force"
);
}
#[test]
fn test_style_target_endpoints_zero() {
let mut s = make_strand(4);
let targets: Vec<[f64; 3]> = s.nodes.iter().map(|n| n.position).collect();
style_target(&mut s, &targets);
assert_eq!(s.rest_curvature[0], [0.0; 3]);
assert_eq!(s.rest_curvature[s.num_nodes() - 1], [0.0; 3]);
}
#[test]
fn test_style_target_straight_zero_curvature() {
let mut s = make_strand(4);
let targets: Vec<[f64; 3]> = s.nodes.iter().map(|n| n.position).collect();
style_target(&mut s, &targets);
for (i, kappa) in s.rest_curvature.iter().enumerate() {
let mag = v3_norm(*kappa);
assert!(
mag < 1e-8,
"Straight target should have zero curvature at node {i}, got {mag}"
);
}
}
#[test]
fn test_step_strand_gravity_lowers_tip() {
let mut s = HairStrand::new_straight(
[0.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
4,
0.001,
1e-6, 1e-7, 1.0, );
let gravity = [0.0, -9.81, 0.0];
let initial_y = s.nodes[4].position[1];
for _ in 0..10 {
step_strand(&mut s, gravity, 1.0 / 60.0);
}
let final_y = s.nodes[4].position[1];
assert!(
final_y < initial_y,
"Tip should fall under gravity: initial={initial_y}, final={final_y}"
);
}
#[test]
fn test_step_strand_root_fixed() {
let mut s = make_strand(4);
let initial_root = s.nodes[0].position;
for _ in 0..100 {
step_strand(&mut s, [0.0, -9.81, 0.0], 1.0 / 60.0);
}
assert_eq!(
s.nodes[0].position, initial_root,
"Root should remain fixed"
);
}
#[test]
fn test_bending_energy_symmetric() {
let mut s_pos = make_strand(4);
let mut s_neg = make_strand(4);
s_pos.nodes[2].position[0] += 0.05;
s_neg.nodes[2].position[0] -= 0.05;
let e_pos = bending_energy(&s_pos);
let e_neg = bending_energy(&s_neg);
assert!(
(e_pos - e_neg).abs() < 1e-10,
"Bending energy should be symmetric: e_pos={e_pos}, e_neg={e_neg}"
);
}
#[test]
fn test_bending_energy_single_segment() {
let s = make_strand(1);
let e = bending_energy(&s);
assert!(
e.abs() < EPS,
"Single segment strand has no bending energy, got {e}"
);
}
#[test]
fn test_twisting_energy_increases_on_lateral_displacement() {
let mut s = make_strand(5);
let e0 = twisting_energy(&s);
s.nodes[3].position[0] += 0.2;
let e1 = twisting_energy(&s);
assert!(
e1 >= e0,
"Twist energy should not decrease with strand deformation: e0={e0} e1={e1}"
);
}
#[test]
fn test_hair_node_defaults() {
let node = HairNode::new([1.0, 2.0, 3.0], 0.01);
assert_eq!(node.velocity, [0.0; 3]);
assert_eq!(node.force, [0.0; 3]);
}
#[test]
fn test_elastic_forces_straight_near_zero() {
let s = make_strand(5);
let forces = discrete_elastic_rod(&s);
for (i, f) in forces.iter().enumerate() {
if s.nodes[i].is_static() {
continue;
}
let mag = v3_norm(*f);
assert!(
mag < 1e-3,
"Elastic forces on undeformed strand should be ~0 at node {i}, got {mag}"
);
}
}
}