Skip to main content

phyz_world/
tendon.rs

1//! Tendon model for cable-like actuators spanning multiple bodies.
2
3use phyz_math::{SpatialVec, Vec3};
4use phyz_model::{Model, State};
5
6/// A tendon is a spring-damper system that follows a path through multiple bodies.
7///
8/// It generates forces along the path based on the current length vs. rest length.
9#[derive(Debug, Clone)]
10pub struct Tendon {
11    /// Sequence of body indices defining the tendon path.
12    pub path: Vec<usize>,
13    /// Spring stiffness (N/m or Nm/rad).
14    pub stiffness: f64,
15    /// Rest length (m).
16    pub rest_length: f64,
17    /// Maximum force the tendon can exert (N).
18    pub max_force: f64,
19    /// Damping coefficient (Ns/m).
20    pub damping: f64,
21}
22
23impl Tendon {
24    /// Create a new tendon with the given parameters.
25    pub fn new(path: Vec<usize>, stiffness: f64, rest_length: f64, max_force: f64) -> Self {
26        Self {
27            path,
28            stiffness,
29            rest_length,
30            max_force,
31            damping: 0.0,
32        }
33    }
34
35    /// Create a new tendon with damping.
36    pub fn with_damping(mut self, damping: f64) -> Self {
37        self.damping = damping;
38        self
39    }
40
41    /// Compute the current length of the tendon.
42    ///
43    /// Sums the Euclidean distances between consecutive bodies in the path.
44    pub fn current_length(&self, state: &State) -> f64 {
45        let mut length = 0.0;
46
47        for i in 0..self.path.len().saturating_sub(1) {
48            let body_a = self.path[i];
49            let body_b = self.path[i + 1];
50
51            if body_a < state.body_xform.len() && body_b < state.body_xform.len() {
52                let pos_a = state.body_xform[body_a].pos;
53                let pos_b = state.body_xform[body_b].pos;
54                length += (pos_b - pos_a).norm();
55            }
56        }
57
58        length
59    }
60
61    /// Compute the rate of change of tendon length.
62    ///
63    /// This requires velocities, which we approximate from body transforms.
64    /// For now, return 0.0 (proper implementation requires forward kinematics with velocities).
65    pub fn current_length_rate(&self, _state: &State) -> f64 {
66        0.0 // Placeholder
67    }
68
69    /// Compute forces applied to each body in the tendon path.
70    ///
71    /// Returns a vector of (body_idx, force) pairs.
72    /// Force is a SpatialVec (6D force/torque in world frame).
73    pub fn compute_forces(&self, state: &State) -> Vec<(usize, SpatialVec)> {
74        let current_len = self.current_length(state);
75        let stretch = current_len - self.rest_length;
76
77        // Spring force: F = -k * (L - L0) - b * dL/dt
78        let length_rate = self.current_length_rate(state);
79        let mut force_magnitude = -self.stiffness * stretch - self.damping * length_rate;
80
81        // Saturate force
82        if force_magnitude.abs() > self.max_force {
83            force_magnitude = force_magnitude.signum() * self.max_force;
84        }
85
86        // Distribute force along tendon path
87        let mut forces = Vec::new();
88
89        for i in 0..self.path.len().saturating_sub(1) {
90            let body_a = self.path[i];
91            let body_b = self.path[i + 1];
92
93            if body_a < state.body_xform.len() && body_b < state.body_xform.len() {
94                let pos_a = state.body_xform[body_a].pos;
95                let pos_b = state.body_xform[body_b].pos;
96
97                let direction = (pos_b - pos_a).normalize();
98                let force_vec = direction * force_magnitude;
99
100                // Apply force to body_a (pulling toward body_b)
101                forces.push((body_a, SpatialVec::new(Vec3::zeros(), force_vec)));
102
103                // Apply opposite force to body_b
104                forces.push((body_b, SpatialVec::new(Vec3::zeros(), -force_vec)));
105            }
106        }
107
108        forces
109    }
110
111    /// Check if tendon is valid for the given model.
112    pub fn is_valid(&self, model: &Model) -> bool {
113        if self.path.len() < 2 {
114            return false;
115        }
116
117        for &body_idx in &self.path {
118            if body_idx >= model.nbodies() {
119                return false;
120            }
121        }
122
123        true
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use phyz_math::{SpatialInertia, SpatialTransform, Vec3};
131    use phyz_model::ModelBuilder;
132
133    #[test]
134    fn test_tendon_length() {
135        // Create a simple 2-body chain
136        let model = ModelBuilder::new()
137            .add_revolute_body(
138                "link1",
139                -1,
140                SpatialTransform::identity(),
141                SpatialInertia::point_mass(1.0, Vec3::new(0.0, 0.0, -0.5)),
142            )
143            .add_revolute_body(
144                "link2",
145                0,
146                SpatialTransform::translation(Vec3::new(0.0, 0.0, -1.0)),
147                SpatialInertia::point_mass(1.0, Vec3::new(0.0, 0.0, -0.5)),
148            )
149            .build();
150
151        let mut state = model.default_state();
152
153        // Set body transforms manually for testing
154        state.body_xform[0] = SpatialTransform::translation(Vec3::new(0.0, 0.0, 0.0));
155        state.body_xform[1] = SpatialTransform::translation(Vec3::new(0.0, 0.0, -1.0));
156
157        let tendon = Tendon::new(vec![0, 1], 100.0, 0.5, 10.0);
158        let length = tendon.current_length(&state);
159
160        // Distance between (0,0,0) and (0,0,-1) is 1.0
161        assert!((length - 1.0).abs() < 1e-10);
162    }
163
164    #[test]
165    fn test_tendon_forces() {
166        let model = ModelBuilder::new()
167            .add_revolute_body(
168                "link1",
169                -1,
170                SpatialTransform::identity(),
171                SpatialInertia::point_mass(1.0, Vec3::new(0.0, 0.0, -0.5)),
172            )
173            .add_revolute_body(
174                "link2",
175                0,
176                SpatialTransform::translation(Vec3::new(0.0, 0.0, -1.0)),
177                SpatialInertia::point_mass(1.0, Vec3::new(0.0, 0.0, -0.5)),
178            )
179            .build();
180
181        let mut state = model.default_state();
182        state.body_xform[0] = SpatialTransform::translation(Vec3::new(0.0, 0.0, 0.0));
183        state.body_xform[1] = SpatialTransform::translation(Vec3::new(0.0, 0.0, -1.5));
184
185        // Tendon with rest length 1.0, current length 1.5 -> stretched by 0.5
186        let tendon = Tendon::new(vec![0, 1], 100.0, 1.0, 1000.0);
187        let forces = tendon.compute_forces(&state);
188
189        // Should have 2 forces (one per body)
190        assert_eq!(forces.len(), 2);
191
192        // Force magnitude should be k * stretch = 100 * 0.5 = 50
193        let force_mag = forces[0].1.linear().norm();
194        assert!((force_mag - 50.0).abs() < 1e-6);
195    }
196
197    #[test]
198    fn test_tendon_force_saturation() {
199        let model = ModelBuilder::new()
200            .add_revolute_body(
201                "link1",
202                -1,
203                SpatialTransform::identity(),
204                SpatialInertia::point_mass(1.0, Vec3::new(0.0, 0.0, -0.5)),
205            )
206            .add_revolute_body(
207                "link2",
208                0,
209                SpatialTransform::translation(Vec3::new(0.0, 0.0, -1.0)),
210                SpatialInertia::point_mass(1.0, Vec3::new(0.0, 0.0, -0.5)),
211            )
212            .build();
213
214        let mut state = model.default_state();
215        state.body_xform[0] = SpatialTransform::translation(Vec3::new(0.0, 0.0, 0.0));
216        state.body_xform[1] = SpatialTransform::translation(Vec3::new(0.0, 0.0, -2.0));
217
218        // Large stretch (1.0) with high stiffness (100) = 100N force
219        // But max_force is 10N, so should saturate
220        let tendon = Tendon::new(vec![0, 1], 100.0, 1.0, 10.0);
221        let forces = tendon.compute_forces(&state);
222
223        let force_mag = forces[0].1.linear().norm();
224        assert!((force_mag - 10.0).abs() < 1e-6);
225    }
226
227    #[test]
228    fn test_tendon_validation() {
229        let model = ModelBuilder::new()
230            .add_revolute_body(
231                "link",
232                -1,
233                SpatialTransform::identity(),
234                SpatialInertia::point_mass(1.0, Vec3::new(0.0, 0.0, -0.5)),
235            )
236            .build();
237
238        let valid_tendon = Tendon::new(vec![0], 100.0, 1.0, 10.0);
239        assert!(!valid_tendon.is_valid(&model)); // Single body path is invalid
240
241        let invalid_tendon = Tendon::new(vec![0, 99], 100.0, 1.0, 10.0);
242        assert!(!invalid_tendon.is_valid(&model)); // Body 99 doesn't exist
243    }
244}