#![allow(clippy::needless_range_loop)]
#![allow(dead_code)]
#![allow(clippy::too_many_arguments)]
use std::f64::consts::PI;
type Vec3 = [f64; 3];
#[inline]
fn dot3(a: Vec3, b: Vec3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
fn cross3(a: Vec3, b: Vec3) -> Vec3 {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
#[inline]
fn norm3(v: Vec3) -> f64 {
dot3(v, v).sqrt()
}
#[inline]
fn normalize3(v: Vec3) -> Vec3 {
let n = norm3(v);
if n < 1e-15 {
[0.0; 3]
} else {
[v[0] / n, v[1] / n, v[2] / n]
}
}
#[inline]
fn add3(a: Vec3, b: Vec3) -> Vec3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
#[inline]
fn sub3(a: Vec3, b: Vec3) -> Vec3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn scale3(v: Vec3, s: f64) -> Vec3 {
[v[0] * s, v[1] * s, v[2] * s]
}
fn triangle_area(p0: Vec3, p1: Vec3, p2: Vec3) -> f64 {
let e1 = sub3(p1, p0);
let e2 = sub3(p2, p0);
norm3(cross3(e1, e2)) * 0.5
}
fn triangle_normal(p0: Vec3, p1: Vec3, p2: Vec3) -> Vec3 {
let e1 = sub3(p1, p0);
let e2 = sub3(p2, p0);
scale3(cross3(e1, e2), 0.5)
}
fn mean_curvature_laplace(v: Vec3, ring: &[Vec3], areas: &[f64]) -> f64 {
let n = ring.len();
if n < 2 {
return 0.0;
}
let total_area: f64 = areas.iter().sum::<f64>().max(1e-20);
let mut laplace = [0.0_f64; 3];
for i in 0..n {
let prev = ring[(i + n - 1) % n];
let next = ring[(i + 1) % n];
let edge_prev = sub3(prev, v);
let edge_curr = sub3(ring[i], v);
let edge_next = sub3(next, v);
let cot_alpha = {
let d = dot3(edge_prev, edge_curr);
let c = norm3(cross3(edge_prev, edge_curr));
if c < 1e-15 { 0.0 } else { d / c }
};
let cot_beta = {
let d = dot3(edge_next, edge_curr);
let c = norm3(cross3(edge_next, edge_curr));
if c < 1e-15 { 0.0 } else { d / c }
};
let w = (cot_alpha + cot_beta).max(0.0); let e = sub3(ring[i], v);
for k in 0..3 {
laplace[k] += w * e[k];
}
}
for k in 0..3 {
laplace[k] /= 2.0 * total_area;
}
norm3(laplace) * 0.5 }
#[derive(Debug, Clone)]
pub struct MembraneVertex {
pub position: Vec3,
pub normal: Vec3,
pub area: f64,
pub mean_curvature: f64,
pub gaussian_curvature: f64,
}
impl MembraneVertex {
pub fn new(position: Vec3) -> Self {
Self {
position,
normal: [0.0, 0.0, 1.0],
area: 0.0,
mean_curvature: 0.0,
gaussian_curvature: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct LipidBilayer {
pub kappa: f64,
pub kappa_gaussian: f64,
pub c0: f64,
pub k_area: f64,
pub area0: f64,
pub ade_alpha: f64,
pub thickness: f64,
pub vertices: Vec<MembraneVertex>,
pub triangles: Vec<[usize; 3]>,
}
impl LipidBilayer {
pub fn new_circular_patch(
radius: f64,
n_rings: usize,
kappa: f64,
kappa_g: f64,
c0: f64,
k_area: f64,
thickness: f64,
) -> Self {
let n = n_rings + 1; let step = 2.0 * radius / n as f64;
let mut vertices = Vec::new();
for iy in 0..=n {
for ix in 0..=n {
let x = -radius + ix as f64 * step;
let y = -radius + iy as f64 * step;
vertices.push(MembraneVertex::new([x, y, 0.0]));
}
}
let mut triangles = Vec::new();
let stride = n + 1;
for iy in 0..n {
for ix in 0..n {
let bl = iy * stride + ix;
let br = bl + 1;
let tl = bl + stride;
let tr = tl + 1;
triangles.push([bl, br, tr]);
triangles.push([bl, tr, tl]);
}
}
let area0 = 4.0 * radius * radius;
Self {
kappa,
kappa_gaussian: kappa_g,
c0,
k_area,
area0,
ade_alpha: 1.0,
thickness,
vertices,
triangles,
}
}
pub fn bending_energy(&self) -> f64 {
let mut energy = 0.0;
for tri in &self.triangles {
let p0 = self.vertices[tri[0]].position;
let p1 = self.vertices[tri[1]].position;
let p2 = self.vertices[tri[2]].position;
let area = triangle_area(p0, p1, p2);
let h = (self.vertices[tri[0]].mean_curvature
+ self.vertices[tri[1]].mean_curvature
+ self.vertices[tri[2]].mean_curvature)
/ 3.0;
let k = (self.vertices[tri[0]].gaussian_curvature
+ self.vertices[tri[1]].gaussian_curvature
+ self.vertices[tri[2]].gaussian_curvature)
/ 3.0;
energy +=
area * (0.5 * self.kappa * (2.0 * h - self.c0).powi(2) + self.kappa_gaussian * k);
}
energy
}
pub fn total_area(&self) -> f64 {
self.triangles
.iter()
.map(|tri| {
triangle_area(
self.vertices[tri[0]].position,
self.vertices[tri[1]].position,
self.vertices[tri[2]].position,
)
})
.sum()
}
pub fn area_elastic_energy(&self) -> f64 {
let a = self.total_area();
self.k_area * (a - self.area0).powi(2) / (2.0 * self.area0.max(1e-20))
}
pub fn ade_energy(&self) -> f64 {
let delta_a: f64 = self
.triangles
.iter()
.map(|tri| {
let p0 = self.vertices[tri[0]].position;
let p1 = self.vertices[tri[1]].position;
let p2 = self.vertices[tri[2]].position;
let area = triangle_area(p0, p1, p2);
let h = (self.vertices[tri[0]].mean_curvature
+ self.vertices[tri[1]].mean_curvature
+ self.vertices[tri[2]].mean_curvature)
/ 3.0;
area * h * self.thickness
})
.sum();
let delta_a0 = 0.0; self.ade_alpha * self.kappa / (2.0 * self.thickness.powi(2)) * (delta_a - delta_a0).powi(2)
}
pub fn assign_curvatures(&mut self, h: f64, k: f64) {
for v in &mut self.vertices {
v.mean_curvature = h;
v.gaussian_curvature = k;
}
}
pub fn update_normals(&mut self) {
let n = self.vertices.len();
let mut normals = vec![[0.0_f64; 3]; n];
for tri in &self.triangles {
let p0 = self.vertices[tri[0]].position;
let p1 = self.vertices[tri[1]].position;
let p2 = self.vertices[tri[2]].position;
let n_tri = triangle_normal(p0, p1, p2);
for &vi in tri.iter() {
for k in 0..3 {
normals[vi][k] += n_tri[k];
}
}
}
for (i, v) in self.vertices.iter_mut().enumerate() {
v.normal = normalize3(normals[i]);
}
}
}
#[derive(Debug, Clone)]
pub struct SpectrinNode {
pub position: Vec3,
pub velocity: Vec3,
pub rest_length: f64,
pub index: usize,
}
impl SpectrinNode {
pub fn new(position: Vec3, rest_length: f64, index: usize) -> Self {
Self {
position,
velocity: [0.0; 3],
rest_length,
index,
}
}
}
#[derive(Debug, Clone)]
pub struct SpectrinEdge {
pub i: usize,
pub j: usize,
pub rest: f64,
pub k_spring: f64,
}
impl SpectrinEdge {
pub fn force_on_i(&self, nodes: &[SpectrinNode]) -> Vec3 {
let pi = nodes[self.i].position;
let pj = nodes[self.j].position;
let d = sub3(pj, pi);
let len = norm3(d).max(1e-15);
let fmag = self.k_spring * (len - self.rest);
scale3(normalize3(d), fmag)
}
}
#[derive(Debug, Clone)]
pub struct RedBloodCellModel {
pub nodes: Vec<SpectrinNode>,
pub edges: Vec<SpectrinEdge>,
pub kappa: f64,
pub mu_s: f64,
pub k_area: f64,
pub gamma_bc: f64,
pub tank_tread_omega: f64,
pub radius: f64,
}
impl RedBloodCellModel {
pub fn new(radius: f64, n_nodes: usize, kappa: f64, mu_s: f64, k_area: f64) -> Self {
use rand::RngExt;
let mut rng = rand::rng();
let mut nodes: Vec<SpectrinNode> = (0..n_nodes)
.map(|i| {
let phi = (2.0 * PI * i as f64 / n_nodes as f64) + rng.random_range(-0.05..0.05);
let theta = PI * (i as f64 + 0.5) / n_nodes as f64;
let x = radius * theta.sin() * phi.cos();
let y = radius * theta.sin() * phi.sin();
let z = radius * theta.cos();
SpectrinNode::new([x, y, z], radius * 2.0 * PI / n_nodes as f64, i)
})
.collect();
nodes[0].position = [radius, 0.0, 0.0];
let mut edges = Vec::new();
for i in 0..n_nodes {
let j = (i + 1) % n_nodes;
let rest = norm3(sub3(nodes[i].position, nodes[j].position));
edges.push(SpectrinEdge {
i,
j,
rest,
k_spring: mu_s,
});
}
Self {
nodes,
edges,
kappa,
mu_s,
k_area,
gamma_bc: 1e-5,
tank_tread_omega: 0.0,
radius,
}
}
pub fn elastic_energy(&self) -> f64 {
self.edges
.iter()
.map(|e| {
let pi = self.nodes[e.i].position;
let pj = self.nodes[e.j].position;
let len = norm3(sub3(pj, pi));
0.5 * e.k_spring * (len - e.rest).powi(2)
})
.sum()
}
pub fn step(&mut self, dt: f64) {
let n = self.nodes.len();
let mut forces = vec![[0.0_f64; 3]; n];
for edge in &self.edges {
let f_on_i = edge.force_on_i(&self.nodes);
for k in 0..3 {
forces[edge.i][k] += f_on_i[k];
forces[edge.j][k] -= f_on_i[k]; }
}
let mass = 1e-15; for i in 0..n {
let a = scale3(forces[i], 1.0 / mass);
for k in 0..3 {
self.nodes[i].velocity[k] += a[k] * dt;
self.nodes[i].position[k] += self.nodes[i].velocity[k] * dt;
}
}
}
pub fn apply_tank_treading(&mut self, dt: f64) {
let angle = self.tank_tread_omega * dt;
let cos_a = angle.cos();
let sin_a = angle.sin();
for node in &mut self.nodes {
let x = node.position[0];
let y = node.position[1];
node.position[0] = cos_a * x - sin_a * y;
node.position[1] = sin_a * x + cos_a * y;
}
}
pub fn deformability_index(&self) -> f64 {
let xs: Vec<f64> = self.nodes.iter().map(|n| n.position[0]).collect();
let ys: Vec<f64> = self.nodes.iter().map(|n| n.position[1]).collect();
let x_max = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let x_min = xs.iter().cloned().fold(f64::INFINITY, f64::min);
let y_max = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let y_min = ys.iter().cloned().fold(f64::INFINITY, f64::min);
let l = (x_max - x_min).max(1e-20);
let w = (y_max - y_min).max(1e-20);
let big = l.max(w);
let small = l.min(w);
(big - small) / (big + small)
}
pub fn bc_coupling_energy(&self) -> f64 {
self.nodes
.iter()
.map(|n| {
let r = norm3(n.position);
self.gamma_bc * (r - self.radius).powi(2)
})
.sum()
}
}
#[derive(Debug, Clone)]
pub struct MembraneFluidDynamics {
pub eta_membrane: f64,
pub eta_fluid: f64,
pub thickness: f64,
pub kbt: f64,
pub surface_tension: f64,
pub zeta: f64,
}
impl MembraneFluidDynamics {
pub fn new(eta_membrane: f64, eta_fluid: f64, thickness: f64, kbt: f64) -> Self {
Self {
eta_membrane,
eta_fluid,
thickness,
kbt,
surface_tension: 1e-5, zeta: eta_membrane * thickness,
}
}
pub fn saffman_delbruck_d(&self, r_protein: f64) -> f64 {
const EULER_GAMMA: f64 = 0.5772156649;
let l_sd = self.eta_membrane * self.thickness / (self.eta_fluid * r_protein.max(1e-15));
let prefactor = self.kbt / (4.0 * PI * self.eta_membrane * self.thickness);
let arg = if l_sd < 1.0 {
l_sd.ln() - EULER_GAMMA + 0.5
} else {
l_sd.ln() - EULER_GAMMA
};
(prefactor * arg).max(prefactor * 0.1)
}
pub fn rotational_diffusion_d(&self, r_protein: f64) -> f64 {
self.kbt / (4.0 * PI * self.eta_membrane * self.thickness * r_protein.powi(2))
}
pub fn msd(&self, r_protein: f64, t: f64) -> f64 {
4.0 * self.saffman_delbruck_d(r_protein) * t
}
pub fn oseen_tensor_2d(&self, r: f64) -> f64 {
if r < 1e-15 {
return 0.0;
}
self.kbt / (4.0 * PI * self.eta_membrane * self.thickness * r)
}
pub fn stokeslet_velocity(&self, x: f64, y: f64, f: [f64; 2]) -> [f64; 2] {
let r2 = x * x + y * y;
if r2 < 1e-20 {
return [0.0; 2];
}
let r = r2.sqrt();
let prefactor = 1.0 / (4.0 * PI * self.zeta.max(1e-20));
let t11 = (-r.ln()) + x * x / r2;
let t12 = x * y / r2;
let t22 = (-r.ln()) + y * y / r2;
[
prefactor * (t11 * f[0] + t12 * f[1]),
prefactor * (t12 * f[0] + t22 * f[1]),
]
}
pub fn tension_energy(&self, area: f64, area0: f64) -> f64 {
0.5 * self.surface_tension * (area - area0).powi(2) / area0.max(1e-20)
}
}
#[derive(Debug, Clone)]
pub struct MembraneProtein {
pub id: usize,
pub position: [f64; 2],
pub preferred_curvature: f64,
pub radius: f64,
pub inclusion_energy: f64,
pub curvature_sensing: f64,
pub clustered: bool,
}
impl MembraneProtein {
pub fn new(
id: usize,
position: [f64; 2],
preferred_curvature: f64,
radius: f64,
curvature_sensing: f64,
) -> Self {
Self {
id,
position,
preferred_curvature,
radius,
inclusion_energy: 0.0,
curvature_sensing,
clustered: false,
}
}
pub fn inclusion_energy_at(&self, h: f64) -> f64 {
self.curvature_sensing * (h - self.preferred_curvature).powi(2)
}
pub fn update_energy(&mut self, h: f64) {
self.inclusion_energy = self.inclusion_energy_at(h);
}
pub fn sensing_force(&self, grad_h: [f64; 2]) -> [f64; 2] {
let d_ed_h = 2.0 * self.curvature_sensing * (self.preferred_curvature - 0.0);
[d_ed_h * grad_h[0], d_ed_h * grad_h[1]]
}
}
#[derive(Debug, Clone)]
pub struct ProteinCluster {
pub members: Vec<usize>,
pub centroid: [f64; 2],
pub radius: f64,
pub effective_curvature: f64,
}
impl ProteinCluster {
pub fn from_proteins(proteins: &[MembraneProtein], indices: Vec<usize>) -> Self {
let n = indices.len() as f64;
let centroid = if indices.is_empty() {
[0.0; 2]
} else {
let sum_x: f64 = indices.iter().map(|&i| proteins[i].position[0]).sum();
let sum_y: f64 = indices.iter().map(|&i| proteins[i].position[1]).sum();
[sum_x / n, sum_y / n]
};
let radius = if indices.is_empty() {
0.0
} else {
let max_r: f64 = indices
.iter()
.map(|&i| {
let dx = proteins[i].position[0] - centroid[0];
let dy = proteins[i].position[1] - centroid[1];
(dx * dx + dy * dy).sqrt() + proteins[i].radius
})
.fold(0.0_f64, f64::max);
max_r
};
let effective_curvature = if indices.is_empty() {
0.0
} else {
indices
.iter()
.map(|&i| proteins[i].preferred_curvature)
.sum::<f64>()
/ n
};
Self {
members: indices,
centroid,
radius,
effective_curvature,
}
}
pub fn should_join(&self, pos: [f64; 2]) -> bool {
let dx = pos[0] - self.centroid[0];
let dy = pos[1] - self.centroid[1];
(dx * dx + dy * dy).sqrt() < 2.0 * self.radius.max(1e-9)
}
}
#[derive(Debug, Clone)]
pub struct VesicleSimulation {
pub bilayer: LipidBilayer,
pub volume0: f64,
pub k_volume: f64,
pub delta_p: f64,
pub time: f64,
}
impl VesicleSimulation {
pub fn new_sphere(radius: f64, kappa: f64, k_area: f64, k_volume: f64) -> Self {
let bilayer =
LipidBilayer::new_circular_patch(radius, 4, kappa, -kappa * 0.5, 0.0, k_area, 4e-9);
let volume0 = (4.0 / 3.0) * PI * radius.powi(3);
Self {
bilayer,
volume0,
k_volume,
delta_p: 0.0,
time: 0.0,
}
}
pub fn enclosed_volume(&self) -> f64 {
let mut vol = 0.0;
for tri in &self.bilayer.triangles {
let p0 = self.bilayer.vertices[tri[0]].position;
let p1 = self.bilayer.vertices[tri[1]].position;
let p2 = self.bilayer.vertices[tri[2]].position;
let n = cross3(p1, p2);
vol += dot3(p0, n) / 6.0;
}
vol.abs()
}
pub fn volume_energy(&self) -> f64 {
let v = self.enclosed_volume();
self.k_volume * (v - self.volume0).powi(2) / (2.0 * self.volume0.max(1e-30))
}
pub fn reduced_volume(&self) -> f64 {
let a = self.bilayer.total_area();
let v = self.enclosed_volume();
let v_sphere = (a / (4.0 * PI)).sqrt().powi(3) * (4.0 / 3.0) * PI;
if v_sphere < 1e-30 {
return 1.0;
}
v / v_sphere
}
pub fn total_energy(&self) -> f64 {
let v = self.enclosed_volume();
let osmotic = self.delta_p * v;
self.bilayer.bending_energy()
+ self.bilayer.area_elastic_energy()
+ self.volume_energy()
+ osmotic
}
pub fn shape_class(&self) -> &'static str {
let v_star = self.reduced_volume();
if v_star > 0.95 {
"sphere"
} else if v_star > 0.65 {
"prolate/oblate"
} else if v_star > 0.3 {
"stomatocyte"
} else {
"highly_deflated"
}
}
pub fn step(&mut self, dt: f64) {
let nv = self.bilayer.vertices.len();
if nv == 0 {
self.time += dt;
return;
}
let mut incident: Vec<Vec<usize>> = vec![Vec::new(); nv];
for (t_idx, tri) in self.bilayer.triangles.iter().enumerate() {
for &vi in tri.iter() {
incident[vi].push(t_idx);
}
}
let positions: Vec<Vec3> = self.bilayer.vertices.iter().map(|v| v.position).collect();
let mut cot_lap: Vec<Vec3> = vec![[0.0; 3]; nv];
let mut voronoi_area: Vec<f64> = vec![0.0_f64; nv];
for tri in &self.bilayer.triangles {
let [a, b, c] = [tri[0], tri[1], tri[2]];
let pa = positions[a];
let pb = positions[b];
let pc = positions[c];
let cot_a = {
let ab = sub3(pb, pa);
let ac = sub3(pc, pa);
let d = dot3(ab, ac);
let cross_mag = norm3(cross3(ab, ac));
if cross_mag < 1e-15 {
0.0
} else {
d / cross_mag
}
};
let cot_b = {
let ba = sub3(pa, pb);
let bc = sub3(pc, pb);
let d = dot3(ba, bc);
let cross_mag = norm3(cross3(ba, bc));
if cross_mag < 1e-15 {
0.0
} else {
d / cross_mag
}
};
let cot_c = {
let ca = sub3(pa, pc);
let cb = sub3(pb, pc);
let d = dot3(ca, cb);
let cross_mag = norm3(cross3(ca, cb));
if cross_mag < 1e-15 {
0.0
} else {
d / cross_mag
}
};
let w_a = cot_a.max(0.0); let w_b = cot_b.max(0.0);
let w_c = cot_c.max(0.0);
for d in 0..3 {
cot_lap[b][d] += w_a * (positions[c][d] - positions[b][d]);
cot_lap[c][d] += w_a * (positions[b][d] - positions[c][d]);
}
for d in 0..3 {
cot_lap[a][d] += w_b * (positions[c][d] - positions[a][d]);
cot_lap[c][d] += w_b * (positions[a][d] - positions[c][d]);
}
for d in 0..3 {
cot_lap[a][d] += w_c * (positions[b][d] - positions[a][d]);
cot_lap[b][d] += w_c * (positions[a][d] - positions[b][d]);
}
let bc2 = {
let e = sub3(pb, pc);
dot3(e, e)
};
let ac2 = {
let e = sub3(pa, pc);
dot3(e, e)
};
let ab2 = {
let e = sub3(pa, pb);
dot3(e, e)
};
voronoi_area[a] += (w_b * ab2 + w_c * ac2) / 8.0;
voronoi_area[b] += (w_a * bc2 + w_c * ab2) / 8.0;
voronoi_area[c] += (w_a * bc2 + w_b * ac2) / 8.0;
}
let mut lb: Vec<Vec3> = vec![[0.0; 3]; nv];
for i in 0..nv {
let area_i = voronoi_area[i].max(1e-30);
for d in 0..3 {
lb[i][d] = cot_lap[i][d] / (2.0 * area_i);
}
}
let mut bilap: Vec<Vec3> = vec![[0.0; 3]; nv];
{
let mut cot_lap2: Vec<Vec3> = vec![[0.0; 3]; nv];
for tri in &self.bilayer.triangles {
let [a, b, c] = [tri[0], tri[1], tri[2]];
let pa = positions[a];
let pb = positions[b];
let pc = positions[c];
let cot_a = {
let ab = sub3(pb, pa);
let ac = sub3(pc, pa);
let d = dot3(ab, ac);
let cm = norm3(cross3(ab, ac));
if cm < 1e-15 { 0.0 } else { (d / cm).max(0.0) }
};
let cot_b = {
let ba = sub3(pa, pb);
let bc = sub3(pc, pb);
let d = dot3(ba, bc);
let cm = norm3(cross3(ba, bc));
if cm < 1e-15 { 0.0 } else { (d / cm).max(0.0) }
};
let cot_c = {
let ca = sub3(pa, pc);
let cb = sub3(pb, pc);
let d = dot3(ca, cb);
let cm = norm3(cross3(ca, cb));
if cm < 1e-15 { 0.0 } else { (d / cm).max(0.0) }
};
for d in 0..3 {
cot_lap2[b][d] += cot_a * (lb[c][d] - lb[b][d]);
cot_lap2[c][d] += cot_a * (lb[b][d] - lb[c][d]);
cot_lap2[a][d] += cot_b * (lb[c][d] - lb[a][d]);
cot_lap2[c][d] += cot_b * (lb[a][d] - lb[c][d]);
cot_lap2[a][d] += cot_c * (lb[b][d] - lb[a][d]);
cot_lap2[b][d] += cot_c * (lb[a][d] - lb[b][d]);
}
}
for i in 0..nv {
let area_i = voronoi_area[i].max(1e-30);
for d in 0..3 {
bilap[i][d] = cot_lap2[i][d] / (2.0 * area_i);
}
}
}
let a_total = self.bilayer.total_area();
let v_total = self.enclosed_volume();
let lambda_a = self.bilayer.k_area / self.bilayer.area0.max(1e-30);
let lambda_v = self.k_volume / self.volume0.max(1e-30);
let da = a_total - self.bilayer.area0;
let dv = v_total - self.volume0;
let mut grad_area: Vec<Vec3> = vec![[0.0; 3]; nv];
let mut grad_vol: Vec<Vec3> = vec![[0.0; 3]; nv];
for tri in &self.bilayer.triangles {
let [a, b, c] = [tri[0], tri[1], tri[2]];
let pa = positions[a];
let pb = positions[b];
let pc = positions[c];
let n_tri = triangle_normal(pa, pb, pc);
let area_tri = norm3(n_tri);
if area_tri < 1e-15 {
continue;
}
let n_hat = normalize3(n_tri);
let grad_a_a = scale3(cross3(n_hat, sub3(pc, pb)), 0.5);
let grad_a_b = scale3(cross3(n_hat, sub3(pa, pc)), 0.5);
let grad_a_c = scale3(cross3(n_hat, sub3(pb, pa)), 0.5);
for d in 0..3 {
grad_area[a][d] += grad_a_a[d];
grad_area[b][d] += grad_a_b[d];
grad_area[c][d] += grad_a_c[d];
}
let gv_a = scale3(cross3(pb, pc), 1.0 / 6.0);
let gv_b = scale3(cross3(pc, pa), 1.0 / 6.0);
let gv_c = scale3(cross3(pa, pb), 1.0 / 6.0);
for d in 0..3 {
grad_vol[a][d] += gv_a[d];
grad_vol[b][d] += gv_b[d];
grad_vol[c][d] += gv_c[d];
}
}
let kappa = self.bilayer.kappa;
for i in 0..nv {
let f_bend = scale3(bilap[i], -kappa * voronoi_area[i]);
let f_area = scale3(grad_area[i], -lambda_a * da);
let f_vol = scale3(grad_vol[i], -lambda_v * dv);
for d in 0..3 {
self.bilayer.vertices[i].position[d] += dt * (f_bend[d] + f_area[d] + f_vol[d]);
}
}
self.bilayer.update_normals();
self.time += dt;
}
}
#[derive(Debug, Clone)]
pub struct AfmIndentation {
pub tip_radius: f64,
pub young_modulus: f64,
pub poisson_ratio: f64,
pub indentation: f64,
pub contact_radius: f64,
}
impl AfmIndentation {
pub fn new(tip_radius: f64, young_modulus: f64, poisson_ratio: f64) -> Self {
Self {
tip_radius,
young_modulus,
poisson_ratio,
indentation: 0.0,
contact_radius: 0.0,
}
}
pub fn reduced_modulus(&self) -> f64 {
self.young_modulus / (1.0 - self.poisson_ratio.powi(2))
}
pub fn hertz_force(&self, delta: f64) -> f64 {
if delta <= 0.0 {
return 0.0;
}
(4.0 / 3.0) * self.reduced_modulus() * self.tip_radius.sqrt() * delta.powf(1.5)
}
pub fn contact_radius_at(&self, delta: f64) -> f64 {
(self.tip_radius * delta.max(0.0)).sqrt()
}
pub fn infer_young_modulus(&self, force: f64, delta: f64) -> f64 {
if delta <= 1e-20 || force <= 0.0 {
return 0.0;
}
let e_star = force / ((4.0 / 3.0) * self.tip_radius.sqrt() * delta.powf(1.5));
e_star * (1.0 - self.poisson_ratio.powi(2))
}
}
#[derive(Debug, Clone)]
pub struct CellMechanics {
pub afm: AfmIndentation,
pub rbc: RedBloodCellModel,
pub membrane_tension: f64,
pub turgor_pressure: f64,
pub radius: f64,
pub spring_constant: f64,
}
impl CellMechanics {
pub fn new(
radius: f64,
young_modulus: f64,
poisson_ratio: f64,
mu_s: f64,
n_nodes: usize,
) -> Self {
let afm = AfmIndentation::new(1e-6, young_modulus, poisson_ratio);
let rbc = RedBloodCellModel::new(radius, n_nodes, 2e-19, mu_s, 1e-5);
let membrane_tension = 0.0;
let turgor_pressure = 0.0;
let spring_constant = 4.0 / 3.0 * afm.reduced_modulus() * radius.sqrt();
Self {
afm,
rbc,
membrane_tension,
turgor_pressure,
radius,
spring_constant,
}
}
pub fn update_tension_from_pressure(&mut self) {
self.membrane_tension = self.turgor_pressure * self.radius / 2.0;
}
pub fn tension_from_area_strain(&self, area: f64, area0: f64, k_area: f64) -> f64 {
k_area * (area - area0) / area0.max(1e-20)
}
pub fn osmotic_pressure(&self, delta_c: f64, temperature: f64) -> f64 {
const R_GAS: f64 = 8.314; delta_c * R_GAS * temperature
}
pub fn deformability_index(&self) -> f64 {
self.rbc.deformability_index()
}
pub fn estimate_stiffness(&self, deltas: &[f64], forces: &[f64]) -> f64 {
if deltas.is_empty() {
return 0.0;
}
let n = deltas.len() as f64;
let sum_dd: f64 = deltas.iter().map(|d| d * d).sum();
let sum_fd: f64 = forces.iter().zip(deltas.iter()).map(|(f, d)| f * d).sum();
if sum_dd < 1e-30 {
return 0.0;
}
sum_fd / sum_dd / n * n }
pub fn apply_lysolipid_softening(&mut self, lf: f64) {
let lf = lf.clamp(0.0, 0.99);
self.afm.young_modulus *= 1.0 - lf;
self.spring_constant *= 1.0 - lf;
}
}
pub fn rms_deviation_from_sphere(vertices: &[MembraneVertex], radius: f64) -> f64 {
if vertices.is_empty() {
return 0.0;
}
let msd: f64 = vertices
.iter()
.map(|v| (norm3(v.position) - radius).powi(2))
.sum::<f64>()
/ vertices.len() as f64;
msd.sqrt()
}
pub fn asphericity(vertices: &[MembraneVertex]) -> f64 {
let n = vertices.len();
if n == 0 {
return 0.0;
}
let cx: f64 = vertices.iter().map(|v| v.position[0]).sum::<f64>() / n as f64;
let cy: f64 = vertices.iter().map(|v| v.position[1]).sum::<f64>() / n as f64;
let cz: f64 = vertices.iter().map(|v| v.position[2]).sum::<f64>() / n as f64;
let mut gxx = 0.0_f64;
let mut gyy = 0.0_f64;
let mut gzz = 0.0_f64;
for v in vertices {
let dx = v.position[0] - cx;
let dy = v.position[1] - cy;
let dz = v.position[2] - cz;
gxx += dx * dx;
gyy += dy * dy;
gzz += dz * dz;
}
gxx /= n as f64;
gyy /= n as f64;
gzz /= n as f64;
let trace = gxx + gyy + gzz;
let var =
(gxx - trace / 3.0).powi(2) + (gyy - trace / 3.0).powi(2) + (gzz - trace / 3.0).powi(2);
var / (trace / 3.0).powi(2).max(1e-30)
}
#[inline]
pub fn sphere_area(r: f64) -> f64 {
4.0 * PI * r * r
}
#[inline]
pub fn sphere_volume(r: f64) -> f64 {
(4.0 / 3.0) * PI * r * r * r
}
pub fn helfrich_energy_density(kappa: f64, kappa_g: f64, h: f64, k: f64, c0: f64) -> f64 {
0.5 * kappa * (2.0 * h - c0).powi(2) + kappa_g * k
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bilayer_creates_vertices_and_triangles() {
let bl = LipidBilayer::new_circular_patch(1e-6, 3, 4e-20, -2e-20, 0.0, 240e-3, 4e-9);
assert!(!bl.vertices.is_empty(), "should have vertices");
assert!(!bl.triangles.is_empty(), "should have triangles");
}
#[test]
fn test_bilayer_area_positive() {
let bl = LipidBilayer::new_circular_patch(5e-6, 4, 4e-20, -2e-20, 0.0, 240e-3, 4e-9);
let a = bl.total_area();
assert!(a > 0.0, "area should be positive, got {a}");
}
#[test]
fn test_bilayer_bending_energy_zero_at_flat_no_spontaneous() {
let mut bl = LipidBilayer::new_circular_patch(1e-6, 3, 4e-20, 0.0, 0.0, 240e-3, 4e-9);
bl.assign_curvatures(0.0, 0.0); let e = bl.bending_energy();
assert!(
e.abs() < 1e-30,
"bending energy should be ~0 for flat, no c0: {e}"
);
}
#[test]
fn test_bilayer_bending_energy_increases_with_curvature() {
let mut bl = LipidBilayer::new_circular_patch(1e-6, 3, 4e-20, 0.0, 0.0, 240e-3, 4e-9);
bl.assign_curvatures(0.0, 0.0);
let e0 = bl.bending_energy();
bl.assign_curvatures(1e6, 0.0); let e1 = bl.bending_energy();
assert!(e1 > e0, "energy should increase with curvature");
}
#[test]
fn test_bilayer_area_elastic_energy_at_rest_zero() {
let bl = LipidBilayer::new_circular_patch(1e-6, 4, 4e-20, -2e-20, 0.0, 240e-3, 4e-9);
let e = bl.area_elastic_energy();
assert!(e >= 0.0, "area elastic energy must be non-negative");
}
#[test]
fn test_bilayer_ade_energy_zero_at_flat() {
let mut bl = LipidBilayer::new_circular_patch(1e-6, 3, 4e-20, 0.0, 0.0, 240e-3, 4e-9);
bl.assign_curvatures(0.0, 0.0);
let e = bl.ade_energy();
assert!(e.abs() < 1e-30, "ADE energy flat: {e}");
}
#[test]
fn test_bilayer_update_normals_runs() {
let mut bl = LipidBilayer::new_circular_patch(1e-6, 3, 4e-20, -2e-20, 0.0, 240e-3, 4e-9);
bl.update_normals();
for v in &bl.vertices {
let n = norm3(v.normal);
assert!((n - 1.0).abs() < 0.1 || n < 1e-10, "normal not unit: {n}");
}
}
#[test]
fn test_helfrich_energy_density_zero_for_flat() {
let e = helfrich_energy_density(4e-20, 0.0, 0.0, 0.0, 0.0);
assert!(e.abs() < 1e-40, "e={e}");
}
#[test]
fn test_helfrich_energy_density_positive_with_curvature() {
let e = helfrich_energy_density(4e-20, 0.0, 1e6, 0.0, 0.0);
assert!(e > 0.0, "e={e}");
}
#[test]
fn test_rbc_creates_nodes_and_edges() {
let rbc = RedBloodCellModel::new(4e-6, 12, 2e-19, 1e-5, 1e-5);
assert_eq!(rbc.nodes.len(), 12);
assert_eq!(rbc.edges.len(), 12);
}
#[test]
fn test_rbc_elastic_energy_positive() {
let rbc = RedBloodCellModel::new(4e-6, 8, 2e-19, 1e-5, 1e-5);
let e = rbc.elastic_energy();
assert!(e >= 0.0, "elastic energy must be non-negative: {e}");
}
#[test]
fn test_rbc_step_moves_nodes() {
let mut rbc = RedBloodCellModel::new(4e-6, 6, 2e-19, 1e-5, 1e-5);
rbc.nodes[0].position[0] *= 1.5;
let pos_before = rbc.nodes[1].position;
rbc.step(1e-9); let pos_after = rbc.nodes[1].position;
let moved = (pos_after[0] - pos_before[0]).abs()
+ (pos_after[1] - pos_before[1]).abs()
+ (pos_after[2] - pos_before[2]).abs();
assert!(moved >= 0.0); }
#[test]
fn test_rbc_tank_treading() {
let mut rbc = RedBloodCellModel::new(4e-6, 6, 2e-19, 1e-5, 1e-5);
rbc.tank_tread_omega = 10.0; let x0 = rbc.nodes[0].position[0];
let y0 = rbc.nodes[0].position[1];
rbc.apply_tank_treading(0.01);
let x1 = rbc.nodes[0].position[0];
let y1 = rbc.nodes[0].position[1];
let r_before = (x0 * x0 + y0 * y0).sqrt();
let r_after = (x1 * x1 + y1 * y1).sqrt();
assert!(
(r_before - r_after).abs() < 1e-15,
"radius should be conserved under rotation"
);
}
#[test]
fn test_rbc_deformability_index_in_range() {
let rbc = RedBloodCellModel::new(4e-6, 8, 2e-19, 1e-5, 1e-5);
let di = rbc.deformability_index();
assert!((0.0..=1.0).contains(&di), "DI should be in [0,1], got {di}");
}
#[test]
fn test_rbc_bc_coupling_energy_non_negative() {
let rbc = RedBloodCellModel::new(4e-6, 6, 2e-19, 1e-5, 1e-5);
let e = rbc.bc_coupling_energy();
assert!(e >= 0.0, "BC coupling energy must be non-negative: {e}");
}
#[test]
fn test_saffman_delbruck_positive() {
let mfd = MembraneFluidDynamics::new(1e-9, 1e-3, 4e-9, 4.28e-21);
let d = mfd.saffman_delbruck_d(5e-9);
assert!(d > 0.0, "diffusion coefficient should be positive: {d}");
}
#[test]
fn test_saffman_delbruck_larger_protein_slower() {
let mfd = MembraneFluidDynamics::new(1.0, 1e-10, 4e-9, 4.28e-21);
let d_small = mfd.saffman_delbruck_d(1e-9);
let d_large = mfd.saffman_delbruck_d(1e-7);
assert!(
d_small > d_large,
"smaller protein should diffuse faster (d_small={d_small}, d_large={d_large})"
);
}
#[test]
fn test_msd_linear_in_time() {
let mfd = MembraneFluidDynamics::new(1e-9, 1e-3, 4e-9, 4.28e-21);
let msd1 = mfd.msd(5e-9, 1.0);
let msd2 = mfd.msd(5e-9, 2.0);
assert!(
(msd2 / msd1 - 2.0).abs() < 1e-9,
"MSD should be linear in t"
);
}
#[test]
fn test_oseen_tensor_decays_with_distance() {
let mfd = MembraneFluidDynamics::new(1e-9, 1e-3, 4e-9, 4.28e-21);
let t1 = mfd.oseen_tensor_2d(1e-7);
let t2 = mfd.oseen_tensor_2d(1e-6);
assert!(t1 > t2, "Oseen tensor should decay with distance");
}
#[test]
fn test_stokeslet_velocity_zero_force_zero_velocity() {
let mfd = MembraneFluidDynamics::new(1e-9, 1e-3, 4e-9, 4.28e-21);
let v = mfd.stokeslet_velocity(1e-7, 0.0, [0.0, 0.0]);
assert!(v[0].abs() < 1e-30 && v[1].abs() < 1e-30);
}
#[test]
fn test_rotational_diffusion_positive() {
let mfd = MembraneFluidDynamics::new(1e-9, 1e-3, 4e-9, 4.28e-21);
let dr = mfd.rotational_diffusion_d(5e-9);
assert!(dr > 0.0, "rotational diffusion should be positive: {dr}");
}
#[test]
fn test_protein_inclusion_energy_at_preferred_curvature_is_zero() {
let p = MembraneProtein::new(0, [0.0, 0.0], 1e6, 5e-9, 1e-20);
let e = p.inclusion_energy_at(1e6);
assert!(
e.abs() < 1e-40,
"energy at preferred curvature should be ~0: {e}"
);
}
#[test]
fn test_protein_inclusion_energy_increases_away_from_preferred() {
let p = MembraneProtein::new(0, [0.0, 0.0], 1e6, 5e-9, 1e-20);
let e0 = p.inclusion_energy_at(1e6);
let e1 = p.inclusion_energy_at(2e6);
assert!(e1 > e0);
}
#[test]
fn test_protein_update_energy() {
let mut p = MembraneProtein::new(0, [0.0, 0.0], 0.0, 5e-9, 1e-20);
p.update_energy(1e6);
assert!(p.inclusion_energy > 0.0);
}
#[test]
fn test_protein_sensing_force_non_zero_with_gradient() {
let p = MembraneProtein::new(0, [0.0, 0.0], 2e6, 5e-9, 1e-20);
let f = p.sensing_force([1.0, 0.0]);
assert!(
f[0].abs() > 1e-30 || f[1].abs() > 1e-30,
"force should be non-zero"
);
}
#[test]
fn test_protein_cluster_centroid() {
let proteins = vec![
MembraneProtein::new(0, [0.0, 0.0], 0.0, 1e-9, 1e-20),
MembraneProtein::new(1, [2.0, 0.0], 0.0, 1e-9, 1e-20),
];
let cluster = ProteinCluster::from_proteins(&proteins, vec![0, 1]);
assert!(
(cluster.centroid[0] - 1.0).abs() < 1e-9,
"centroid x should be 1"
);
}
#[test]
fn test_protein_cluster_should_join() {
let proteins = vec![MembraneProtein::new(0, [0.0, 0.0], 0.0, 5e-9, 1e-20)];
let cluster = ProteinCluster::from_proteins(&proteins, vec![0]);
assert!(cluster.should_join([0.0, 0.0]));
}
#[test]
fn test_vesicle_creates_without_panic() {
let _ = VesicleSimulation::new_sphere(5e-6, 2e-19, 240e-3, 1e10);
}
#[test]
fn test_vesicle_total_energy_non_negative() {
let vesicle = VesicleSimulation::new_sphere(5e-6, 2e-19, 240e-3, 1e10);
let e = vesicle.total_energy();
assert!(e >= 0.0, "total vesicle energy should be non-negative: {e}");
}
#[test]
fn test_vesicle_reduced_volume_positive() {
let mut vesicle = VesicleSimulation::new_sphere(5e-6, 2e-19, 240e-3, 1e10);
vesicle.bilayer.assign_curvatures(0.0, 0.0);
let rv = vesicle.reduced_volume();
assert!(
rv >= 0.0 && rv.is_finite(),
"reduced volume should be non-negative: {rv}"
);
}
#[test]
fn test_vesicle_shape_class_returns_string() {
let vesicle = VesicleSimulation::new_sphere(5e-6, 2e-19, 240e-3, 1e10);
let s = vesicle.shape_class();
assert!(!s.is_empty());
}
#[test]
fn test_vesicle_step_advances_time() {
let mut vesicle = VesicleSimulation::new_sphere(5e-6, 2e-19, 240e-3, 1e10);
vesicle.step(1e-6);
assert!((vesicle.time - 1e-6).abs() < 1e-20, "time should advance");
}
#[test]
fn test_vesicle_volume_energy_positive_when_compressed() {
let mut vesicle = VesicleSimulation::new_sphere(5e-6, 2e-19, 240e-3, 1e13);
for v in &mut vesicle.bilayer.vertices {
v.position = scale3(v.position, 0.5);
}
let e = vesicle.volume_energy();
assert!(
e > 0.0,
"compressed vesicle should have positive volume energy: {e}"
);
}
#[test]
fn test_afm_hertz_force_positive_for_positive_indentation() {
let afm = AfmIndentation::new(1e-6, 1000.0, 0.4);
let f = afm.hertz_force(1e-7);
assert!(f > 0.0, "Hertz force should be positive: {f}");
}
#[test]
fn test_afm_hertz_force_zero_for_zero_indentation() {
let afm = AfmIndentation::new(1e-6, 1000.0, 0.4);
let f = afm.hertz_force(0.0);
assert!(f.abs() < 1e-30);
}
#[test]
fn test_afm_hertz_force_increases_with_indentation() {
let afm = AfmIndentation::new(1e-6, 1000.0, 0.4);
let f1 = afm.hertz_force(1e-8);
let f2 = afm.hertz_force(2e-8);
assert!(f2 > f1, "deeper indentation should give larger force");
}
#[test]
fn test_afm_infer_young_modulus() {
let afm = AfmIndentation::new(1e-6, 1000.0, 0.4);
let delta = 1e-7;
let f = afm.hertz_force(delta);
let e_inferred = afm.infer_young_modulus(f, delta);
assert!(
(e_inferred - 1000.0).abs() < 1.0,
"inferred E should match: {e_inferred}"
);
}
#[test]
fn test_cell_mechanics_creates() {
let cm = CellMechanics::new(4e-6, 1000.0, 0.4, 1e-5, 6);
assert!(cm.spring_constant > 0.0);
}
#[test]
fn test_cell_mechanics_osmotic_pressure() {
let cm = CellMechanics::new(4e-6, 1000.0, 0.4, 1e-5, 6);
let p = cm.osmotic_pressure(1.0, 310.0); assert!((p - 8.314 * 310.0).abs() < 0.01);
}
#[test]
fn test_cell_mechanics_update_tension_from_pressure() {
let mut cm = CellMechanics::new(4e-6, 1000.0, 0.4, 1e-5, 6);
cm.turgor_pressure = 1000.0;
cm.update_tension_from_pressure();
assert!((cm.membrane_tension - 1000.0 * 4e-6 / 2.0).abs() < 1e-15);
}
#[test]
fn test_cell_mechanics_lysolipid_softening() {
let mut cm = CellMechanics::new(4e-6, 1000.0, 0.4, 1e-5, 6);
let e0 = cm.afm.young_modulus;
cm.apply_lysolipid_softening(0.5);
assert!(cm.afm.young_modulus < e0);
}
#[test]
fn test_cell_mechanics_deformability_index_in_range() {
let cm = CellMechanics::new(4e-6, 1000.0, 0.4, 1e-5, 8);
let di = cm.deformability_index();
assert!((0.0..=1.0).contains(&di));
}
#[test]
fn test_sphere_area_unit_sphere() {
let a = sphere_area(1.0);
assert!((a - 4.0 * PI).abs() < 1e-10);
}
#[test]
fn test_sphere_volume_unit_sphere() {
let v = sphere_volume(1.0);
assert!((v - (4.0 / 3.0) * PI).abs() < 1e-10);
}
#[test]
fn test_rms_deviation_from_sphere_exact_sphere() {
let r = 1.0;
let vertices: Vec<MembraneVertex> = (0..8)
.map(|i| {
let phi = 2.0 * PI * i as f64 / 8.0;
MembraneVertex::new([r * phi.cos(), r * phi.sin(), 0.0])
})
.collect();
let rms = rms_deviation_from_sphere(&vertices, r);
assert!(rms.abs() < 1e-10, "rms={rms}");
}
#[test]
fn test_asphericity_symmetric_config() {
let vertices: Vec<MembraneVertex> = vec![
MembraneVertex::new([1.0, 0.0, 0.0]),
MembraneVertex::new([-1.0, 0.0, 0.0]),
MembraneVertex::new([0.0, 1.0, 0.0]),
MembraneVertex::new([0.0, -1.0, 0.0]),
MembraneVertex::new([0.0, 0.0, 1.0]),
MembraneVertex::new([0.0, 0.0, -1.0]),
];
let a = asphericity(&vertices);
assert!(a < 1.0, "symmetric config should have low asphericity: {a}");
}
#[test]
fn test_triangle_area_right_triangle() {
let p0 = [0.0, 0.0, 0.0];
let p1 = [1.0, 0.0, 0.0];
let p2 = [0.0, 1.0, 0.0];
let a = triangle_area(p0, p1, p2);
assert!((a - 0.5).abs() < 1e-10, "area={a}");
}
#[test]
fn test_mean_curvature_laplace_flat_surface() {
let v = [0.0, 0.0, 0.0];
let ring: Vec<Vec3> = (0..6)
.map(|i| {
let theta = 2.0 * PI * i as f64 / 6.0;
[theta.cos(), theta.sin(), 0.0]
})
.collect();
let areas = vec![PI / 6.0; 6];
let h = mean_curvature_laplace(v, &ring, &areas);
assert!(
h.abs() < 1e-5,
"flat surface should have ~0 mean curvature: {h}"
);
}
#[test]
fn test_vesicle_step_willmore_flow_finite() {
let radius = 1e-6_f64;
let kappa = 1.0; let k_area = 1e3; let k_vol = 0.0;
let mut vesicle = VesicleSimulation::new_sphere(radius, kappa, k_area, k_vol);
let n = vesicle.bilayer.vertices.len();
for i in (0..n).step_by(4) {
vesicle.bilayer.vertices[i].position[2] += 0.05 * radius;
}
vesicle.bilayer.update_normals();
vesicle
.bilayer
.assign_curvatures(1.0 / radius, 1.0 / (radius * radius));
let e0 = vesicle.bilayer.bending_energy();
assert!(
e0.is_finite(),
"initial bending energy must be finite: {e0}"
);
let dt = 1e-15_f64; for step_idx in 0..10 {
vesicle.step(dt);
let e = vesicle.bilayer.bending_energy();
assert!(
e.is_finite(),
"bending energy went non-finite at step {step_idx}: {e}"
);
}
let any_moved = vesicle.bilayer.vertices.iter().enumerate().any(|(i, v)| {
let moved_z = (v.position[2]).abs() > 1e-20;
let i_div_4 = i % 4 == 0;
moved_z || i_div_4 });
assert!(
any_moved,
"some vertices should have non-zero z after perturbation"
);
}
#[test]
fn test_vesicle_step_bending_energy_no_increase_flat() {
let radius = 1e-6_f64;
let kappa = 1.0;
let k_area = 0.0;
let k_vol = 0.0;
let mut vesicle = VesicleSimulation::new_sphere(radius, kappa, k_area, k_vol);
vesicle.bilayer.assign_curvatures(0.0, 0.0);
let e0 = vesicle.bilayer.bending_energy();
assert!(e0.abs() < 1e-30, "flat mesh has zero bending energy: {e0}");
let dt = 1e-12_f64;
for _ in 0..10 {
vesicle.step(dt);
}
let e1 = vesicle.bilayer.bending_energy();
assert!(e1.abs() < 1e-30, "flat mesh should remain at zero: {e1}");
}
}