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