Skip to main content

phyz_model/
joint.rs

1//! Joint types and definitions.
2
3use phyz_math::{DMat, Mat3, SpatialTransform, SpatialVec, Vec3, fp, quat_exp};
4
5/// Joint type enumeration.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub enum JointType {
8    /// Single rotational DOF about an axis.
9    Revolute,
10    /// Single translational DOF along an axis.
11    Prismatic,
12    /// 3 DOF spherical joint (ball joint) using quaternions.
13    Spherical,
14    /// 6 DOF free joint (3 translation + 3 rotation).
15    Free,
16    /// 0 DOF fixed joint (rigid attachment).
17    Fixed,
18    /// Alias for Revolute (MuJoCo compatibility).
19    Hinge,
20    /// Alias for Prismatic (MuJoCo compatibility).
21    Slide,
22    /// Alias for Spherical (MuJoCo compatibility).
23    Ball,
24}
25
26/// A joint connecting two bodies.
27#[derive(Debug, Clone)]
28pub struct Joint {
29    /// Joint type.
30    pub joint_type: JointType,
31    /// Transform from parent body frame to joint frame (constant).
32    ///
33    /// `pos` is the joint origin expressed in parent-body coordinates and
34    /// `rot` is the coordinate transform *parent → joint*, i.e. the joint's
35    /// orientation in the parent frame is `rot.transpose()`.
36    pub parent_to_joint: SpatialTransform,
37    /// Joint axis, expressed in the joint frame (for revolute: typically Z).
38    pub axis: Vec3,
39    /// Damping coefficient (force = -damping * qd).
40    pub damping: f64,
41    /// Joint position limits [lower, upper] (None = unlimited).
42    ///
43    /// Only meaningful for single-DOF joints (revolute/prismatic). Limits on
44    /// ball and free joints are ignored — MuJoCo expresses those as a cone
45    /// limit, which this model does not represent yet.
46    pub limits: Option<[f64; 2]>,
47    /// Stiffness of the soft joint-limit force (force per unit of violation).
48    pub limit_stiffness: f64,
49    /// Damping of the soft joint-limit force (force per unit of inward velocity).
50    pub limit_damping: f64,
51    /// Rotor / armature inertia added to this joint's diagonal of the mass matrix.
52    ///
53    /// MuJoCo's `armature`. Physically it is the reflected inertia of the motor
54    /// rotor; numerically it also regularises the mass matrix.
55    pub armature: f64,
56    /// Passive spring stiffness (force = -stiffness * (q - spring_ref)).
57    pub stiffness: f64,
58    /// Rest position of the passive spring (MuJoCo's `springref`).
59    pub spring_ref: f64,
60    /// Dry (Coulomb) friction magnitude — MuJoCo's `frictionloss`, and where
61    /// URDF's `<dynamics friction="...">` lands.
62    pub friction_loss: f64,
63    /// Actuation effort limit (torque or force), if the source declares one.
64    ///
65    /// Descriptive only: nothing in the solver clamps to it yet.
66    pub effort_limit: Option<f64>,
67    /// Velocity limit, if the source declares one. Descriptive only.
68    pub velocity_limit: Option<f64>,
69    /// Name of the joint in the source file (empty if unnamed).
70    pub name: String,
71}
72
73impl Default for Joint {
74    fn default() -> Self {
75        Self {
76            joint_type: JointType::Fixed,
77            parent_to_joint: SpatialTransform::identity(),
78            axis: Vec3::zeros(),
79            damping: 0.0,
80            limits: None,
81            limit_stiffness: DEFAULT_LIMIT_STIFFNESS,
82            limit_damping: DEFAULT_LIMIT_DAMPING,
83            armature: 0.0,
84            stiffness: 0.0,
85            spring_ref: 0.0,
86            friction_loss: 0.0,
87            effort_limit: None,
88            velocity_limit: None,
89            name: String::new(),
90        }
91    }
92}
93
94/// Default stiffness of the soft joint-limit force.
95///
96/// Chosen to be stiff enough that a limit is visibly hard at the ~1 ms
97/// timesteps this engine targets, but soft enough to stay explicit-integrator
98/// stable for unit-scale inertias. Models with very different scales should set
99/// [`Joint::limit_stiffness`] explicitly.
100pub const DEFAULT_LIMIT_STIFFNESS: f64 = 1000.0;
101
102/// Default damping of the soft joint-limit force. Critical-ish for a unit
103/// inertia against [`DEFAULT_LIMIT_STIFFNESS`], which kills limit chatter.
104pub const DEFAULT_LIMIT_DAMPING: f64 = 60.0;
105
106impl Joint {
107    /// Create a revolute joint with the given parent-to-joint transform.
108    pub fn revolute(parent_to_joint: SpatialTransform) -> Self {
109        Self {
110            joint_type: JointType::Revolute,
111            parent_to_joint,
112            axis: Vec3::new(0.0, 0.0, 1.0), // revolute about Z
113            ..Default::default()
114        }
115    }
116
117    /// Create a prismatic joint with the given parent-to-joint transform and axis.
118    pub fn prismatic(parent_to_joint: SpatialTransform, axis: Vec3) -> Self {
119        Self {
120            joint_type: JointType::Prismatic,
121            parent_to_joint,
122            axis,
123            ..Default::default()
124        }
125    }
126
127    /// Create a spherical (ball) joint with the given parent-to-joint transform.
128    pub fn spherical(parent_to_joint: SpatialTransform) -> Self {
129        Self {
130            joint_type: JointType::Spherical,
131            parent_to_joint,
132            axis: Vec3::zeros(), // not used for spherical
133            ..Default::default()
134        }
135    }
136
137    /// Create a free joint with the given parent-to-joint transform.
138    pub fn free(parent_to_joint: SpatialTransform) -> Self {
139        Self {
140            joint_type: JointType::Free,
141            parent_to_joint,
142            axis: Vec3::zeros(), // not used for free
143            ..Default::default()
144        }
145    }
146
147    /// Create a fixed joint (rigid attachment).
148    pub fn fixed(parent_to_joint: SpatialTransform) -> Self {
149        Self {
150            joint_type: JointType::Fixed,
151            parent_to_joint,
152            axis: Vec3::zeros(),
153            ..Default::default()
154        }
155    }
156
157    /// Set the joint name (builder style).
158    pub fn with_name(mut self, name: &str) -> Self {
159        self.name = name.to_string();
160        self
161    }
162
163    /// Number of degrees of freedom for this joint type.
164    pub fn ndof(&self) -> usize {
165        match self.joint_type {
166            JointType::Revolute | JointType::Hinge => 1,
167            JointType::Prismatic | JointType::Slide => 1,
168            JointType::Spherical | JointType::Ball => 3,
169            JointType::Free => 6,
170            JointType::Fixed => 0,
171        }
172    }
173
174    /// Soft joint-limit force for a single-DOF joint at position `q`, velocity `qd`.
175    ///
176    /// Returns a generalized force along the joint's DOF (positive = pushes `q`
177    /// up). Zero when the joint is unlimited, multi-DOF, or inside its range.
178    ///
179    /// # Why a penalty force
180    ///
181    /// The contact layer is currently a penalty method, so limits are modelled
182    /// the same way for consistency: a one-sided spring-damper on the violation
183    /// depth. The damping term is *gated* — it only ever resists motion deeper
184    /// into the limit, never pulls the joint back toward the violation — so this
185    /// force is strictly dissipative and cannot inject energy at the boundary
186    /// (which is what makes naive `-d * qd` limit damping oscillate).
187    ///
188    /// # Migration to a constraint
189    ///
190    /// This is deliberately expressed as a pure function of `(q, qd)` returning
191    /// a force, with the violation depth and its sign computed by
192    /// [`Joint::limit_violation`]. When the solver gains unilateral constraints,
193    /// `limit_violation` becomes the constraint function `g(q) >= 0` and its
194    /// Jacobian row is the joint's motion subspace; only the force assembly here
195    /// is replaced, not the model or the parsing that feeds it.
196    pub fn limit_force(&self, q: f64, qd: f64) -> f64 {
197        let Some((depth, sign)) = self.limit_violation(q) else {
198            return 0.0;
199        };
200        // `sign` is the direction the restoring force must push (+1 at the lower
201        // limit, -1 at the upper). Damping engages only while the joint is still
202        // moving into the limit, i.e. when qd opposes `sign`.
203        let inward_speed = (-sign * qd).max(0.0);
204        sign * (self.limit_stiffness * depth + self.limit_damping * inward_speed)
205    }
206
207    /// Limit violation at position `q`, as `(depth, restoring_direction)`.
208    ///
209    /// `depth` is a non-negative penetration past the limit; the direction is
210    /// `+1.0` past the lower bound and `-1.0` past the upper bound. Returns
211    /// `None` when the joint is unlimited, multi-DOF, or within range.
212    ///
213    /// This is the constraint function a future unilateral solver would use.
214    pub fn limit_violation(&self, q: f64) -> Option<(f64, f64)> {
215        if self.ndof() != 1 {
216            return None;
217        }
218        let [lo, hi] = self.limits?;
219        if q < lo {
220            Some((lo - q, 1.0))
221        } else if q > hi {
222            Some((q - hi, -1.0))
223        } else {
224            None
225        }
226    }
227
228    /// Total passive generalized force on DOF `k` of this joint.
229    ///
230    /// Combines viscous damping, the passive spring, dry friction and the soft
231    /// joint limit. `q`/`qd` are this DOF's position and velocity.
232    pub fn passive_force(&self, k: usize, q: f64, qd: f64) -> f64 {
233        let mut f = -self.damping * qd;
234        if self.ndof() == 1 && k == 0 {
235            f += -self.stiffness * (q - self.spring_ref);
236            f += self.limit_force(q, qd);
237            if self.friction_loss > 0.0 {
238                // Smoothed Coulomb friction: -mu * sign(qd), regularised so a
239                // joint at rest does not chatter under an explicit integrator.
240                // A proper implementation is a box constraint on the friction
241                // force, which lands with the constraint solver.
242                const FRICTION_VEL_SCALE: f64 = 1e-3;
243                f += -self.friction_loss * fp::tanh(qd / FRICTION_VEL_SCALE);
244            }
245        }
246        f
247    }
248
249    /// Compute the joint transform for the given joint position(s).
250    ///
251    /// Returns the Plücker transform from predecessor to successor frame.
252    /// `q` slice should have length >= ndof().
253    pub fn joint_transform_slice(&self, q: &[f64]) -> SpatialTransform {
254        match self.joint_type {
255            JointType::Revolute | JointType::Hinge => {
256                // Passive rotation: negate angle for coordinate transform
257                let angle = q[0];
258                let (s, c) = fp::sin_cos(-angle);
259                let a = &self.axis;
260                let ax = phyz_math::skew(a);
261                let rot = Mat3::identity() + ax * s + ax * ax * (1.0 - c);
262                SpatialTransform::new(rot, Vec3::zeros())
263            }
264            JointType::Prismatic | JointType::Slide => {
265                let distance = q[0];
266                let pos = self.axis * distance;
267                SpatialTransform::new(Mat3::identity(), pos)
268            }
269            JointType::Spherical | JointType::Ball => {
270                // q = exponential coordinates (3) of the child→parent
271                // rotation R, integrated as R ← R·exp(ω·dt) (body angular
272                // velocity composed on the right — see
273                // phyz_rigid::integrate). The joint transform is the
274                // *coordinate map* parent→child, i.e. Rᵀ: same negation the
275                // hinge branch applies to its angle. Using R here made the
276                // dynamics (which see q only through this map) disagree with
277                // the integrator by an inverse, which pumped energy into any
278                // passive spherical joint the moment its axis moved.
279                let w = Vec3::new(q[0], q[1], q[2]);
280                let rot = quat_exp(&w).to_matrix().transpose();
281                SpatialTransform::new(rot, Vec3::zeros())
282            }
283            JointType::Free => {
284                // ### FREE-JOINT DOF ORDER — ANGULAR FIRST. ###
285                //
286                // q = [wx, wy, wz, x, y, z]: exp-coords of the child→parent
287                // rotation, *then* position in parent coords. This matches
288                // `SpatialVec`'s `[angular; linear]` order, which is what the
289                // free joint's motion subspace (the 6×6 identity, see
290                // `motion_subspace_matrix`) and therefore `v`, `qdd`, `tau`,
291                // the Jacobians and the contact solver all use.
292                //
293                // It did NOT used to. Until this was fixed, `q` was
294                // `[x, y, z, wx, wy, wz]` — translation first — while `v` was
295                // angular first, so the flat `q += v·dt` that most callers
296                // performed fed the vertical acceleration into the yaw
297                // exponential coordinate: a free body released under gravity
298                // never fell, it spun up at 9.81 rad/s². If you are changing
299                // this, change `phyz_rigid::integrate_configuration` and the
300                // GPU `INTEGRATE_SHADER` in the same commit.
301                //
302                // Note this is *not* `phyz_diff`'s rollout layout, which packs
303                // a free joint as `[x, y, z, quat(4)]` in its own private
304                // `DofLayout` and never shares indices with `State::q`.
305                let w = Vec3::new(q[0], q[1], q[2]);
306                let pos = Vec3::new(q[3], q[4], q[5]);
307                let rot = quat_exp(&w).to_matrix().transpose();
308                SpatialTransform::new(rot, pos)
309            }
310            JointType::Fixed => {
311                // No DOF, return identity
312                SpatialTransform::identity()
313            }
314        }
315    }
316
317    /// Compute the joint transform for a single-DOF joint (backward compat).
318    /// For multi-DOF joints, use joint_transform_slice instead.
319    pub fn joint_transform(&self, q: f64) -> SpatialTransform {
320        self.joint_transform_slice(&[q])
321    }
322
323    /// Motion subspace matrix S for this joint.
324    /// Returns a matrix of size 6 × ndof.
325    /// For single-DOF joints, returns a 6×1 column vector.
326    pub fn motion_subspace_matrix(&self) -> DMat {
327        match self.joint_type {
328            JointType::Revolute | JointType::Hinge => {
329                let s = SpatialVec::new(self.axis, Vec3::zeros());
330                let arr = s.as_array();
331                DMat::from_row_slice(6, 1, &arr)
332            }
333            JointType::Prismatic | JointType::Slide => {
334                let s = SpatialVec::new(Vec3::zeros(), self.axis);
335                let arr = s.as_array();
336                DMat::from_row_slice(6, 1, &arr)
337            }
338            JointType::Spherical | JointType::Ball => {
339                // 3 DOF: angular velocity in body frame
340                // S = [I_3×3; 0_3×3] (angular part is identity, linear is zero)
341                DMat::from_row_slice(
342                    6,
343                    3,
344                    &[
345                        1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
346                        0.0, 0.0, 0.0,
347                    ],
348                )
349            }
350            JointType::Free => {
351                // 6 DOF: [angular; linear] velocity
352                // S = I_6×6
353                DMat::identity(6)
354            }
355            JointType::Fixed => {
356                // 0 DOF: empty 6×0 matrix
357                DMat::zeros(6, 0)
358            }
359        }
360    }
361
362    /// Motion subspace for single-DOF joints (backward compat).
363    /// For multi-DOF joints, use motion_subspace_matrix instead.
364    /// Fixed joints return a zero vector.
365    pub fn motion_subspace(&self) -> SpatialVec {
366        match self.joint_type {
367            JointType::Revolute | JointType::Hinge => SpatialVec::new(self.axis, Vec3::zeros()),
368            JointType::Prismatic | JointType::Slide => SpatialVec::new(Vec3::zeros(), self.axis),
369            JointType::Fixed => SpatialVec::zero(), // 0 DOF
370            _ => panic!(
371                "motion_subspace() only valid for single-DOF joints; use motion_subspace_matrix() for multi-DOF joints"
372            ),
373        }
374    }
375}