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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ All notable changes to GeneLab are recorded here.

## [Unreleased]

### Fixed

- **Root-velocity writes were silent no-ops on rigid articulations** (#242): Genesis's
`RigidEntity` has `get_vel` / `get_ang` but no `set_vel` / `set_ang` (those setters exist
only on FEM / tool entities), so `push_by_setting_velocity`, the velocity half of
`reset_root_state_uniform`, and `Articulation.write_root_state` wrote nothing — push-based
domain randomization never reached the simulator. Root velocity now routes through the new
`genelab.entity.write_root_velocity`, which drives `set_dofs_velocity` on the base free
joint's 6 DoFs (0–2 world-frame linear, 3–5 angular) and falls back to direct setters for
entity types that have them. The wuji reorient cube kick / reset and the Franka cube reset
in `examples/` had the same dead idiom and now use the shared helper. Policies previously
trained with push events effectively had those disturbances disabled; retraining may be
needed where push robustness matters.
- **Teleop HUD text never displayed under Genesis 1.2** (same bug class as #242, found by
auditing every `getattr`-guarded Genesis call): the outer `Viewer` forwards
`register_keybinds` but not `set_message_text`, so the keyboard bridge's HUD write
silently no-opped. The bridge now falls back to the inner pyrender viewer. A new
`tests/test_genesis_api_contract.py` pins every `getattr`-guarded Genesis method name
against the installed Genesis so future upstream renames fail tests instead of silently
disabling physics or UI.

## [0.4.0] — 2026-07-03

### Changed
Expand Down
10 changes: 2 additions & 8 deletions examples/franka/src/genelab_franka/mdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import torch

from genelab.entity import write_root_velocity
from genelab.entity._torch import to_tensor

from genelab_franka.constants import (
Expand Down Expand Up @@ -171,14 +172,7 @@ def reset_cube_uniform(
except TypeError:
set_pos(new_pos)
zeros = torch.zeros(n, 3, device=env.device)
for fn_name in ("set_vel", "set_ang"):
fn = getattr(handle, fn_name, None)
if fn is None:
continue
try:
fn(zeros, envs_idx=env_ids)
except TypeError:
fn(zeros)
write_root_velocity(handle, zeros, zeros, env_ids)


def resample_goal_uniform(
Expand Down
4 changes: 2 additions & 2 deletions examples/wuji/src/genelab_wuji/reorient/mdp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import torch

from genelab.entity import write_root_velocity
from genelab.managers.command_manager import CommandTerm, CommandTermCfg
from genelab.utils.math import quat_error_magnitude, quat_mul

Expand Down Expand Up @@ -217,12 +218,11 @@ def _pose_goal_marker(self) -> None:
for setter, value in (
("set_pos", pos),
("set_quat", self._goal_quat_w),
("set_vel", zeros),
("set_ang", zeros),
):
fn = getattr(handle, setter, None)
if fn is not None:
fn(value)
write_root_velocity(handle, zeros, zeros)

@property
def success_achieved(self) -> torch.Tensor:
Expand Down
28 changes: 13 additions & 15 deletions examples/wuji/src/genelab_wuji/reorient/mdp/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import torch

from genelab.entity import write_root_velocity
from genelab_wuji.reorient.constants import REORIENT_CUBE_INIT_POS
from genelab_wuji.reorient.mdp._state import cage_counter, disturbance_scale_value
from genelab_wuji.reorient.mdp._math import random_quat
Expand Down Expand Up @@ -31,8 +32,6 @@ def reset_object_orientation(
for setter, value in (
("set_pos", pos),
("set_quat", quat),
("set_vel", zeros),
("set_ang", zeros),
):
fn = getattr(handle, setter, None)
if fn is None:
Expand All @@ -41,6 +40,7 @@ def reset_object_orientation(
fn(value, envs_idx=env_ids)
except TypeError:
fn(value)
write_root_velocity(handle, zeros, zeros, env_ids)


def reset_cage_state(env: "EnvContext", env_ids: torch.Tensor | None) -> None:
Expand Down Expand Up @@ -89,32 +89,27 @@ def randomize_cube_physics(
pass


def _kick(
def _kicked(
handle: object,
getter: str,
setter: str,
env: "EnvContext",
env_ids: torch.Tensor,
n: int,
lo: float,
hi: float,
scale: float,
) -> None:
) -> torch.Tensor | None:
"""Current velocity (via ``getter``) plus a random-direction impulse, for ``env_ids``."""
fn_get = getattr(handle, getter, None)
fn_set = getattr(handle, setter, None)
if fn_get is None or fn_set is None:
return
if fn_get is None:
return None
cur = torch.as_tensor(fn_get(), device=env.device, dtype=torch.float)
if cur.dim() == 1:
cur = cur.unsqueeze(0).expand(env.num_envs, -1)
direction = torch.randn(n, 3, device=env.device)
direction = direction / direction.norm(dim=-1, keepdim=True).clamp_min(1e-6)
mag = torch.empty(n, 1, device=env.device).uniform_(lo, lo + (hi - lo) * scale)
new = cur[env_ids] + direction * mag
try:
fn_set(new, envs_idx=env_ids)
except TypeError:
fn_set(new)
return cur[env_ids] + direction * mag


def apply_velocity_disturbance(
Expand All @@ -137,5 +132,8 @@ def apply_velocity_disturbance(
n = int(env_ids.numel())
handle = env.scene[object_name].gs_handle # type: ignore[index]
scale = float(disturbance_scale_value(env)[0])
_kick(handle, "get_vel", "set_vel", env, env_ids, n, min_speed, max_speed, scale)
_kick(handle, "get_ang", "set_ang", env, env_ids, n, min_angular, max_angular, scale)
vel = _kicked(handle, "get_vel", env, env_ids, n, min_speed, max_speed, scale)
ang = _kicked(handle, "get_ang", env, env_ids, n, min_angular, max_angular, scale)
if vel is None or ang is None:
return
write_root_velocity(handle, vel, ang, env_ids)
6 changes: 6 additions & 0 deletions src/genelab/bridges/keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ def _broadcast_hud(self, viewer: object) -> None:
if not self.cfg.show_hud:
return
msg = f"teleop vx={self._vx:+.2f} vy={self._vy:+.2f} wz={self._wz:+.2f}"
# Genesis 1.2's outer Viewer forwards register_keybinds but not set_message_text —
# the HUD line lives on the inner pyrender viewer (same wrapper gap as the
# viewer.plugins / _viewer_plugins fallback in scene).
set_text = getattr(viewer, "set_message_text", None)
if set_text is None:
inner = getattr(viewer, "_pyrender_viewer", None)
set_text = getattr(inner, "set_message_text", None)
if set_text is not None:
set_text(msg)
2 changes: 2 additions & 0 deletions src/genelab/entity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from genelab.entity.articulation import Articulation, ArticulationCfg, RobotState
from genelab.entity.avatar import Avatar, AvatarCfg
from genelab.entity.rigid_object import RigidObject, RigidObjectCfg
from genelab.entity.root_velocity import write_root_velocity

__all__ = [
"Articulation",
Expand All @@ -12,4 +13,5 @@
"RigidObject",
"RigidObjectCfg",
"RobotState",
"write_root_velocity",
]
4 changes: 2 additions & 2 deletions src/genelab/entity/_articulation_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from genelab.actuator import ActuatorBase
from genelab.entity._torch import to_tensor
from genelab.entity.root_velocity import write_root_velocity


class ArticulationWriter:
Expand Down Expand Up @@ -77,13 +78,12 @@ def write_root_state(
for fn_name, value in (
("set_pos", root_pos),
("set_quat", root_quat),
("set_vel", root_lin_vel_w),
("set_ang", root_ang_vel_w),
):
fn = getattr(robot, fn_name, None)
if fn is None:
continue
fn(value, envs_idx=env_ids)
write_root_velocity(robot, root_lin_vel_w, root_ang_vel_w, env_ids)

def reset(self, env_ids: torch.Tensor) -> None:
"""Reset actuated joints to default pose + zero velocity for ``env_ids``."""
Expand Down
69 changes: 69 additions & 0 deletions src/genelab/entity/root_velocity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Root-velocity write-back for Genesis entity handles.

Genesis's ``RigidEntity`` API is asymmetric: ``get_vel`` / ``get_ang`` exist, but the
matching setters ``set_vel`` / ``set_ang`` exist only on FEM / tool entities. Writing a
floating base's velocity therefore goes through ``set_dofs_velocity`` on the base free
joint's 6 DoFs — indices 0–2 carry world-frame linear velocity, 3–5 world-frame angular
velocity. Every GeneLab site that overwrites root velocity (event terms, the articulation
writer, example resets) must route through :func:`write_root_velocity`; calling
``getattr(handle, "set_vel", ...)`` directly silently no-ops on rigid entities (#242).
"""

from typing import Any

import torch


def base_dof_indices(handle: Any) -> list[int] | None:
"""The 6 free-joint DoF indices of ``handle``'s floating base, or ``None`` if fixed-based.

Scans ``handle.joints`` for the first joint with ≥ 6 DoFs (same idiom as the
articulation binder's free-joint detection) and returns its first six
``dofs_idx_local`` entries, falling back to ``dof_start`` arithmetic for handles
that don't expose the index list.
"""
joints = getattr(handle, "joints", None) or []
for joint in joints:
if int(getattr(joint, "n_dofs", 1)) < 6:
continue
idx = getattr(joint, "dofs_idx_local", None)
if idx is not None:
idx = [int(i) for i in idx]
if len(idx) >= 6:
return idx[:6]
start = int(getattr(joint, "dof_start", 0))
return list(range(start, start + 6))
return None


def write_root_velocity(
handle: Any,
lin_vel_w: torch.Tensor,
ang_vel_w: torch.Tensor,
env_ids: torch.Tensor | None = None,
) -> bool:
"""Overwrite ``handle``'s world-frame root velocity; returns ``True`` if written.

``lin_vel_w`` / ``ang_vel_w`` are ``(n, 3)`` aligned with ``env_ids`` (``None`` →
all envs). Rigid entities take the free-joint ``set_dofs_velocity`` path; entities
exposing direct ``set_vel`` / ``set_ang`` setters (FEM / tool entities, test fakes)
take those. ``False`` means the handle has neither — e.g. a fixed-base articulation,
which has no root velocity to write.
"""
set_dofs_velocity = getattr(handle, "set_dofs_velocity", None)
base_idx = base_dof_indices(handle)
if set_dofs_velocity is not None and base_idx is not None:
velocity = torch.cat([lin_vel_w, ang_vel_w], dim=-1)
set_dofs_velocity(velocity, base_idx, envs_idx=env_ids)
return True
wrote = False
for name, value in (("set_vel", lin_vel_w), ("set_ang", ang_vel_w)):
fn = getattr(handle, name, None)
if fn is None:
continue
try:
fn(value, envs_idx=env_ids)
except TypeError:
fn(value)
wrote = True
return wrote
27 changes: 3 additions & 24 deletions src/genelab/mdp/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import torch

from genelab.entity.root_velocity import write_root_velocity
from genelab.mdp._helpers import asset_articulation, asset_handle
from genelab.utils.math import quat_from_euler_xyz, quat_mul

Expand Down Expand Up @@ -72,18 +73,7 @@ def reset_root_state_uniform(
if axis in velocity_range:
lo, hi = velocity_range[axis]
ang[:, idx] = torch.empty(n, device=env.device).uniform_(lo, hi)
set_vel = getattr(handle, "set_vel", None)
set_ang = getattr(handle, "set_ang", None)
if set_vel is not None:
try:
set_vel(vel, envs_idx=env_ids)
except TypeError:
set_vel(vel)
if set_ang is not None:
try:
set_ang(ang, envs_idx=env_ids)
except TypeError:
set_ang(ang)
write_root_velocity(handle, vel, ang, env_ids)


def reset_joints_to_default(
Expand Down Expand Up @@ -170,18 +160,7 @@ def push_by_setting_velocity(
if axis in velocity_range:
lo, hi = velocity_range[axis]
ang[:, idx] = torch.empty(n, device=env.device).uniform_(lo, hi)
set_vel = getattr(handle, "set_vel", None)
set_ang = getattr(handle, "set_ang", None)
if set_vel is not None:
try:
set_vel(vel, envs_idx=env_ids)
except TypeError:
set_vel(vel)
if set_ang is not None:
try:
set_ang(ang, envs_idx=env_ids)
except TypeError:
set_ang(ang)
write_root_velocity(handle, vel, ang, env_ids)


def randomize_terrain_params(
Expand Down
39 changes: 39 additions & 0 deletions tests/test_bridges.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,42 @@ class _HeadlessEnv:

bridge.on_build(_HeadlessEnv()) # type: ignore[arg-type]
bridge.pre_step(_HeadlessEnv()) # type: ignore[arg-type]


def test_keyboard_hud_falls_back_to_inner_pyrender_viewer() -> None:
"""Genesis 1.2's outer Viewer forwards register_keybinds but not set_message_text,
so the HUD write must reach the inner ``_pyrender_viewer`` (same wrapper gap as
the ``viewer.plugins`` / ``_viewer_plugins`` fallback in scene)."""
bridge = KeyboardTwistBridge(KeyboardTwistBridgeCfg())

class _InnerViewer:
def __init__(self) -> None:
self.messages: list[str] = []

def set_message_text(self, text: str) -> None:
self.messages.append(text)

class _OuterViewer:
def __init__(self) -> None:
self._pyrender_viewer = _InnerViewer()

viewer = _OuterViewer()
bridge._broadcast_hud(viewer)
assert viewer._pyrender_viewer.messages
assert viewer._pyrender_viewer.messages[0].startswith("teleop")


def test_keyboard_hud_prefers_direct_setter() -> None:
"""A viewer exposing set_message_text directly (older Genesis, fakes) is used as-is."""
bridge = KeyboardTwistBridge(KeyboardTwistBridgeCfg())

class _DirectViewer:
def __init__(self) -> None:
self.messages: list[str] = []

def set_message_text(self, text: str) -> None:
self.messages.append(text)

viewer = _DirectViewer()
bridge._broadcast_hud(viewer)
assert viewer.messages and viewer.messages[0].startswith("teleop")
Loading