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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
## Unreleased

### Added

- Multibody joints now support a per-DoF **armature** (reflected rotor inertia, matching MuJoCo's
concept), exposed via `Multibody::armature()` / `armature_mut()`. The related
`Multibody::dof_inverse_inertia()` returns the articulated DoF inverse inertia including armature.
- Multibody joints now support passive **per-DoF springs** (MuJoCo-style spring/damper) via
`MultibodyJoint::set_spring()` / `spring()`, integrated implicitly.
- Multibody joints now support **holonomic DoF coupling**: constrain one generalized coordinate to
another via `MultibodyDofCoupling` and `Multibody::add_dof_coupling()` / `couplings()`.
- `MultibodyLink::assembly_id()` exposes the offset of a link's DoFs in the multibody's generalized
coordinate/velocity vectors.
- `rapier3d-mjcf`: support for tendons, equality constraints, materials (including visual-mesh
materials), actuator max-force, joint DoF coupling, armature, per-DoF spring/damper, and control.
- Testbed: the ability to specify a material.

### Fixed

- Corrected the local frame of impulse joints attached to multibodies.
- Added a missing coupling term for loop-closing impulse-joint constraints (bodies with a non-zero
center-of-mass offset).
- Fixed a crash occurring with the `parallel` feature enabled.

## v0.33.0 (05 June 2026)

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ oorandom = { version = "11", default-features = false }
#wide = { path = "../wide" }
#glamx = { path = "../glamx" }

#kiss3d = { git = "https://github.com/sebcrozet/kiss3d" }
#kiss3d = { git = "https://github.com/sebcrozet/kiss3d", branch = "render-2d" }
#nalgebra = { git = "https://github.com/dimforge/nalgebra", branch = "dev" }
#parry2d = { git = "https://github.com/dimforge/parry", branch = "master" }
#parry3d = { git = "https://github.com/dimforge/parry", branch = "master" }
Expand Down
39 changes: 39 additions & 0 deletions crates/mjcf-rs/src/equality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ pub enum Equality {
Connect(EqualityConnect),
/// `<equality><weld>` — rigid attachment between two bodies.
Weld(EqualityWeld),
/// `<equality><joint>` — polynomial coupling between two joints' positions.
Joint(EqualityJoint),
/// `<equality><tendon>` — polynomial coupling between two tendon lengths,
/// or (with `tendon2` omitted) a fixed tendon pinned to a constant length.
Tendon(EqualityTendon),
}

/// Common metadata shared by all equality variants.
Expand Down Expand Up @@ -49,3 +54,37 @@ pub struct EqualityWeld {
/// `torquescale` attribute.
pub torque_scale: f64,
}

/// `<equality><joint>` element: couples the position of `joint2` to that of
/// `joint1` through a quartic polynomial of the offset from their reference
/// positions:
/// `q2 − ref2 = p0 + p1·(q1 − ref1) + p2·(q1 − ref1)² + p3·(q1 − ref1)³ + p4·(q1 − ref1)⁴`.
/// `joint2` may be omitted, in which case `joint1` is constrained to the
/// constant `p0`.
#[derive(Clone, Debug, Default)]
pub struct EqualityJoint {
/// Common metadata.
pub common: EqualityCommon,
/// First (independent) joint.
pub joint1: String,
/// Second (dependent) joint. `None` constrains `joint1` to `polycoef[0]`.
pub joint2: Option<String>,
/// Polynomial coefficients `[p0, p1, p2, p3, p4]` (default `[0,1,0,0,0]`).
pub polycoef: [f64; 5],
}

/// `<equality><tendon>` element: couples the length of `tendon2` to that of
/// `tendon1` through the same quartic polynomial form as [`EqualityJoint`].
/// `tendon2` may be omitted, in which case `tendon1`'s length is constrained to
/// the constant `polycoef[0]`.
#[derive(Clone, Debug, Default)]
pub struct EqualityTendon {
/// Common metadata.
pub common: EqualityCommon,
/// First (independent) tendon name.
pub tendon1: String,
/// Second (dependent) tendon name. `None` constrains `tendon1` to `polycoef[0]`.
pub tendon2: Option<String>,
/// Polynomial coefficients `[p0, p1, p2, p3, p4]` (default `[0,1,0,0,0]`).
pub polycoef: [f64; 5],
}
7 changes: 7 additions & 0 deletions crates/mjcf-rs/src/extras.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ pub struct Actuator {
pub gainprm: Vec<f64>,
/// `biasprm` (up to 10 entries).
pub biasprm: Vec<f64>,
/// `gaintype` (`fixed`, `affine`, `muscle`, …). `None` ⇒ MJCF default
/// (`fixed`). Used to interpret `<general>` actuators.
pub gain_type: Option<String>,
/// `biastype` (`none`, `affine`, `muscle`, …). `None` ⇒ MJCF default
/// (`none`). An `affine` bias turns a `<general>` actuator into a
/// position/velocity servo: `bias = biasprm0 + biasprm1·q + biasprm2·q̇`.
pub bias_type: Option<String>,
/// `dyntype`.
pub dyn_type: Option<String>,
/// `dynprm`.
Expand Down
1 change: 1 addition & 0 deletions crates/mjcf-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub mod extras;
mod loader;
pub mod model;
pub mod normals;
pub mod tendon;
pub mod types;

#[cfg(feature = "msh")]
Expand Down
36 changes: 26 additions & 10 deletions crates/mjcf-rs/src/loader/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use super::parse_utils::{
absolutize_model_assets, asset_default_name, parse_f64, parse_f64_list, parse_quat, parse_u32,
parse_vec3, parse_vec4,
};
use super::prototypes::merge_material_proto;
use super::state::ParseState;

impl ParseState {
Expand Down Expand Up @@ -125,21 +126,36 @@ impl ParseState {
self.model.assets.textures.push(t);
}
"material" => {
let mut m = Material::default();
// Resolve the material against its `<default class=…>`
// chain: the material's own attributes win, then the class
// defaults fill the rest, then MuJoCo's built-in defaults
// (specular/shininess 0.5; metallic/roughness -1, a sentinel
// meaning "unset — fall back to the legacy Phong shininess").
let inst = self.parse_material_prototype(child)?;
let mut name = None;
let mut class = None;
for attr in child.attributes() {
match attr.name() {
"name" => m.name = Some(attr.value().to_string()),
"class" => m.class = Some(attr.value().to_string()),
"texture" => m.texture = Some(attr.value().to_string()),
"rgba" => m.rgba = Some(parse_vec4(attr.value(), "material", "rgba")?),
"emission" => m.emission = parse_f64(attr.value())?,
"specular" => m.specular = parse_f64(attr.value())?,
"shininess" => m.shininess = parse_f64(attr.value())?,
"roughness" => m.roughness = parse_f64(attr.value())?,
"metallic" => m.metallic = parse_f64(attr.value())?,
"name" => name = Some(attr.value().to_string()),
"class" => class = Some(attr.value().to_string()),
_ => {}
}
}
let p = merge_material_proto(
Some(self.merged_material_proto(class.as_deref())),
inst,
);
let m = Material {
name,
class,
texture: p.texture,
rgba: p.rgba,
emission: p.emission.unwrap_or(0.0),
specular: p.specular.unwrap_or(0.5),
shininess: p.shininess.unwrap_or(0.5),
roughness: p.roughness.unwrap_or(-1.0),
metallic: p.metallic.unwrap_or(-1.0),
};
self.model.assets.materials.push(m);
}
"include" => {
Expand Down
26 changes: 26 additions & 0 deletions crates/mjcf-rs/src/loader/attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,32 @@ impl ParseState {
.equality
.push(crate::equality::Equality::Weld(new_w));
}
crate::equality::Equality::Joint(j) => {
let mut new_j = j.clone();
if let Some(n) = new_j.common.name.take() {
new_j.common.name = Some(prefixed(prefix, &n));
}
new_j.joint1 = prefixed(prefix, &new_j.joint1);
if let Some(j2) = new_j.joint2.take() {
new_j.joint2 = Some(prefixed(prefix, &j2));
}
self.model
.equality
.push(crate::equality::Equality::Joint(new_j));
}
crate::equality::Equality::Tendon(t) => {
let mut new_t = t.clone();
if let Some(n) = new_t.common.name.take() {
new_t.common.name = Some(prefixed(prefix, &n));
}
new_t.tendon1 = prefixed(prefix, &new_t.tendon1);
if let Some(t2) = new_t.tendon2.take() {
new_t.tendon2 = Some(prefixed(prefix, &t2));
}
self.model
.equality
.push(crate::equality::Equality::Tendon(new_t));
}
}
}
}
Expand Down
96 changes: 94 additions & 2 deletions crates/mjcf-rs/src/loader/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ use roxmltree::Node;

use crate::Pose;
use crate::contact::{ContactExclude, ContactPair};
use crate::equality::{Equality, EqualityCommon, EqualityConnect, EqualityWeld};
use crate::equality::{
Equality, EqualityCommon, EqualityConnect, EqualityJoint, EqualityTendon, EqualityWeld,
};
use crate::error::ParseError;
use crate::extras::{Actuator, ActuatorKind, Keyframe, Sensor};
use crate::tendon::{FixedTendon, TendonJoint};
use crate::types::Tristate;
use glamx::glam::{DQuat, DVec3};

Expand Down Expand Up @@ -131,7 +134,53 @@ impl ParseState {
}
self.model.equality.push(Equality::Weld(w));
}
"joint" | "tendon" | "flex" | "flexvert" | "flexstrain" => {
"joint" => {
let mut ej = EqualityJoint {
common,
polycoef: [0.0, 1.0, 0.0, 0.0, 0.0],
..Default::default()
};
for attr in child.attributes() {
match attr.name() {
"joint1" => ej.joint1 = attr.value().to_string(),
"joint2" => ej.joint2 = Some(attr.value().to_string()),
"polycoef" => {
let v = parse_f64_list(attr.value())?;
for (i, slot) in ej.polycoef.iter_mut().enumerate() {
if let Some(c) = v.get(i) {
*slot = *c;
}
}
}
_ => {}
}
}
self.model.equality.push(Equality::Joint(ej));
}
"tendon" => {
let mut et = EqualityTendon {
common,
polycoef: [0.0, 1.0, 0.0, 0.0, 0.0],
..Default::default()
};
for attr in child.attributes() {
match attr.name() {
"tendon1" => et.tendon1 = attr.value().to_string(),
"tendon2" => et.tendon2 = Some(attr.value().to_string()),
"polycoef" => {
let v = parse_f64_list(attr.value())?;
for (i, slot) in et.polycoef.iter_mut().enumerate() {
if let Some(c) = v.get(i) {
*slot = *c;
}
}
}
_ => {}
}
}
self.model.equality.push(Equality::Tendon(et));
}
"flex" | "flexvert" | "flexstrain" => {
log::debug!("equality `{tag}` is out of scope; skipped");
}
"include" => self.parse_include(child, true)?,
Expand Down Expand Up @@ -169,6 +218,8 @@ impl ParseState {
force_limited: proto.force_limited.unwrap_or(Tristate::Auto),
gainprm: proto.gainprm.unwrap_or_default(),
biasprm: proto.biasprm.unwrap_or_default(),
gain_type: proto.gain_type.clone(),
bias_type: proto.bias_type.clone(),
dyn_type: proto.dyn_type.clone(),
dynprm: proto.dynprm.unwrap_or_default(),
kp: proto.kp,
Expand All @@ -191,6 +242,8 @@ impl ParseState {
"forcelimited" => a.force_limited = parse_tristate(attr.value())?,
"gainprm" => a.gainprm = parse_f64_list(attr.value())?,
"biasprm" => a.biasprm = parse_f64_list(attr.value())?,
"gaintype" => a.gain_type = Some(attr.value().to_string()),
"biastype" => a.bias_type = Some(attr.value().to_string()),
"dyntype" => a.dyn_type = Some(attr.value().to_string()),
"dynprm" => a.dynprm = parse_f64_list(attr.value())?,
"kp" => a.kp = Some(parse_f64(attr.value())?),
Expand Down Expand Up @@ -276,4 +329,43 @@ impl ParseState {
}
Ok(())
}

/// Parse a top-level `<tendon>` block. Only `<fixed>` tendons are kept;
/// `<spatial>` tendons (site-routed cables) are out of scope and skipped.
pub(super) fn parse_tendon(&mut self, node: Node) -> Result<(), ParseError> {
for child in node.children().filter(|n| n.is_element()) {
match child.tag_name().name() {
"include" => self.parse_include(child, true)?,
"spatial" => {
log::debug!("<tendon><spatial> is out of scope; skipped");
}
"fixed" => {
let mut t = FixedTendon {
name: child.attribute("name").map(|s| s.to_string()),
class: child.attribute("class").map(|s| s.to_string()),
joints: Vec::new(),
};
for term in child.children().filter(|n| n.is_element()) {
if term.tag_name().name() != "joint" {
continue;
}
let Some(joint) = term.attribute("joint") else {
continue;
};
let coef = match term.attribute("coef") {
Some(c) => parse_f64(c)?,
None => 1.0,
};
t.joints.push(TendonJoint {
joint: joint.to_string(),
coef,
});
}
self.model.tendons.push(t);
}
_ => {}
}
}
Ok(())
}
}
Loading
Loading