#![allow(clippy::needless_range_loop)]
#![allow(dead_code)]
#![allow(clippy::too_many_arguments)]
#[inline]
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
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],
]
}
#[inline]
fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
#[inline]
fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
#[inline]
fn norm3(a: [f64; 3]) -> f64 {
dot3(a, a).sqrt()
}
#[inline]
fn normalize3(a: [f64; 3]) -> [f64; 3] {
let n = norm3(a);
if n < 1e-300 {
[0.0; 3]
} else {
scale3(a, 1.0 / n)
}
}
#[inline]
fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
v.max(lo).min(hi)
}
pub const HAPTIC_MIN_FREQUENCY_HZ: f64 = 500.0;
pub const PHANTOM_MAX_FORCE_N: f64 = 8.5;
pub const HAPTIC_LOOP_DT: f64 = 1.0e-3;
#[derive(Debug, Clone)]
pub struct ProxyState {
pub position: [f64; 3],
pub velocity: [f64; 3],
pub surface_normal: [f64; 3],
pub penetration_depth: f64,
pub in_contact: bool,
}
impl ProxyState {
pub fn new(position: [f64; 3]) -> Self {
Self {
position,
velocity: [0.0; 3],
surface_normal: [0.0, 1.0, 0.0],
penetration_depth: 0.0,
in_contact: false,
}
}
pub fn update(
&mut self,
cursor_pos: [f64; 3],
contact_point: [f64; 3],
contact_normal: [f64; 3],
dt: f64,
) {
let prev = self.position;
let d = dot3(sub3(cursor_pos, contact_point), contact_normal);
if d < 0.0 {
self.position = add3(cursor_pos, scale3(contact_normal, -d));
self.surface_normal = contact_normal;
self.penetration_depth = (-d).max(0.0);
self.in_contact = true;
} else {
self.position = cursor_pos;
self.penetration_depth = 0.0;
self.in_contact = false;
}
if dt > 1e-12 {
self.velocity = scale3(sub3(self.position, prev), 1.0 / dt);
}
}
}
#[derive(Debug, Clone)]
pub struct HapticCursor {
pub position: [f64; 3],
pub velocity: [f64; 3],
pub feedback_force: [f64; 3],
}
impl HapticCursor {
pub fn new(position: [f64; 3]) -> Self {
Self {
position,
velocity: [0.0; 3],
feedback_force: [0.0; 3],
}
}
pub fn set_state(&mut self, position: [f64; 3], velocity: [f64; 3]) {
self.position = position;
self.velocity = velocity;
}
}
#[derive(Debug, Clone)]
pub struct ContactForceModel {
pub stiffness: f64,
pub damping: f64,
pub max_force: f64,
}
impl ContactForceModel {
pub fn new(stiffness: f64, damping: f64, max_force: f64) -> Self {
Self {
stiffness,
damping,
max_force,
}
}
pub fn compute_force(&self, proxy: &ProxyState) -> [f64; 3] {
if !proxy.in_contact {
return [0.0; 3];
}
let n = proxy.surface_normal;
let f_spring = scale3(n, self.stiffness * proxy.penetration_depth);
let v_normal = dot3(proxy.velocity, n);
let f_damp = scale3(n, -self.damping * v_normal);
let f_total = add3(f_spring, f_damp);
let mag = norm3(f_total);
if mag > self.max_force {
scale3(f_total, self.max_force / mag)
} else {
f_total
}
}
pub fn local_stiffness(&self) -> f64 {
self.stiffness
}
}
#[derive(Debug, Clone)]
pub struct VirtualCoupling {
pub stiffness: f64,
pub damping: f64,
pub tool_position: [f64; 3],
pub tool_velocity: [f64; 3],
}
impl VirtualCoupling {
pub fn new(stiffness: f64, damping: f64) -> Self {
Self {
stiffness,
damping,
tool_position: [0.0; 3],
tool_velocity: [0.0; 3],
}
}
pub fn coupling_force(&self, device_pos: [f64; 3], device_vel: [f64; 3]) -> [f64; 3] {
let dp = sub3(device_pos, self.tool_position);
let dv = sub3(device_vel, self.tool_velocity);
add3(scale3(dp, self.stiffness), scale3(dv, self.damping))
}
pub fn integrate_tool(
&mut self,
device_pos: [f64; 3],
device_vel: [f64; 3],
m_tool: f64,
dt: f64,
) {
let f = self.coupling_force(device_pos, device_vel);
let accel = scale3(f, 1.0 / m_tool);
self.tool_velocity = add3(self.tool_velocity, scale3(accel, dt));
self.tool_position = add3(self.tool_position, scale3(self.tool_velocity, dt));
}
pub fn max_stable_stiffness(m_tool: f64, dt: f64) -> f64 {
2.0 * m_tool / (dt * dt)
}
}
#[derive(Debug, Clone)]
pub struct PhantomHapticDevice {
pub workspace: [f64; 3],
pub max_force: f64,
pub max_torque: f64,
pub position: [f64; 3],
pub velocity: [f64; 3],
pub orientation: [f64; 3],
pub gravity_compensation: bool,
pub stylus_mass: f64,
}
impl PhantomHapticDevice {
pub fn new() -> Self {
Self {
workspace: [0.15, 0.15, 0.15],
max_force: PHANTOM_MAX_FORCE_N,
max_torque: 0.188,
position: [0.0; 3],
velocity: [0.0; 3],
orientation: [0.0; 3],
gravity_compensation: true,
stylus_mass: 0.050,
}
}
pub fn update_state(&mut self, position: [f64; 3], velocity: [f64; 3], orientation: [f64; 3]) {
for i in 0..3 {
self.position[i] = clamp(position[i], -self.workspace[i], self.workspace[i]);
}
self.velocity = velocity;
self.orientation = orientation;
}
pub fn gravity_compensation_force(&self) -> [f64; 3] {
if self.gravity_compensation {
[0.0, self.stylus_mass * 9.81, 0.0]
} else {
[0.0; 3]
}
}
pub fn saturate_force(&self, force: [f64; 3]) -> [f64; 3] {
let mag = norm3(force);
if mag > self.max_force {
scale3(force, self.max_force / mag)
} else {
force
}
}
pub fn in_workspace(&self, pos: [f64; 3]) -> bool {
pos[0].abs() <= self.workspace[0]
&& pos[1].abs() <= self.workspace[1]
&& pos[2].abs() <= self.workspace[2]
}
}
impl Default for PhantomHapticDevice {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct NeedleInsertionModel {
pub pre_puncture_stiffness: f64,
pub puncture_force: f64,
pub cutting_stiffness: f64,
pub friction_coeff: f64,
pub needle_diameter: f64,
pub insertion_depth: f64,
pub punctured: bool,
pub puncture_depth: f64,
}
impl NeedleInsertionModel {
pub fn new(
pre_puncture_stiffness: f64,
puncture_force: f64,
cutting_stiffness: f64,
friction_coeff: f64,
needle_diameter: f64,
) -> Self {
Self {
pre_puncture_stiffness,
puncture_force,
cutting_stiffness,
friction_coeff,
needle_diameter,
insertion_depth: 0.0,
punctured: false,
puncture_depth: 0.0,
}
}
pub fn axial_force(&self) -> f64 {
if !self.punctured {
self.pre_puncture_stiffness * self.insertion_depth
} else {
let depth_beyond = (self.insertion_depth - self.puncture_depth).max(0.0);
let f_cut = self.cutting_stiffness * depth_beyond;
let circumference = std::f64::consts::PI * self.needle_diameter;
let f_friction = self.friction_coeff * circumference * depth_beyond * 1000.0; f_cut + f_friction
}
}
pub fn advance(&mut self, delta: f64) {
self.insertion_depth += delta.max(0.0);
if !self.punctured {
let f = self.pre_puncture_stiffness * self.insertion_depth;
if f >= self.puncture_force {
self.punctured = true;
self.puncture_depth = self.insertion_depth;
}
}
}
pub fn retract(&mut self, delta: f64) {
self.insertion_depth = (self.insertion_depth - delta.max(0.0)).max(0.0);
if self.insertion_depth < 1e-6 {
self.punctured = false;
self.puncture_depth = 0.0;
}
}
pub fn lateral_force(&self, lateral_deflection: f64) -> f64 {
0.1 * self.pre_puncture_stiffness * lateral_deflection
}
}
#[derive(Debug, Clone)]
pub struct DeformableTissueHaptics {
pub nx: usize,
pub ny: usize,
pub youngs_modulus: f64,
pub viscosity: f64,
pub node_spacing: f64,
pub displacements: Vec<f64>,
pub velocities: Vec<f64>,
pub rest_positions: Vec<[f64; 2]>,
}
impl DeformableTissueHaptics {
pub fn new(
nx: usize,
ny: usize,
node_spacing: f64,
youngs_modulus: f64,
viscosity: f64,
) -> Self {
let n = nx * ny;
let mut rest_positions = Vec::with_capacity(n);
for j in 0..ny {
for i in 0..nx {
rest_positions.push([i as f64 * node_spacing, j as f64 * node_spacing]);
}
}
Self {
nx,
ny,
youngs_modulus,
viscosity,
node_spacing,
displacements: vec![0.0; n],
velocities: vec![0.0; n],
rest_positions,
}
}
pub fn apply_point_load(&mut self, px: f64, py: f64, force_z: f64) {
let inv_h = 1.0 / self.node_spacing;
let ix = (px * inv_h).floor() as isize;
let iy = (py * inv_h).floor() as isize;
let tx = (px * inv_h) - ix as f64;
let ty = (py * inv_h) - iy as f64;
let nx = self.nx as isize;
let ny = self.ny as isize;
let corners = [
(ix, iy, (1.0 - tx) * (1.0 - ty)),
(ix + 1, iy, tx * (1.0 - ty)),
(ix, iy + 1, (1.0 - tx) * ty),
(ix + 1, iy + 1, tx * ty),
];
let area = self.node_spacing * self.node_spacing;
for (ci, cj, w) in corners {
if ci >= 0 && ci < nx && cj >= 0 && cj < ny {
let idx = cj as usize * self.nx + ci as usize;
let pressure = force_z * w / area;
let stiffness = self.youngs_modulus / self.node_spacing;
self.displacements[idx] += pressure / stiffness;
}
}
}
pub fn step(&mut self, dt: f64) {
let tau = self.viscosity / self.youngs_modulus;
let decay = (-dt / tau).exp();
for i in 0..self.displacements.len() {
self.displacements[i] *= decay;
self.velocities[i] = self.displacements[i] * (decay - 1.0) / dt;
}
}
pub fn surface_height(&self, px: f64, py: f64) -> f64 {
let inv_h = 1.0 / self.node_spacing;
let ix = (px * inv_h).floor() as isize;
let iy = (py * inv_h).floor() as isize;
let tx = (px * inv_h) - ix as f64;
let ty = (py * inv_h) - iy as f64;
let nx = self.nx as isize;
let ny = self.ny as isize;
let corners = [
(ix, iy, (1.0 - tx) * (1.0 - ty)),
(ix + 1, iy, tx * (1.0 - ty)),
(ix, iy + 1, (1.0 - tx) * ty),
(ix + 1, iy + 1, tx * ty),
];
let mut h = 0.0;
for (ci, cj, w) in corners {
if ci >= 0 && ci < nx && cj >= 0 && cj < ny {
let idx = cj as usize * self.nx + ci as usize;
h += w * self.displacements[idx];
}
}
h
}
}
#[derive(Debug, Clone)]
pub struct VirtualFixture {
pub fixture_type: FixtureType,
pub stiffness: f64,
pub damping: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FixtureType {
ForbiddenHalfSpace {
point: [f64; 3],
normal: [f64; 3],
},
LinearGuide {
origin: [f64; 3],
direction: [f64; 3],
},
PlanarGuide {
origin: [f64; 3],
normal: [f64; 3],
},
}
impl VirtualFixture {
pub fn new(fixture_type: FixtureType, stiffness: f64, damping: f64) -> Self {
Self {
fixture_type,
stiffness,
damping,
}
}
pub fn constraint_force(&self, pos: [f64; 3], vel: [f64; 3]) -> [f64; 3] {
match &self.fixture_type {
FixtureType::ForbiddenHalfSpace { point, normal } => {
let d = dot3(sub3(pos, *point), *normal);
if d > 0.0 {
let f_spring = scale3(*normal, -self.stiffness * d);
let v_n = dot3(vel, *normal);
let f_damp = scale3(*normal, -self.damping * v_n);
add3(f_spring, f_damp)
} else {
[0.0; 3]
}
}
FixtureType::LinearGuide { origin, direction } => {
let dp = sub3(pos, *origin);
let along = dot3(dp, *direction);
let on_line = add3(*origin, scale3(*direction, along));
let error = sub3(pos, on_line);
let v_perp = sub3(vel, scale3(*direction, dot3(vel, *direction)));
let f_spring = scale3(error, -self.stiffness);
let f_damp = scale3(v_perp, -self.damping);
add3(f_spring, f_damp)
}
FixtureType::PlanarGuide { origin, normal } => {
let d = dot3(sub3(pos, *origin), *normal);
let f_spring = scale3(*normal, -self.stiffness * d);
let v_n = dot3(vel, *normal);
let f_damp = scale3(*normal, -self.damping * v_n);
add3(f_spring, f_damp)
}
}
}
}
#[derive(Debug, Clone)]
pub struct LatencyCompensator {
pub latency: f64,
history: Vec<(f64, [f64; 3])>,
max_history: usize,
}
impl LatencyCompensator {
pub fn new(latency: f64) -> Self {
Self {
latency,
history: Vec::with_capacity(64),
max_history: 64,
}
}
pub fn record(&mut self, t: f64, pos: [f64; 3]) {
if self.history.len() >= self.max_history {
self.history.remove(0);
}
self.history.push((t, pos));
}
pub fn predict(&self, t: f64) -> [f64; 3] {
if self.history.len() < 2 {
return self.history.last().map(|e| e.1).unwrap_or([0.0; 3]);
}
let n = self.history.len();
let (t1, p1) = self.history[n - 2];
let (t2, p2) = self.history[n - 1];
let dt_hist = t2 - t1;
if dt_hist < 1e-12 {
return p2;
}
let vel = scale3(sub3(p2, p1), 1.0 / dt_hist);
let dt_pred = (t + self.latency) - t2;
add3(p2, scale3(vel, dt_pred))
}
}
#[derive(Debug, Clone)]
pub struct MultiPointHapticContact {
pub contacts: Vec<ContactPoint>,
pub max_combined_force: f64,
}
#[derive(Debug, Clone)]
pub struct ContactPoint {
pub id: usize,
pub stiffness: f64,
pub damping: f64,
pub penetration: f64,
pub normal: [f64; 3],
pub contact_velocity: [f64; 3],
pub active: bool,
}
impl ContactPoint {
pub fn new(id: usize, stiffness: f64, damping: f64) -> Self {
Self {
id,
stiffness,
damping,
penetration: 0.0,
normal: [0.0, 1.0, 0.0],
contact_velocity: [0.0; 3],
active: false,
}
}
pub fn force(&self) -> [f64; 3] {
if !self.active || self.penetration <= 0.0 {
return [0.0; 3];
}
let f_spring = scale3(self.normal, self.stiffness * self.penetration);
let v_n = dot3(self.contact_velocity, self.normal);
let f_damp = scale3(self.normal, -self.damping * v_n);
add3(f_spring, f_damp)
}
}
impl MultiPointHapticContact {
pub fn new(max_combined_force: f64) -> Self {
Self {
contacts: Vec::new(),
max_combined_force,
}
}
pub fn add_contact(&mut self, cp: ContactPoint) {
self.contacts.push(cp);
}
pub fn total_force(&self) -> [f64; 3] {
let mut total = [0.0; 3];
for cp in &self.contacts {
total = add3(total, cp.force());
}
let mag = norm3(total);
if mag > self.max_combined_force {
scale3(total, self.max_combined_force / mag)
} else {
total
}
}
pub fn active_count(&self) -> usize {
self.contacts.iter().filter(|c| c.active).count()
}
}
#[derive(Debug, Clone)]
pub struct HapticRenderingLoop {
pub cursor: HapticCursor,
pub proxy: ProxyState,
pub contact_model: ContactForceModel,
pub coupling: VirtualCoupling,
pub time: f64,
pub dt: f64,
pub iteration: u64,
}
impl HapticRenderingLoop {
pub fn new(
stiffness: f64,
damping: f64,
coupling_stiffness: f64,
coupling_damping: f64,
dt: f64,
) -> Self {
Self {
cursor: HapticCursor::new([0.0; 3]),
proxy: ProxyState::new([0.0; 3]),
contact_model: ContactForceModel::new(stiffness, damping, PHANTOM_MAX_FORCE_N),
coupling: VirtualCoupling::new(coupling_stiffness, coupling_damping),
time: 0.0,
dt,
iteration: 0,
}
}
pub fn tick(
&mut self,
device_pos: [f64; 3],
device_vel: [f64; 3],
contact_point: [f64; 3],
contact_normal: [f64; 3],
) -> [f64; 3] {
self.cursor.set_state(device_pos, device_vel);
self.proxy
.update(device_pos, contact_point, contact_normal, self.dt);
let f_contact = self.contact_model.compute_force(&self.proxy);
let f_coupling = self.coupling.coupling_force(device_pos, device_vel);
let f_total = add3(f_contact, f_coupling);
self.cursor.feedback_force = self.contact_model.compute_force(&self.proxy); self.time += self.dt;
self.iteration += 1;
f_total
}
}
pub struct HapticFrequencyAnalysis;
impl HapticFrequencyAnalysis {
pub fn min_stable_frequency(surface_stiffness: f64, device_mass: f64) -> f64 {
let omega = (surface_stiffness / device_mass).sqrt();
omega / (2.0 * std::f64::consts::PI)
}
pub fn max_renderable_stiffness(frequency_hz: f64, device_mass: f64) -> f64 {
let omega = 2.0 * std::f64::consts::PI * frequency_hz;
device_mass * omega * omega
}
pub fn required_dt(surface_stiffness: f64, device_mass: f64) -> f64 {
let f_min = Self::min_stable_frequency(surface_stiffness, device_mass);
if f_min < 1e-6 {
1.0
} else {
1.0 / (2.0 * f_min)
}
}
}
#[derive(Debug, Clone)]
pub struct TissueLayerModel {
pub layers: Vec<TissueLayer>,
}
#[derive(Debug, Clone)]
pub struct TissueLayer {
pub name: &'static str,
pub thickness: f64,
pub youngs_modulus: f64,
pub viscosity: f64,
pub compression: f64,
}
impl TissueLayer {
pub fn new(name: &'static str, thickness: f64, youngs_modulus: f64, viscosity: f64) -> Self {
Self {
name,
thickness,
youngs_modulus,
viscosity,
compression: 0.0,
}
}
pub fn reaction_force(&self, delta: f64) -> f64 {
let strain = (delta / self.thickness).min(1.0);
let k_eff = self.youngs_modulus * (1.0 + 10.0 * strain * strain);
k_eff * delta
}
}
impl TissueLayerModel {
pub fn new(layers: Vec<TissueLayer>) -> Self {
Self { layers }
}
pub fn default_anatomical() -> Self {
Self {
layers: vec![
TissueLayer::new("skin", 0.002, 100_000.0, 50.0),
TissueLayer::new("fat", 0.020, 5_000.0, 20.0),
TissueLayer::new("muscle", 0.030, 50_000.0, 200.0),
TissueLayer::new("organ", 0.050, 20_000.0, 100.0),
],
}
}
pub fn total_reaction_force(&self, total_depth: f64) -> f64 {
let mut remaining = total_depth;
let mut force = 0.0;
for layer in &self.layers {
if remaining <= 0.0 {
break;
}
let delta = remaining.min(layer.thickness);
force += layer.reaction_force(delta);
remaining -= delta;
}
force
}
pub fn depth_in_layer(&self, total_depth: f64) -> (usize, f64) {
let mut remaining = total_depth;
for (i, layer) in self.layers.iter().enumerate() {
if remaining <= layer.thickness {
return (i, remaining);
}
remaining -= layer.thickness;
}
(self.layers.len().saturating_sub(1), remaining)
}
}
#[derive(Debug, Clone, Default)]
pub struct HapticSimulationStats {
pub tick_count: u64,
pub max_force_n: f64,
pub avg_force_n: f64,
pub clamp_events: u64,
pub elapsed_time: f64,
}
impl HapticSimulationStats {
pub fn record_force(&mut self, force_mag: f64, clamped: bool) {
self.tick_count += 1;
if force_mag > self.max_force_n {
self.max_force_n = force_mag;
}
self.avg_force_n += (force_mag - self.avg_force_n) / self.tick_count as f64;
if clamped {
self.clamp_events += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
#[test]
fn test_dot3() {
let a = [1.0, 2.0, 3.0];
let b = [4.0, 5.0, 6.0];
assert!((dot3(a, b) - 32.0).abs() < EPS);
}
#[test]
fn test_cross3() {
let i = [1.0_f64, 0.0, 0.0];
let j = [0.0_f64, 1.0, 0.0];
let k = cross3(i, j);
assert!(k[0].abs() < EPS && k[1].abs() < EPS && (k[2] - 1.0).abs() < EPS);
}
#[test]
fn test_normalize3_unit_length() {
let v = [3.0_f64, 4.0, 0.0];
let u = normalize3(v);
let n = norm3(u);
assert!((n - 1.0).abs() < EPS, "norm={n}");
}
#[test]
fn test_normalize3_zero_returns_zero() {
let v = [0.0_f64; 3];
let u = normalize3(v);
assert_eq!(u, [0.0; 3]);
}
#[test]
fn test_proxy_no_penetration() {
let mut proxy = ProxyState::new([0.0; 3]);
proxy.update([0.0, 0.1, 0.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.001);
assert!(!proxy.in_contact, "should not be in contact");
assert!(proxy.penetration_depth < EPS);
}
#[test]
fn test_proxy_penetration_detected() {
let mut proxy = ProxyState::new([0.0; 3]);
proxy.update([0.0, -0.005, 0.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.001);
assert!(proxy.in_contact, "should be in contact");
assert!(
(proxy.penetration_depth - 0.005).abs() < 1e-6,
"depth={}",
proxy.penetration_depth
);
}
#[test]
fn test_proxy_position_clamped_to_surface() {
let mut proxy = ProxyState::new([0.0; 3]);
proxy.update([0.0, -0.01, 0.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.001);
assert!(
proxy.position[1].abs() < EPS,
"proxy y={}",
proxy.position[1]
);
}
#[test]
fn test_contact_force_no_contact() {
let model = ContactForceModel::new(1000.0, 10.0, 8.5);
let proxy = ProxyState::new([0.0; 3]);
let f = model.compute_force(&proxy);
assert_eq!(f, [0.0; 3]);
}
#[test]
fn test_contact_force_proportional_to_penetration() {
let model = ContactForceModel::new(1000.0, 0.0, 100.0);
let mut proxy = ProxyState::new([0.0; 3]);
proxy.in_contact = true;
proxy.surface_normal = [0.0, 1.0, 0.0];
proxy.penetration_depth = 0.005;
proxy.velocity = [0.0; 3];
let f = model.compute_force(&proxy);
assert!((f[1] - 5.0).abs() < EPS, "f_y={}", f[1]);
}
#[test]
fn test_contact_force_clamped_to_max() {
let model = ContactForceModel::new(100_000.0, 0.0, 8.5);
let mut proxy = ProxyState::new([0.0; 3]);
proxy.in_contact = true;
proxy.surface_normal = [0.0, 1.0, 0.0];
proxy.penetration_depth = 1.0; proxy.velocity = [0.0; 3];
let f = model.compute_force(&proxy);
let mag = norm3(f);
assert!(
(mag - 8.5).abs() < 1e-6,
"should be clamped to 8.5 N, got {mag}"
);
}
#[test]
fn test_virtual_coupling_zero_error_zero_force() {
let vc = VirtualCoupling::new(500.0, 10.0);
let f = vc.coupling_force([0.0; 3], [0.0; 3]);
assert_eq!(f, [0.0; 3]);
}
#[test]
fn test_virtual_coupling_spring_force() {
let vc = VirtualCoupling::new(500.0, 0.0);
let f = vc.coupling_force([0.01, 0.0, 0.0], [0.0; 3]);
assert!((f[0] - 5.0).abs() < EPS, "f_x={}", f[0]);
}
#[test]
fn test_virtual_coupling_max_stable_stiffness() {
let k_max = VirtualCoupling::max_stable_stiffness(0.05, 0.001);
let expected = 2.0 * 0.05 / (0.001 * 0.001);
assert!((k_max - expected).abs() < EPS);
}
#[test]
fn test_virtual_coupling_integrate_tool_moves() {
let mut vc = VirtualCoupling::new(1000.0, 5.0);
let init_pos = vc.tool_position;
vc.integrate_tool([0.01, 0.0, 0.0], [0.0; 3], 0.05, 0.001);
assert!(
norm3(sub3(vc.tool_position, init_pos)) > 0.0,
"tool should have moved"
);
}
#[test]
fn test_phantom_in_workspace() {
let device = PhantomHapticDevice::new();
assert!(device.in_workspace([0.0; 3]));
assert!(device.in_workspace([0.14, 0.14, 0.14]));
assert!(!device.in_workspace([0.2, 0.0, 0.0]));
}
#[test]
fn test_phantom_saturate_force() {
let device = PhantomHapticDevice::new();
let f_big = [100.0, 0.0, 0.0];
let f_sat = device.saturate_force(f_big);
let mag = norm3(f_sat);
assert!((mag - PHANTOM_MAX_FORCE_N).abs() < 1e-6, "mag={mag}");
}
#[test]
fn test_phantom_gravity_compensation_enabled() {
let device = PhantomHapticDevice::new();
let f_gc = device.gravity_compensation_force();
assert!(f_gc[1] > 0.0, "gravity comp should push up");
}
#[test]
fn test_needle_pre_puncture_linear() {
let mut needle = NeedleInsertionModel::new(5000.0, 2.0, 100.0, 0.3, 0.001);
needle.advance(0.0002); assert!(!needle.punctured);
let f = needle.axial_force();
assert!((f - 5000.0 * 0.0002).abs() < 1e-6);
}
#[test]
fn test_needle_puncture_event() {
let mut needle = NeedleInsertionModel::new(5000.0, 0.5, 100.0, 0.3, 0.001);
needle.advance(0.001); assert!(needle.punctured, "needle should have punctured");
}
#[test]
fn test_needle_retract_resets_puncture() {
let mut needle = NeedleInsertionModel::new(5000.0, 0.5, 100.0, 0.3, 0.001);
needle.advance(0.01);
assert!(needle.punctured);
needle.retract(0.02); assert!(!needle.punctured, "retraction past start resets puncture");
}
#[test]
fn test_needle_lateral_force() {
let needle = NeedleInsertionModel::new(5000.0, 2.0, 100.0, 0.3, 0.001);
let f = needle.lateral_force(0.001);
assert!(f > 0.0, "lateral force should be positive");
}
#[test]
fn test_tissue_haptics_no_load_zero_height() {
let tissue = DeformableTissueHaptics::new(5, 5, 0.01, 10_000.0, 50.0);
let h = tissue.surface_height(0.02, 0.02);
assert!(h.abs() < EPS, "no load → zero height, got {h}");
}
#[test]
fn test_tissue_haptics_apply_load_increases_displacement() {
let mut tissue = DeformableTissueHaptics::new(10, 10, 0.01, 10_000.0, 50.0);
tissue.apply_point_load(0.04, 0.04, -10.0);
let h = tissue.surface_height(0.04, 0.04);
assert!(h < 0.0, "negative load should depress tissue, h={h}");
}
#[test]
fn test_tissue_haptics_step_decays_displacement() {
let mut tissue = DeformableTissueHaptics::new(10, 10, 0.01, 10_000.0, 10.0);
tissue.apply_point_load(0.04, 0.04, -10.0);
let h0 = tissue.surface_height(0.04, 0.04);
tissue.step(0.1);
let h1 = tissue.surface_height(0.04, 0.04);
assert!(
h1.abs() < h0.abs(),
"displacement should decay after step: h0={h0}, h1={h1}"
);
}
#[test]
fn test_virtual_fixture_forbidden_outside_region_no_force() {
let vf = VirtualFixture::new(
FixtureType::ForbiddenHalfSpace {
point: [0.0; 3],
normal: [0.0, 1.0, 0.0],
},
1000.0,
10.0,
);
let f = vf.constraint_force([0.0, -0.1, 0.0], [0.0; 3]);
assert_eq!(f, [0.0; 3]);
}
#[test]
fn test_virtual_fixture_forbidden_inside_region_pushes_out() {
let vf = VirtualFixture::new(
FixtureType::ForbiddenHalfSpace {
point: [0.0; 3],
normal: [0.0, 1.0, 0.0],
},
1000.0,
0.0,
);
let f = vf.constraint_force([0.0, 0.005, 0.0], [0.0; 3]);
assert!(f[1] < 0.0, "should push out, f_y={}", f[1]);
assert!((f[1] + 5.0).abs() < EPS, "f_y = -1000 * 0.005 = -5 N");
}
#[test]
fn test_virtual_fixture_linear_guide() {
let vf = VirtualFixture::new(
FixtureType::LinearGuide {
origin: [0.0; 3],
direction: [1.0, 0.0, 0.0],
},
500.0,
0.0,
);
let f = vf.constraint_force([1.0, 0.01, 0.0], [0.0; 3]);
assert!(f[1] < 0.0, "should pull back to guide line, f_y={}", f[1]);
}
#[test]
fn test_virtual_fixture_planar_guide() {
let vf = VirtualFixture::new(
FixtureType::PlanarGuide {
origin: [0.0; 3],
normal: [0.0, 1.0, 0.0],
},
800.0,
0.0,
);
let f = vf.constraint_force([0.0, 0.002, 0.0], [0.0; 3]);
assert!(f[1] < 0.0, "should push back to plane, f_y={}", f[1]);
assert!((f[1] + 1.6).abs() < EPS, "f_y = -800 * 0.002 = -1.6 N");
}
#[test]
fn test_latency_compensator_constant_velocity() {
let mut lc = LatencyCompensator::new(0.010); lc.record(0.000, [0.0, 0.0, 0.0]);
lc.record(0.001, [0.001, 0.0, 0.0]); let pred = lc.predict(0.001);
assert!((pred[0] - 0.011).abs() < 1e-6, "pred_x={}", pred[0]);
}
#[test]
fn test_latency_compensator_single_sample_returns_last() {
let mut lc = LatencyCompensator::new(0.005);
lc.record(0.0, [1.0, 2.0, 3.0]);
let pred = lc.predict(0.0);
assert_eq!(pred, [1.0, 2.0, 3.0]);
}
#[test]
fn test_multipoint_no_active_contacts_zero_force() {
let mp = MultiPointHapticContact::new(20.0);
let f = mp.total_force();
assert_eq!(f, [0.0; 3]);
}
#[test]
fn test_multipoint_two_active_contacts_sum() {
let mut mp = MultiPointHapticContact::new(100.0);
let mut cp1 = ContactPoint::new(0, 1000.0, 0.0);
cp1.active = true;
cp1.normal = [0.0, 1.0, 0.0];
cp1.penetration = 0.002;
let mut cp2 = ContactPoint::new(1, 1000.0, 0.0);
cp2.active = true;
cp2.normal = [0.0, 1.0, 0.0];
cp2.penetration = 0.003;
mp.add_contact(cp1);
mp.add_contact(cp2);
let f = mp.total_force();
assert!((f[1] - 5.0).abs() < EPS, "f_y={}", f[1]);
}
#[test]
fn test_multipoint_force_clamped() {
let mut mp = MultiPointHapticContact::new(3.0);
let mut cp = ContactPoint::new(0, 100_000.0, 0.0);
cp.active = true;
cp.normal = [0.0, 1.0, 0.0];
cp.penetration = 1.0;
mp.add_contact(cp);
let f = mp.total_force();
let mag = norm3(f);
assert!(
(mag - 3.0).abs() < 1e-6,
"should be clamped to 3 N, got {mag}"
);
}
#[test]
fn test_multipoint_active_count() {
let mut mp = MultiPointHapticContact::new(10.0);
let mut cp1 = ContactPoint::new(0, 100.0, 0.0);
cp1.active = true;
let cp2 = ContactPoint::new(1, 100.0, 0.0);
mp.add_contact(cp1);
mp.add_contact(cp2);
assert_eq!(mp.active_count(), 1);
}
#[test]
fn test_min_stable_frequency() {
let f = HapticFrequencyAnalysis::min_stable_frequency(1000.0, 0.05);
assert!((f - 22.508).abs() < 0.01, "f_min={f}");
}
#[test]
fn test_max_renderable_stiffness_at_1khz() {
let k = HapticFrequencyAnalysis::max_renderable_stiffness(1000.0, 0.05);
assert!(k > 1e6, "k_max={k}");
}
#[test]
fn test_required_dt_higher_stiffness_needs_smaller_dt() {
let dt_soft = HapticFrequencyAnalysis::required_dt(1_000.0, 0.05);
let dt_hard = HapticFrequencyAnalysis::required_dt(10_000.0, 0.05);
assert!(
dt_hard < dt_soft,
"harder tissue requires smaller dt: dt_soft={dt_soft}, dt_hard={dt_hard}"
);
}
#[test]
fn test_tissue_layer_zero_depth_zero_force() {
let model = TissueLayerModel::default_anatomical();
let f = model.total_reaction_force(0.0);
assert!(f.abs() < EPS, "f={f}");
}
#[test]
fn test_tissue_layer_shallow_penetration_skin_only() {
let model = TissueLayerModel::default_anatomical();
let f_shallow = model.total_reaction_force(0.001); let (layer_idx, _depth) = model.depth_in_layer(0.001);
assert_eq!(layer_idx, 0, "should be in skin layer");
assert!(f_shallow > 0.0);
}
#[test]
fn test_tissue_layer_force_increases_with_depth() {
let model = TissueLayerModel::default_anatomical();
let f1 = model.total_reaction_force(0.005);
let f2 = model.total_reaction_force(0.010);
assert!(f2 > f1, "deeper penetration should give more force");
}
#[test]
fn test_tissue_layer_depth_in_layer_correct() {
let model = TissueLayerModel::default_anatomical();
let (layer_idx, _depth) = model.depth_in_layer(0.025); assert_eq!(layer_idx, 2, "should be in muscle layer");
}
#[test]
fn test_haptic_stats_max_force_tracked() {
let mut stats = HapticSimulationStats::default();
stats.record_force(1.0, false);
stats.record_force(5.0, false);
stats.record_force(3.0, false);
assert!((stats.max_force_n - 5.0).abs() < EPS);
}
#[test]
fn test_haptic_stats_clamp_events_counted() {
let mut stats = HapticSimulationStats::default();
stats.record_force(1.0, false);
stats.record_force(8.5, true);
stats.record_force(8.5, true);
assert_eq!(stats.clamp_events, 2);
}
#[test]
fn test_haptic_stats_avg_force() {
let mut stats = HapticSimulationStats::default();
stats.record_force(2.0, false);
stats.record_force(4.0, false);
assert!(
(stats.avg_force_n - 3.0).abs() < 1e-6,
"avg={}",
stats.avg_force_n
);
}
#[test]
fn test_haptic_loop_tick_no_contact_zero_contact_force() {
let mut loop_ctrl = HapticRenderingLoop::new(1000.0, 10.0, 500.0, 5.0, 0.001);
let f = loop_ctrl.tick([0.0, 0.01, 0.0], [0.0; 3], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
assert!(norm3(f) > 0.0);
}
#[test]
fn test_haptic_loop_tick_advances_time() {
let mut loop_ctrl = HapticRenderingLoop::new(1000.0, 10.0, 500.0, 5.0, 0.001);
loop_ctrl.tick([0.0; 3], [0.0; 3], [0.0; 3], [0.0, 1.0, 0.0]);
loop_ctrl.tick([0.0; 3], [0.0; 3], [0.0; 3], [0.0, 1.0, 0.0]);
assert!((loop_ctrl.time - 0.002).abs() < EPS);
assert_eq!(loop_ctrl.iteration, 2);
}
}