Skip to main content

symtropy_physics/
articulation.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3//! Articulated chain builder for serial kinematic chains.
4//!
5//! Constructs a series of rigid bodies connected by hinge joints with
6//! optional motor drives. Used by robotics platforms (manipulator arm,
7//! quadruped legs, exoskeleton limbs).
8
9use nalgebra::SVector;
10use symtropy_math::Point;
11
12use crate::body::{BodyHandle, RigidBody};
13use crate::joints::{HingeJoint, MotorDrive};
14use crate::world::PhysicsWorld;
15
16/// Specification for a single link in an articulated chain.
17#[derive(Clone, Debug)]
18pub struct LinkSpec {
19    pub mass: f64,
20    pub length: f64,
21    pub radius: f64,
22    pub plane_a: usize,
23    pub plane_b: usize,
24    pub angle_limits: Option<(f64, f64)>,
25    pub motor_max_force: Option<f64>,
26    pub motor_damping: Option<f64>,
27}
28
29impl Default for LinkSpec {
30    fn default() -> Self {
31        Self {
32            mass: 1.0,
33            length: 0.3,
34            radius: 0.03,
35            plane_a: 0,
36            plane_b: 2,
37            angle_limits: Some((-2.9, 2.9)),
38            motor_max_force: Some(50.0),
39            motor_damping: None,
40        }
41    }
42}
43
44/// An articulated chain in a physics world.
45#[derive(Debug)]
46pub struct ArticulatedChain {
47    pub base: BodyHandle,
48    pub links: Vec<BodyHandle>,
49    pub num_joints: usize,
50}
51
52impl ArticulatedChain {
53    /// Read approximate joint angles from relative body positions.
54    pub fn read_joint_states(&self, world: &PhysicsWorld<3>) -> Vec<(f64, f64)> {
55        let mut states = Vec::with_capacity(self.num_joints);
56        let mut prev = self.base;
57        for &link in &self.links {
58            let (angle, vel) = match (world.body(prev), world.body(link)) {
59                (Some(a), Some(b)) => {
60                    let d = b.transform.translation.0 - a.transform.translation.0;
61                    (
62                        d[2].atan2(d[0]),
63                        b.angular_velocity.get(0, 2) - a.angular_velocity.get(0, 2),
64                    )
65                }
66                _ => (0.0, 0.0),
67            };
68            states.push((angle, vel));
69            prev = link;
70        }
71        states
72    }
73
74    /// Get the tip (last link) body handle.
75    pub fn tip(&self) -> BodyHandle {
76        *self.links.last().unwrap_or(&self.base)
77    }
78}
79
80/// Builder for articulated chains.
81pub struct ChainBuilder {
82    base_pos: Point<3>,
83    links: Vec<LinkSpec>,
84}
85
86impl ChainBuilder {
87    pub fn new() -> Self {
88        Self {
89            base_pos: Point::origin(),
90            links: Vec::new(),
91        }
92    }
93
94    pub fn base_position(mut self, pos: Point<3>) -> Self {
95        self.base_pos = pos;
96        self
97    }
98
99    pub fn add_link(mut self, spec: LinkSpec) -> Self {
100        self.links.push(spec);
101        self
102    }
103
104    /// Build the chain in the physics world.
105    pub fn build(self, world: &mut PhysicsWorld<3>) -> ArticulatedChain {
106        // Static base (small sphere)
107        let base = world.add_body(RigidBody::static_body(
108            BodyHandle(0), // overwritten by add_body
109            self.base_pos,
110            Box::new(symtropy_math::Sphere::new(Point::origin(), 0.05)),
111        ));
112
113        let mut links = Vec::with_capacity(self.links.len());
114        let mut prev = base;
115        let mut pos = self.base_pos.0;
116
117        for spec in &self.links {
118            pos[2] -= spec.length;
119            let link_pos = Point::new([pos[0], pos[1], pos[2]]);
120            let handle = world.add_body(RigidBody::dynamic_sphere(
121                BodyHandle(0),
122                link_pos,
123                spec.radius.max(0.01),
124                spec.mass,
125            ));
126
127            let anchor_a: SVector<f64, 3> = SVector::from([0.0, 0.0, -spec.length * 0.5]);
128            let anchor_b: SVector<f64, 3> = SVector::from([0.0, 0.0, spec.length * 0.5]);
129
130            let mut hinge = HingeJoint::with_anchors(
131                prev,
132                handle,
133                anchor_a,
134                anchor_b,
135                spec.plane_a,
136                spec.plane_b,
137            );
138            if let Some((min, max)) = spec.angle_limits {
139                hinge = hinge.with_limits(min, max);
140            }
141            if let Some(max_force) = spec.motor_max_force {
142                let mut motor = MotorDrive::new(0.0, max_force);
143                if let Some(d) = spec.motor_damping {
144                    motor.kd = d;
145                }
146                hinge = hinge.with_motor(motor);
147            }
148
149            world.add_constraint(Box::new(hinge));
150            links.push(handle);
151            prev = handle;
152        }
153
154        ArticulatedChain {
155            base,
156            links,
157            num_joints: self.links.len(),
158        }
159    }
160}
161
162impl Default for ChainBuilder {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn world_with_gravity() -> PhysicsWorld<3> {
173        PhysicsWorld::new(SVector::from([0.0, 0.0, -9.81]))
174    }
175
176    #[test]
177    fn test_single_link_pendulum() {
178        let mut world = world_with_gravity();
179        let chain = ChainBuilder::new()
180            .base_position(Point::new([0.0, 0.0, 2.0]))
181            .add_link(LinkSpec {
182                mass: 1.0,
183                length: 0.5,
184                ..Default::default()
185            })
186            .build(&mut world);
187
188        assert_eq!(chain.num_joints, 1);
189        // After gravity steps, the tip should have moved
190        for _ in 0..500 {
191            world.step(0.002);
192        }
193        let tip = world.body(chain.tip()).unwrap();
194        let pos = &tip.transform.translation.0;
195        assert!(
196            pos[0].is_finite() && pos[2].is_finite(),
197            "Tip position should be finite: {pos:?}"
198        );
199        // The constraint keeps it connected to base; verify it hasn't exploded
200        let dist_from_base = ((pos[0]).powi(2) + (pos[1]).powi(2) + (pos[2] - 2.0).powi(2)).sqrt();
201        assert!(
202            dist_from_base < 2.0,
203            "Tip should stay near base: dist={dist_from_base}"
204        );
205    }
206
207    #[test]
208    fn test_three_link_chain_finite() {
209        let mut world = world_with_gravity();
210        let chain = ChainBuilder::new()
211            .base_position(Point::new([0.0, 0.0, 3.0]))
212            .add_link(LinkSpec {
213                mass: 2.0,
214                length: 0.5,
215                ..Default::default()
216            })
217            .add_link(LinkSpec {
218                mass: 1.5,
219                length: 0.4,
220                ..Default::default()
221            })
222            .add_link(LinkSpec {
223                mass: 1.0,
224                length: 0.3,
225                ..Default::default()
226            })
227            .build(&mut world);
228
229        assert_eq!(chain.num_joints, 3);
230        for _ in 0..200 {
231            world.step(0.001);
232        }
233        for &h in &chain.links {
234            let p = &world.body(h).unwrap().transform.translation.0;
235            assert!(p[0].is_finite() && p[1].is_finite() && p[2].is_finite());
236        }
237    }
238
239    #[test]
240    fn test_joint_readback() {
241        let mut world = world_with_gravity();
242        let chain = ChainBuilder::new()
243            .base_position(Point::new([0.0, 0.0, 2.0]))
244            .add_link(LinkSpec::default())
245            .add_link(LinkSpec::default())
246            .build(&mut world);
247
248        let states = chain.read_joint_states(&world);
249        assert_eq!(states.len(), 2);
250        for (a, v) in &states {
251            assert!(a.is_finite());
252            assert!(v.is_finite());
253        }
254    }
255
256    #[test]
257    fn test_tip_is_last_link() {
258        let mut world = PhysicsWorld::new(SVector::from([0.0, 0.0, 0.0]));
259        let chain = ChainBuilder::new()
260            .add_link(LinkSpec::default())
261            .add_link(LinkSpec::default())
262            .build(&mut world);
263        assert_eq!(chain.tip(), chain.links[1]);
264    }
265}