diff --git a/mojo/elements/body.py b/mojo/elements/body.py
index fa3509b..bf2d5df 100644
--- a/mojo/elements/body.py
+++ b/mojo/elements/body.py
@@ -98,3 +98,6 @@ def set_kinematic(self, value: bool):
if value and not self.is_kinematic():
self.mjcf.add("freejoint")
self._mojo.mark_dirty()
+ elif not value and self.is_kinematic():
+ self.remove_all_joints()
+ self._mojo.mark_dirty()
diff --git a/mojo/elements/element.py b/mojo/elements/element.py
index 6b05714..d2f8fe7 100644
--- a/mojo/elements/element.py
+++ b/mojo/elements/element.py
@@ -20,6 +20,14 @@ def _is_kinematic(elem: mjcf.Element):
return has_freejoint or has_joints or _is_kinematic(elem.parent)
+def _remove_all_joints(elem: mjcf.Element):
+ if hasattr(elem, "freejoint") and elem.freejoint is not None:
+ elem.freejoint.remove()
+ if hasattr(elem, "joint") and len(elem.joint) > 0:
+ for joint in elem.joint:
+ joint.remove()
+
+
def _find_freejoint(elem: mjcf.Element):
if elem.parent is None:
# Root of tree
@@ -73,6 +81,9 @@ def get_quaternion(self) -> np.ndarray:
def is_kinematic(self) -> bool:
return _is_kinematic(self.mjcf)
+ def remove_all_joints(self):
+ _remove_all_joints(self.mjcf)
+
@property
def id(self):
return self._mojo.physics.bind(self.mjcf).element_id
diff --git a/mojo/elements/geom.py b/mojo/elements/geom.py
index 09a1572..72e75c6 100644
--- a/mojo/elements/geom.py
+++ b/mojo/elements/geom.py
@@ -175,3 +175,6 @@ def set_kinematic(self, value: bool):
if value and not self.is_kinematic():
self.mjcf.parent.add("freejoint")
self._mojo.mark_dirty()
+ elif not value and self.is_kinematic():
+ self.parent.remove_all_joints()
+ self._mojo.mark_dirty()
diff --git a/mojo/elements/joint.py b/mojo/elements/joint.py
index b76a662..f664f16 100644
--- a/mojo/elements/joint.py
+++ b/mojo/elements/joint.py
@@ -54,7 +54,7 @@ def create(
def get_joint_position(self) -> float:
"""Get current joint position."""
- return float(self._mojo.physics.bind(self.mjcf).qpos)
+ return float(self._mojo.physics.bind(self.mjcf).qpos.item())
def set_joint_position(self, value: float):
self._mojo.physics.bind(self.mjcf).qpos *= 0
@@ -63,4 +63,4 @@ def set_joint_position(self, value: float):
def get_joint_velocity(self) -> float:
"""Get current joint velocity."""
- return float(self._mojo.physics.bind(self.mjcf).qvel)
+ return float(self._mojo.physics.bind(self.mjcf).qvel.item())
diff --git a/mojo/elements/utils.py b/mojo/elements/utils.py
index 97f7e1a..8ab8956 100644
--- a/mojo/elements/utils.py
+++ b/mojo/elements/utils.py
@@ -5,11 +5,14 @@
import numpy as np
from dm_control import mjcf
+from lxml import etree
from mojo.elements.consts import TextureMapping
# Default minimum distance between two geoms for them to be considered in collision.
_DEFAULT_COLLISION_MARGIN: float = 1e-8
+_FREEJOINT_TAG = "freejoint"
+_WORLDBODY_TAG = "worldbody"
def has_collision(
@@ -77,6 +80,30 @@ def load_mesh(
return mesh
+def resolve_freejoints(
+ root_model: mjcf.RootElement, model: mjcf.RootElement
+) -> mjcf.RootElement:
+ child_xml = model.to_xml()
+ if len(child_xml.findall(f".//{_FREEJOINT_TAG}")) > 0:
+ root_xml = root_model.to_xml()
+ worldbody = root_xml.find(_WORLDBODY_TAG)
+ search_expr = f".//{child_xml.tag}"
+ for attr_name, attr_value in child_xml.attrib.items():
+ search_expr += f"[@{attr_name}='{attr_value}']"
+ child_xml = root_xml.find(search_expr)
+ freejoints = child_xml.findall(f".//{_FREEJOINT_TAG}")
+ for joint in freejoints:
+ worldbody.append(joint.getparent())
+ if len(child_xml) == 0:
+ child_xml.getparent().remove(child_xml)
+ root_model = mjcf.from_xml_string(
+ etree.tostring(root_xml),
+ escape_separators=True,
+ assets=root_model.get_assets(),
+ )
+ return root_model
+
+
class AssetStore:
"""Container for Mujoco assets."""
diff --git a/mojo/mojo.py b/mojo/mojo.py
index 9a98169..e3c8195 100644
--- a/mojo/mojo.py
+++ b/mojo/mojo.py
@@ -7,7 +7,7 @@
from mojo.elements.body import Body
from mojo.elements.element import MujocoElement
from mojo.elements.model import MujocoModel
-from mojo.elements.utils import AssetStore
+from mojo.elements.utils import AssetStore, resolve_freejoints
class Mojo:
@@ -108,6 +108,7 @@ def load_model(
path: str,
parent: MujocoElement = None,
on_loaded: Optional[Callable[[mjcf.RootElement], None]] = None,
+ handle_freejoints: bool = False,
):
"""Load a Mujoco model from xml file and attach to specified parent element.
@@ -116,6 +117,8 @@ def load_model(
If None, it attaches to the root element.
:param on_loaded: Optional callback to be executed after model is loaded.
Use it to customize the Mujoco model before attaching it to the parent.
+ :param handle_freejoints: If true handles elements.
+ Freejoint bodies will be re-parented to the worldbody.
:return: A Body element representing the attached model.
"""
@@ -124,6 +127,11 @@ def load_model(
on_loaded(model_mjcf)
attach_site = self.root_element.mjcf if parent is None else parent.mjcf
attached_model_mjcf = attach_site.attach(model_mjcf)
+ if handle_freejoints:
+ root_model_mjcf = resolve_freejoints(
+ self.root_element.mjcf, attached_model_mjcf
+ )
+ self.root_element = MujocoElement(self, root_model_mjcf)
self.mark_dirty()
return Body(self, attached_model_mjcf)
diff --git a/tests/assets/models/sphere.xml b/tests/assets/models/sphere.xml
new file mode 100644
index 0000000..801b5cc
--- /dev/null
+++ b/tests/assets/models/sphere.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/assets/models/sphere_and_box.xml b/tests/assets/models/sphere_and_box.xml
new file mode 100644
index 0000000..fa1eac6
--- /dev/null
+++ b/tests/assets/models/sphere_and_box.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/elements/test_body.py b/tests/elements/test_body.py
index 5fdbd6a..5ff7c7d 100644
--- a/tests/elements/test_body.py
+++ b/tests/elements/test_body.py
@@ -45,3 +45,10 @@ def test_get_set_kinematic(mojo: Mojo, body: Body):
mojo.step() # Objects should fall
after = body.get_position()
assert np.any(np.not_equal(before, after))
+
+
+def test_is_kinematic(mojo: Mojo, body: Body):
+ body.set_kinematic(True)
+ assert body.is_kinematic()
+ body.set_kinematic(False)
+ assert not body.is_kinematic()
diff --git a/tests/elements/test_geom.py b/tests/elements/test_geom.py
index 45ec766..966442c 100644
--- a/tests/elements/test_geom.py
+++ b/tests/elements/test_geom.py
@@ -80,3 +80,10 @@ def test_get_set_kinematic(mojo: Mojo, geom: Geom):
mojo.step() # Objects should fall
after = geom.get_position()
assert np.any(np.not_equal(before, after))
+
+
+def test_is_kinematic(mojo: Mojo, geom: Geom):
+ geom.set_kinematic(True)
+ assert geom.is_kinematic()
+ geom.set_kinematic(False)
+ assert not geom.is_kinematic()
diff --git a/tests/elements/test_mojo.py b/tests/elements/test_mojo.py
new file mode 100644
index 0000000..1a68588
--- /dev/null
+++ b/tests/elements/test_mojo.py
@@ -0,0 +1,36 @@
+from pathlib import Path
+
+import pytest
+
+from mojo import Mojo
+from mojo.elements import Body
+
+
+@pytest.fixture()
+def mojo() -> Mojo:
+ return Mojo(str(Path(__file__).parents[1] / "world.xml"))
+
+
+def load_model(mojo: Mojo, model_name: str, handle_freejoints: bool) -> Body:
+ body = mojo.load_model(
+ str(Path(__file__).parents[1] / "assets" / "models" / model_name),
+ handle_freejoints=handle_freejoints,
+ )
+ _ = mojo.physics
+ return body
+
+
+def test_load_freejoint(mojo: Mojo):
+ load_model(mojo, "sphere.xml", True)
+
+
+def test_load_freejoint_raises_by_default(mojo: Mojo):
+ with pytest.raises(ValueError):
+ load_model(mojo, "sphere.xml", False)
+
+
+def test_load_freejoint_hierarchy(mojo: Mojo):
+ sphere_and_box = load_model(mojo, "sphere_and_box.xml", True)
+ for joint in sphere_and_box.joints:
+ assert joint.mjcf.tag == "freejoint"
+ assert joint.mjcf.parent.parent.tag == "worldbody"