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}