Load an MJCF ArticulationΒΆ

Use world.load_mjcf(...) to parse an MJCF file and world.add_articulation(...) to create the live simulation object.

data = world.load_mjcf(mjcf_path, order="DFS")
config = ke.physics.ArticulationConfig.free_base()

record = world.add_articulation(
    data,
    env_id=0,
    obj_id=0,
    name="robot",
    config=config,
)
robot = record.articulation

Use ArticulationConfig.fixed_base() for a fixed root. Keep the same traversal order when creating articulation visuals so simulation body indices and visual body indices agree.

robot_visual = visual.add_articulation_scene_graph(
    0,
    0,
    mjcf_path,
    path="/robot",
    order="DFS",
    material=robot_material,
)

Run the complete control example with an MJCF file:

python ./python/examples/mjcf_dof_control.py /path/to/robot.xml
Complete source: mjcf_dof_control.py
  1"""Generic MJCF articulation DOF position-control viewer."""
  2
  3from __future__ import annotations
  4
  5import argparse
  6import math
  7from pathlib import Path
  8
  9import numpy as np
 10
 11import kangengine as ke
 12from kangengine import imgui, keys
 13
 14
 15def package_asset_path(*parts: str) -> str:
 16    return str(Path(ke.__file__).resolve().parent / "assets" / Path(*parts))
 17
 18
 19def default_mjcf_path() -> Path:
 20    return Path(package_asset_path("characters", "kw", "kw.xml"))
 21
 22
 23class MjcfDofControlApp(ke.App):
 24    """Load an MJCF articulation and expose every DOF as an ImGui slider."""
 25
 26    window_title = "MJCF DOF Control"
 27    object_name = "mjcf"
 28    prim_base_path = "/mjcf"
 29    camera_pos = (3.8, -5.4, 1.2)
 30    camera_target = (0.0, 0.0, 0.45)
 31    # None preserves per-geom MJCF rgba. Set an RGBA tuple to force a single
 32    # override color for the whole articulation.
 33    visual_color = None  # np.array([1,1,1, 1.0])
 34    ground_size = 10.0
 35    root_pos = (0.0, 0.0, 1.5)
 36    root_rot_xyzw = (0.0, 0.0, 0.0, 1.0)
 37    fixed_base = False
 38    order = "DFS"
 39    sim_dt = 1.0 / 240.0
 40    step_substeps = 4
 41    default_kp = 120.0
 42    default_kd = 12.0
 43    default_anim_amp = 0.35
 44    default_anim_speed = 1.0
 45    default_contact_force_scale = 0.002
 46    contact_force_threshold = 1e-3
 47    visual_alpha_with_collision = 0.1
 48
 49    def __init__(self, mjcf_path: str | Path):
 50        super().__init__()
 51        self.mjcf_path = str(Path(mjcf_path).expanduser().resolve())
 52
 53    def setup(self):
 54        self.timing = self.configure_timing(
 55            ke.SimulationTimingConfig.from_dt(
 56                physics_dt=self.sim_dt,
 57                fixed_dt=self.sim_dt * self.step_substeps,
 58                render_hz=60.0,
 59            )
 60        )
 61        self.set_simulation_hotkeys_enabled(True)
 62        self.elapsed = 0.0
 63        self.kp = float(self.default_kp)
 64        self.kd = float(self.default_kd)
 65        self.animate = True
 66        self.anim_amp = float(self.default_anim_amp)
 67        self.anim_speed = float(self.default_anim_speed)
 68        self.show_collision = False
 69        self.show_contact_forces = False
 70        self.contact_force_scale = float(self.default_contact_force_scale)
 71        self.contact_force_view = None
 72        self.contact_force_color = np.array([[1.0, 0.25, 0.05, 1.0]], dtype=np.float32)
 73        self.empty_vec3 = np.empty((0, 3), dtype=np.float32)
 74        self.empty_vec4 = np.empty((0, 4), dtype=np.float32)
 75        red = ke.ColorLibrary.get(ke.ColorType.RED)
 76        magenta = ke.ColorLibrary.get(ke.ColorType.MAGENTA)
 77        self.drag_force_line_color = np.array(
 78            [[red.r, red.g, red.b, red.a]], dtype=np.float32
 79        )
 80        self.drag_force_target_color = np.array(
 81            [[magenta.r, magenta.g, magenta.b, magenta.a]], dtype=np.float32
 82        )
 83        self.drag_force_line_starts = np.empty((3, 3), dtype=np.float32)
 84        self.drag_force_line_ends = np.empty((3, 3), dtype=np.float32)
 85        self.drag_force_up_z = np.array([0.0, 0.0, 1.0], dtype=np.float32)
 86        self.drag_force_up_y = np.array([0.0, 1.0, 0.0], dtype=np.float32)
 87        self.drag_force_enabled = True
 88        self.drag_force_stiffness = 750.0
 89        self.drag_force_damping = 75.0
 90        self.drag_force_max = 300.0
 91        self.drag_force_arrow_scale = 0.003
 92        self.show_drag_force_arrow = True
 93        self._drag_force_body_id = None
 94        self._drag_force_local_anchor = None
 95        self._drag_force_target = None
 96        self._drag_force_anchor_world = None
 97        self._drag_force_vector = None
 98        self._clear_drag_force_arrow()
 99
100        self.configure_camera()
101        self.standard_materials = self.create_standard_materials()
102        self.debug_material = self.standard_materials.common
103        self.create_world()
104        self.load_articulation()
105        self._reset()
106        self.print_summary()
107
108    def configure_camera(self):
109        self.get_camera().set_camera_pos(ke.Vec3(*self.camera_pos))
110        self.get_camera().set_target_pos(ke.Vec3(*self.camera_target))
111
112    def create_world(self):
113        self.world = ke.sim.KangSimWorld(
114            num_envs=1,
115            sim_dt=self.timing.physics_dt,
116            add_ground=True,
117        )
118        self.visual = ke.visual.sim.SimWorldVisualizer(self, self.world)
119
120        self.ground_view = self.scene.add_ground(
121            "/ground",
122            scale=float(self.ground_size),
123            material=self.standard_materials.ground,
124        )
125
126    def load_articulation(self):
127        data = self.world.load_mjcf(self.mjcf_path, order=self.order)
128        self.obj_id = 0
129        config = (
130            ke.physics.ArticulationConfig.fixed_base()
131            if self.fixed_base
132            else ke.physics.ArticulationConfig.free_base()
133        )
134        self.robot = self.world.add_articulation(
135            data,
136            env_id=0,
137            obj_id=self.obj_id,
138            name=self.object_name,
139            config=config,
140        ).articulation
141
142        self.articulation_visual_view = self.visual.add_articulation_scene_graph(
143            0,
144            self.obj_id,
145            self.mjcf_path,
146            path=self.prim_base_path,
147            order=self.order,
148            material=self.standard_materials.pbr,
149            collision_path=f"{self.prim_base_path}_collision",
150            show_collision=self.show_collision,
151            color=(
152                None
153                if self.visual_color is None
154                else np.array(self.visual_color, dtype=np.float32)
155            ),
156        )
157        self.visual_body_prims = self.articulation_visual_view.prims
158        # self.collision_body_prims = self.articulation_visual_view.collision_visuals
159
160        self.num_dofs = self.robot.num_dofs()
161        self.dof_names = self.world.state.get_obj_dof_names(self.obj_id)
162        self.dof_limits = np.asarray(
163            self.world.state.get_obj_dof_limits(self.obj_id), dtype=np.float32
164        )
165        if self.dof_limits.shape != (self.num_dofs, 2):
166            self.dof_limits = np.tile(
167                np.array([-math.pi, math.pi], dtype=np.float32),
168                (self.num_dofs, 1),
169            )
170        self.targets = self.initial_targets()
171
172    def initial_targets(self) -> np.ndarray:
173        return np.zeros(self.num_dofs, dtype=np.float32)
174
175    def print_summary(self):
176        print(
177            f"{self.object_name} loaded: links={self.robot.num_links()} dofs={self.num_dofs}"
178        )
179        print("DOFs:", ", ".join(self.dof_names))
180        print("XML:", self.mjcf_path)
181
182    def _reset(self):
183        self.elapsed = 0.0
184        self.targets[:] = self.initial_targets()
185        self.world.set_root_state(
186            None,
187            self.obj_id,
188            np.array(self.root_pos, dtype=np.float32),
189            np.array(self.root_rot_xyzw, dtype=np.float32),
190        )
191        self.world.set_dof_state(None, self.obj_id, self.targets)
192        self.world.set_cmd(
193            None,
194            self.obj_id,
195            self.targets,
196            mode=ke.sim.ControlMode.POS,
197            kp=self.kp,
198            kd=self.kd,
199        )
200        self.world.step(substeps=0, apply_commands=False)
201        self.visual.sync()
202        self._update_contact_force_arrows()
203        self._clear_drag_force()
204
205    def _animated_targets(self):
206        out = np.zeros_like(self.targets)
207        for i in range(self.num_dofs):
208            lo, hi = self.slider_limits(i)
209            span = max(0.0, min(float(hi - lo) * 0.35, self.anim_amp))
210            center = 0.5 * float(lo + hi)
211            phase = self.elapsed * self.anim_speed + i * 0.75
212            out[i] = np.clip(center + span * math.sin(phase), lo, hi)
213        return out
214
215    def slider_limits(self, dof_index: int) -> tuple[float, float]:
216        lo, hi = self.dof_limits[dof_index]
217        if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
218            return -math.pi, math.pi
219        return float(lo), float(hi)
220
221    def pre_update(self):
222        if self.was_key_pressed(keys.R):
223            self._reset()
224        if self.was_key_pressed(keys.Q):
225            self.animate = not self.animate
226
227    def fixed_update(self, fixed_dt):
228        self.elapsed += fixed_dt
229        if self.animate:
230            self.targets[:] = self._animated_targets()
231
232        self.world.set_cmd(
233            None,
234            self.obj_id,
235            self.targets,
236            mode=ke.sim.ControlMode.POS,
237            kp=self.kp,
238            kd=self.kd,
239        )
240        self.world.advance(fixed_dt)
241
242    def pre_render(self):
243        self.visual.sync()
244        self._update_contact_force_arrows()
245        self._update_drag_force_arrow()
246        self.check_error()
247
248    def on_force_drag_begin(self, result, target):
249        if not self.drag_force_enabled or not result.hit:
250            self._clear_drag_force()
251            return
252
253        pick = self.articulation_visual_view.pick_body(result)
254        if pick is None:
255            self._clear_drag_force()
256            return
257
258        self._drag_force_body_id = int(pick.body_id)
259        body_state = self._drag_body_state(self._drag_force_body_id)
260        if body_state is None:
261            return
262        body_pos, body_rot, _, _ = body_state
263        hit_pos = self._vec3_to_np(result.position)
264        self._drag_force_local_anchor = self._quat_inverse_rotate_xyzw(
265            body_rot, hit_pos - body_pos
266        )
267        self._drag_force_target = self._vec3_to_np(target)
268        self._apply_drag_force()
269        self._update_drag_force_arrow()
270
271    def on_force_drag_update(self, result, target):
272        if not self.drag_force_enabled or self._drag_force_body_id is None:
273            self._clear_drag_force()
274            return
275        self._drag_force_target = self._vec3_to_np(target)
276        self._apply_drag_force()
277        self._update_drag_force_arrow()
278
279    def on_force_drag_end(self):
280        self._clear_drag_force()
281
282    def _apply_drag_force(self):
283        if (
284            self._drag_force_body_id is None
285            or self._drag_force_local_anchor is None
286            or self._drag_force_target is None
287        ):
288            return
289        body_state = self._drag_body_state(self._drag_force_body_id)
290        if body_state is None:
291            return
292        body_pos, body_rot, body_vel, body_ang_vel = body_state
293        radius = self._quat_rotate_xyzw(body_rot, self._drag_force_local_anchor)
294        anchor_world = body_pos + radius
295        point_vel = body_vel + np.cross(body_ang_vel, radius)
296        force = (self._drag_force_target - anchor_world) * float(
297            self.drag_force_stiffness
298        ) - point_vel * float(self.drag_force_damping)
299        norm = float(np.linalg.norm(force))
300        if norm > float(self.drag_force_max) > 0.0:
301            force *= float(self.drag_force_max) / norm
302        self._drag_force_anchor_world = anchor_world.astype(np.float32)
303        self._drag_force_vector = force.astype(np.float32)
304        self.world.set_body_force_at_position(
305            0,
306            self.obj_id,
307            self._drag_force_body_id,
308            force.astype(np.float32),
309            anchor_world.astype(np.float32),
310        )
311
312    def _clear_drag_force_arrow(self):
313        self.clear_debug_lines("/debug/mjcf_drag_force")
314        self.clear_debug_points("/debug/mjcf_drag_force_target")
315
316    def _update_drag_force_arrow(self):
317        if (
318            not self.show_drag_force_arrow
319            or self._drag_force_anchor_world is None
320            or self._drag_force_vector is None
321            or self._drag_force_target is None
322        ):
323            self._clear_drag_force_arrow()
324            return
325
326        start = self._drag_force_anchor_world
327        force = self._drag_force_vector
328        force_len = float(np.linalg.norm(force))
329        if force_len < 1e-5:
330            self._clear_drag_force_arrow()
331            return
332
333        direction = force / force_len
334        end = start + force * float(self.drag_force_arrow_scale)
335        shaft_len = float(np.linalg.norm(end - start))
336        head_len = min(max(shaft_len * 0.25, 0.04), 0.18)
337
338        side = np.cross(direction, self.drag_force_up_z)
339        if np.linalg.norm(side) < 1e-5:
340            side = np.cross(direction, self.drag_force_up_y)
341        side = side / max(float(np.linalg.norm(side)), 1e-8)
342
343        back = end - direction * head_len
344        left = back + side * head_len * 0.45
345        right = back - side * head_len * 0.45
346        self.drag_force_line_starts[0] = start
347        self.drag_force_line_starts[1] = end
348        self.drag_force_line_starts[2] = end
349        self.drag_force_line_ends[0] = end
350        self.drag_force_line_ends[1] = left
351        self.drag_force_line_ends[2] = right
352        self.log_debug_lines(
353            "/debug/mjcf_drag_force",
354            self.drag_force_line_starts,
355            self.drag_force_line_ends,
356            self.drag_force_line_color,
357            3.0,
358        )
359        self.log_debug_points(
360            "/debug/mjcf_drag_force_target",
361            self._drag_force_target.reshape(1, 3),
362            self.drag_force_target_color,
363            10.0,
364        )
365
366    def _drag_body_state(self, body_id: int):
367        body_pos = self._state_array(self.world.state.get_body_pos(self.obj_id)[0])
368        if body_id >= body_pos.shape[0]:
369            self._clear_drag_force()
370            return None
371        body_rot = self._state_array(self.world.state.get_body_rot(self.obj_id)[0])
372        body_vel = self._state_array(self.world.state.get_body_vel(self.obj_id)[0])
373        body_ang_vel = self._state_array(
374            self.world.state.get_body_ang_vel(self.obj_id)[0]
375        )
376        return (
377            body_pos[body_id],
378            body_rot[body_id],
379            body_vel[body_id],
380            body_ang_vel[body_id],
381        )
382
383    def _clear_drag_force(self):
384        if getattr(self, "_drag_force_body_id", None) is not None:
385            self.world.set_body_force(
386                0,
387                self.obj_id,
388                int(self._drag_force_body_id),
389                np.zeros(3, dtype=np.float32),
390            )
391        self._drag_force_body_id = None
392        self._drag_force_local_anchor = None
393        self._drag_force_target = None
394
395    def _clear_contact_force_arrows(self):
396        if self.contact_force_view is None:
397            return
398        self.contact_force_view.update_arrows(
399            self.empty_vec3,
400            self.empty_vec3,
401            self.empty_vec4,
402        )
403
404    def _update_contact_force_arrows(self):
405        if not self.show_contact_forces:
406            self._clear_contact_force_arrows()
407            return
408
409        # Body-aggregated force visualization.
410        # the arrows start at link origins instead of real contact points.
411        # body_pos = np.asarray(
412        #     self.world.state.get_body_pos(self.obj_id)[0], dtype=np.float32
413        # )
414        # forces = np.asarray(
415        #     self.world.state.get_contact_forces(self.obj_id)[0], dtype=np.float32
416        # )
417        # active = np.linalg.norm(forces, axis=1) > float(self.contact_force_threshold)
418        # starts = body_pos[active]
419        # ends = starts + forces[active] * float(self.contact_force_scale)
420
421        contacts = self.world.physics.get_contacts()
422        starts = []
423        ends = []
424        dt = max(float(self.world.sim_dt), 1e-8)
425        for contact in contacts:
426            position = self._vec3_to_np(contact.position)
427            force = self._vec3_to_np(contact.impulse) / dt
428            if np.linalg.norm(force) <= float(self.contact_force_threshold):
429                continue
430            starts.append(position)
431            ends.append(position + force * float(self.contact_force_scale))
432
433        if not starts:
434            self._clear_contact_force_arrows()
435            return
436
437        starts = np.asarray(starts, dtype=np.float32)
438        ends = np.asarray(ends, dtype=np.float32)
439        colors = np.repeat(self.contact_force_color, starts.shape[0], axis=0)
440
441        if self.contact_force_view is None:
442            self.contact_force_view = self.scene.log_arrows(
443                "/debug/contact_forces",
444                self.debug_material,
445                starts,
446                ends,
447                colors,
448                0.015,
449                12,
450            )
451        else:
452            self.contact_force_view.update_arrows(starts, ends, colors)
453
454    @staticmethod
455    def _vec3_to_np(value) -> np.ndarray:
456        return np.array(
457            [float(value.x), float(value.y), float(value.z)], dtype=np.float32
458        )
459
460    @staticmethod
461    def _state_array(value) -> np.ndarray:
462        if hasattr(value, "detach"):
463            value = value.detach().cpu().numpy()
464        return np.asarray(value, dtype=np.float32)
465
466    @staticmethod
467    def _quat_rotate_xyzw(quat, vector) -> np.ndarray:
468        q = np.asarray(quat, dtype=np.float32)
469        v = np.asarray(vector, dtype=np.float32)
470        qv = q[:3]
471        t = 2.0 * np.cross(qv, v)
472        return (v + q[3] * t + np.cross(qv, t)).astype(np.float32)
473
474    @classmethod
475    def _quat_inverse_rotate_xyzw(cls, quat, vector) -> np.ndarray:
476        q = np.asarray(quat, dtype=np.float32).copy()
477        q[:3] *= -1.0
478        return cls._quat_rotate_xyzw(q, vector)
479
480    def _set_visual_alpha(self, alpha: float):
481        if self.visual_color is None:
482            self.articulation_visual_view.set_alpha(alpha)
483            return
484        color = np.array(self.visual_color, dtype=np.float32).reshape(-1)
485        if color.size == 3:
486            color = np.concatenate([color, np.ones(1, dtype=np.float32)])
487        color = color[:4].copy()
488        color[3] = float(alpha)
489        self.articulation_visual_view.set_color(color)
490
491    def _set_collision_visible(self, visible: bool):
492        self.show_collision = bool(visible)
493        self.articulation_visual_view.set_collision_visible(self.show_collision)
494        self._set_visual_alpha(
495            self.visual_alpha_with_collision if self.show_collision else 1.0
496        )
497
498    def render(self):
499        imgui.begin(self.window_title)
500        state = "paused" if self.is_simulation_paused() else "running"
501        imgui.text(f"State: {state}")
502        imgui.text(
503            "Enter: play/pause    Space: pause/step    R: reset    Q: auto motion"
504        )
505        imgui.text(f"Links: {self.robot.num_links()}  DOFs: {self.num_dofs}")
506        imgui.text(Path(self.mjcf_path).name)
507        imgui.separator()
508        _, self.kp = imgui.slider_float("kp", self.kp, 0.0, 1000.0)
509        _, self.kd = imgui.slider_float("kd", self.kd, 0.0, 80.0)
510        _, self.animate = imgui.checkbox("Animate targets", self.animate)
511        _, self.anim_amp = imgui.slider_float("anim amplitude", self.anim_amp, 0.0, 1.5)
512        _, self.anim_speed = imgui.slider_float("anim speed", self.anim_speed, 0.0, 6.0)
513        changed, self.show_collision = imgui.checkbox(
514            "Show collision prims",
515            self.show_collision,
516        )
517        if changed:
518            self._set_collision_visible(self.show_collision)
519        changed, self.show_contact_forces = imgui.checkbox(
520            "Show contact forces",
521            self.show_contact_forces,
522        )
523        if changed:
524            self._update_contact_force_arrows()
525        _, self.contact_force_scale = imgui.slider_float(
526            "contact force scale",
527            self.contact_force_scale,
528            0.0,
529            0.02,
530        )
531        imgui.separator()
532        changed, self.drag_force_enabled = imgui.checkbox(
533            "Enable drag force",
534            self.drag_force_enabled,
535        )
536        if changed and not self.drag_force_enabled:
537            self._clear_drag_force()
538        changed, self.show_drag_force_arrow = imgui.checkbox(
539            "Show drag force arrow",
540            self.show_drag_force_arrow,
541        )
542        if changed and not self.show_drag_force_arrow:
543            self._clear_drag_force_arrow()
544        _, self.drag_force_stiffness = imgui.slider_float(
545            "drag force stiffness",
546            self.drag_force_stiffness,
547            0.0,
548            1000.0,
549        )
550        _, self.drag_force_damping = imgui.slider_float(
551            "drag force damping",
552            self.drag_force_damping,
553            0.0,
554            80.0,
555        )
556        _, self.drag_force_max = imgui.slider_float(
557            "drag force max",
558            self.drag_force_max,
559            0.0,
560            2000.0,
561        )
562        _, self.drag_force_arrow_scale = imgui.slider_float(
563            "drag force arrow scale",
564            self.drag_force_arrow_scale,
565            0.0005,
566            0.02,
567        )
568        if self._drag_force_body_id is not None:
569            imgui.text(f"Dragging body: {self._drag_force_body_id}")
570        imgui.separator()
571
572        for i, name in enumerate(self.dof_names):
573            lo, hi = self.slider_limits(i)
574            changed, value = imgui.slider_float(name, float(self.targets[i]), lo, hi)
575            if changed:
576                self.targets[i] = value
577                self.animate = False
578
579        pos = self.world.state.get_dof_pos(self.obj_id)[0]
580        imgui.separator()
581        imgui.text("Current DOF positions")
582        for name, value in zip(self.dof_names, pos):
583            imgui.text(f"{name}: {float(value): .3f}")
584        imgui.end()
585
586    def cleanup(self):
587        if hasattr(self, "world"):
588            self.world.release()
589
590
591def main():
592    parser = argparse.ArgumentParser()
593    parser.add_argument(
594        "mjcf_path",
595        nargs="?",
596        default=str(default_mjcf_path()),
597        help="Path to an MJCF XML file",
598    )
599    parser.add_argument(
600        "--fixed-base",
601        action="store_true",
602        help="Use a fixed root instead of the default free root.",
603    )
604    parser.add_argument("--order", default="DFS", choices=("DFS", "BFS"))
605    parser.add_argument("--width", type=int, default=1920)
606    parser.add_argument("--height", type=int, default=1080)
607    args = parser.parse_args()
608
609    class CliMjcfDofControlApp(MjcfDofControlApp):
610        fixed_base = args.fixed_base
611        order = args.order
612
613    app = CliMjcfDofControlApp(args.mjcf_path)
614    app.initialize(args.width, args.height, False, ke.UpAxis.Z)
615    app.start()
616
617
618if __name__ == "__main__":
619    main()

MJCF articulation

Collision debug

Loaded MJCF articulation

MJCF collision debug geometry