Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/rapier2d/tests/snapshot_portability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use rapier2d::prelude::*;
/// Snapshot size in bytes, and its FNV-1a digest. Both, because the size alone localizes a
/// failure: a differing size means a container's *encoding* changed, an equal size with a
/// differing digest means the values did.
const GOLDEN: (usize, u64) = (88_532, 0xccad_c968_4d93_9348);
const GOLDEN: (usize, u64) = (88_572, 0x9e84_4370_55e1_8c88);

const STEPS: usize = 60;

Expand Down
6 changes: 6 additions & 0 deletions crates/rapier3d-mjcf/src/loader/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ impl<'a> Conversion<'a> {
joint,
damping_per_dof: 0.0,
armature_per_dof: 0.0,
frictionloss_per_dof: 0.0,
spring_stiffness_per_dof: 0.0,
spring_ref: 0.0,
springdamper: None,
Expand Down Expand Up @@ -322,6 +323,10 @@ impl<'a> Conversion<'a> {
// generalized mass matrix at insertion time rather than baked into
// the link's spatial inertia — see `MjcfJoint::armature_per_dof`.
let armature_per_dof = j.armature.max(0.0) as Real;
// Dry joint friction, routed into the multibody's per-DoF
// `frictionloss` vector at insertion time — see
// `MjcfJoint::frictionloss_per_dof`.
let frictionloss_per_dof = j.frictionloss.max(0.0) as Real;
// Passive spring carried so the multibody path can integrate it
// implicitly (a valid `springdamper` overrides `<joint stiffness>`,
// leaving this 0 and supplying the stiffness post-assembly). A
Expand All @@ -341,6 +346,7 @@ impl<'a> Conversion<'a> {
joint,
damping_per_dof,
armature_per_dof,
frictionloss_per_dof,
spring_stiffness_per_dof,
spring_ref,
springdamper,
Expand Down
13 changes: 11 additions & 2 deletions crates/rapier3d-mjcf/src/loader/insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ use super::handles::{
MjcfActuatorHandle, MjcfBodyHandle, MjcfColliderHandle, MjcfJointHandle, MjcfRobotHandles,
};
use super::mass::{
add_armature_to_multibody, add_joint_coupling_to_multibody, add_spring_to_multibody,
add_springdamper_to_multibody, move_motor_damping_to_multibody,
add_armature_to_multibody, add_frictionloss_to_multibody, add_joint_coupling_to_multibody,
add_spring_to_multibody, add_springdamper_to_multibody, move_motor_damping_to_multibody,
};
use super::options::MjcfMultibodyOptions;
use super::types::MjcfRobot;
Expand Down Expand Up @@ -161,6 +161,7 @@ impl MjcfRobot {
};
let damping_per_dof = j.damping_per_dof;
let armature_per_dof = j.armature_per_dof;
let frictionloss_per_dof = j.frictionloss_per_dof;
let spring_stiffness_per_dof = j.spring_stiffness_per_dof;
let spring_ref = j.spring_ref;
let springdamper = j.springdamper;
Expand Down Expand Up @@ -211,6 +212,14 @@ impl MjcfRobot {
if armature_per_dof > 0.0 {
add_armature_to_multibody(multibody_joints, h, armature_per_dof);
}
// Route MJCF `<joint frictionloss>` into the multibody's
// per-DoF friction vector, where the solver turns it into a
// box-bounded constraint row. The serial-joint path has no
// such vector, so it keeps the motor approximation built by
// the joint builder.
if frictionloss_per_dof > 0.0 {
add_frictionloss_to_multibody(multibody_joints, h, frictionloss_per_dof);
}
// Integrate MJCF `<joint stiffness>` springs implicitly on the
// multibody (stable for stiff springs on low-inertia links),
// replacing the explicit position motor that the serial-joint
Expand Down
27 changes: 23 additions & 4 deletions crates/rapier3d-mjcf/src/loader/joint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,17 +143,36 @@ impl<'a> Conversion<'a> {
}
}

// Friction loss (lossy approximation): use a velocity motor
// capped at `frictionloss`.
// Friction loss, for the *impulse-joint* path only: a zero-target
// velocity motor capped at `frictionloss`. That is the same row the
// multibody path builds properly (zero target velocity, impulse
// bounded by `frictionloss·dt`), but squeezed into the joint's one
// motor slot. The multibody path instead routes the value through
// `Multibody::frictionloss` (`add_frictionloss_to_multibody`) and
// clears this motor, so the two never coexist.
//
// Skipped when a spring already owns the slot: `motor_velocity`
// zeroes the motor's stiffness and damping, which would silently
// delete the `<joint stiffness>` / `<joint springdamper>` spring
// installed just above. A spring is the more load-bearing of the
// two, and on the multibody path (where both are wanted together)
// friction no longer needs the slot at all.
if joint.frictionloss > 0.0 {
let axis = match joint.type_ {
mb::JointType::Hinge | mb::JointType::Ball => Some(JointAxis::AngX),
mb::JointType::Slide => Some(JointAxis::LinX),
_ => None,
};
if let Some(ax) = axis {
builder = builder.motor_velocity(ax, 0.0, 0.0);
builder = builder.motor_max_force(ax, joint.frictionloss as Real);
if stiffness.is_some() {
log::warn!(
"<joint name={:?}>: `frictionloss` is not applied on the impulse-joint path because the joint also has a spring, and both need the single motor slot. The multibody path applies both.",
joint.name,
);
} else {
builder = builder.motor_velocity(ax, 0.0, 0.0);
builder = builder.motor_max_force(ax, joint.frictionloss as Real);
}
}
}
}
Expand Down
83 changes: 82 additions & 1 deletion crates/rapier3d-mjcf/src/loader/mass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ pub(super) fn move_motor_damping_to_multibody(
handle: MultibodyJointHandle,
damping: Real,
) {
use rapier3d::dynamics::JointAxesMask;
use rapier3d::math::SPATIAL_DIM;
let Some((multibody, link_id)) = multibody_joints.get_mut(handle) else {
return;
Expand All @@ -194,7 +195,19 @@ pub(super) fn move_motor_damping_to_multibody(
let motor_bits = link.joint.data.motor_axes.bits();
for i in 0..SPATIAL_DIM {
if (motor_bits & (1 << i)) != 0 {
link.joint.data.motors[i].damping = 0.0;
let motor = &mut link.joint.data.motors[i];
motor.damping = 0.0;
// Damping was this motor's only contribution: what is left is a
// zero-target row with no gains, i.e. a rigid velocity lock with
// unlimited force. Drop the axis (like `add_spring_to_multibody`
// does); actuators re-enable it on the axes they drive.
if motor.stiffness == 0.0
&& motor.target_vel == 0.0
&& motor.max_force == Real::MAX
&& let Some(flag) = JointAxesMask::from_bits(1u8 << i)
{
link.joint.data.motor_axes.remove(flag);
}
}
}
// Drop the &mut MultibodyLink borrow before reborrowing the multibody.
Expand Down Expand Up @@ -256,6 +269,74 @@ pub(super) fn add_armature_to_multibody(
}
}

/// After a joint has been inserted into a multibody, add the MJCF
/// `<joint frictionloss>` to the multibody's per-DoF friction vector. The
/// solver emits one box-bounded constraint row per DoF with a non-zero entry,
/// driving that DoF's velocity to zero with the impulse capped at
/// `frictionloss · dt`.
///
/// Applied uniformly to every free DoF of the joint, matching MJCF semantics
/// (a ball joint with `frictionloss=f` gets `f` on each of its angular DoFs).
///
/// Also clears the zero-velocity motor the serial-joint builder installs as the
/// impulse path's approximation, so the two do not stack. Unlike that motor,
/// the constraint rows cover every free DoF and leave the motor slot free for
/// a spring or an actuator.
pub(super) fn add_frictionloss_to_multibody(
multibody_joints: &mut MultibodyJointSet,
handle: MultibodyJointHandle,
frictionloss: Real,
) {
use rapier3d::dynamics::JointAxesMask;
use rapier3d::math::SPATIAL_DIM;
let Some((multibody, link_id)) = multibody_joints.get_mut(handle) else {
return;
};
// Reconstruct this link's DoF offset in the multibody's flat vector
// (assembly_id isn't public), same as the armature helper above.
let mut offset = 0;
for (i, link) in multibody.links().enumerate() {
if i == link_id {
break;
}
offset += link.joint().ndofs();
}
let Some(link) = multibody.links().nth(link_id) else {
return;
};
let locked_bits = link.joint.data.locked_axes.bits();
let fl_vec = multibody.frictions_mut();
let mut local_dof = 0;
for i in 0..SPATIAL_DIM {
if (locked_bits & (1 << i)) == 0 {
let idx = offset + local_dof;
if idx < fl_vec.len() {
fl_vec[idx] = frictionloss;
}
local_dof += 1;
}
}

// Drop the impulse-path motor approximation. Only when it is the friction
// one: a motor carrying a spring (non-zero stiffness or damping) or an
// actuator target belongs to something else.
let Some(link) = multibody.links_mut().nth(link_id) else {
return;
};
for axis in 0..SPATIAL_DIM {
if (locked_bits & (1 << axis)) == 0 {
let motor = &link.joint.data.motors[axis];
let is_friction_motor = motor.stiffness == 0.0
&& motor.damping == 0.0
&& motor.target_vel == 0.0
&& motor.max_force == frictionloss;
if is_friction_motor && let Some(flag) = JointAxesMask::from_bits(1u8 << axis) {
link.joint.data.motor_axes.remove(flag);
}
}
}
}

/// After a joint has been inserted into a multibody, install a passive
/// `<joint stiffness springref>` spring as an *implicit* spring on the
/// multibody link (force `-k·(q − rest)`, integrated implicitly in the
Expand Down
12 changes: 12 additions & 0 deletions crates/rapier3d-mjcf/src/loader/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ pub struct MjcfJoint {
/// (huge along the joint axis, ~0 across it) and the multibody mass
/// matrix ill-conditioned.
pub armature_per_dof: Real,
/// MJCF `<joint frictionloss>` value (dry joint friction, N or N·m). On
/// the multibody insertion path this becomes a per-DoF entry of the
/// multibody's `frictionloss` vector, which the solver turns into one
/// box-bounded constraint row per DoF.
///
/// It is deliberately **not** a motor: MuJoCo's friction loss is a bound on
/// the force friction may generate, not a `-f·sign(q̇)` force, and a joint
/// commonly carries both a position servo and a friction loss. Routing it
/// through the joint's single motor slot (as this loader used to) made the
/// two fight: the servo's `forcerange` overwrote the friction bound, and
/// the friction entry wiped a `<joint stiffness>` spring's coefficients.
pub frictionloss_per_dof: Real,
/// MJCF `<joint stiffness>` (passive spring). On the multibody path this
/// can be integrated implicitly in the generalized dynamics (added to the
/// mass-matrix diagonal as `dt²·k` with a force `-k·(q − ref)`), which is
Expand Down
Loading
Loading