Skip to content
Draft
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
5 changes: 5 additions & 0 deletions source/isaaclab/changelog.d/articulation-ordering.major.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Added
^^^^^

* Added articulation ordering utilities and optional :class:`~isaaclab.assets.ArticulationCfg`
fields for public joint/body ordering presets.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from isaaclab.utils.configclass import configclass

from ..asset_base_cfg import AssetBaseCfg
from .ordering import ArticulationOrderingConvention

if TYPE_CHECKING:
from .articulation import Articulation
Expand Down Expand Up @@ -68,6 +69,22 @@ class InitialStateCfg(AssetBaseCfg.InitialStateCfg):
The soft joint position limits are accessible through the :attr:`ArticulationData.soft_joint_pos_limits` attribute.
"""

joint_ordering: list[str] | tuple[str, ...] | str | ArticulationOrderingConvention | None = None
"""Optional public joint ordering convention or complete joint-name permutation.

If ``None``, the public joint order follows the active backend order and preserves
the direct identity path. String aliases currently accept ``"physx"`` and
``"mjwarp"``.
"""

body_ordering: list[str] | tuple[str, ...] | str | ArticulationOrderingConvention | None = None
"""Optional public body ordering convention or complete body-name permutation.

If ``None``, the public body order follows the active backend order and preserves
the direct identity path. String aliases currently accept ``"physx"`` and
``"mjwarp"``.
"""

actuators: dict[str, ActuatorBaseCfg] = MISSING
"""Actuators for the robot with corresponding joint names."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from ...sim import SimulationContext
from ...utils.leapp.leapp_semantics import OutputKindEnum, joint_names_resolver, leapp_tensor_semantics
from ..asset_base import AssetBase
from .ordering import ArticulationNameMap, build_articulation_name_map
from .ordering_resolvers import resolve_articulation_ordering_names

if TYPE_CHECKING:
from isaaclab.utils.wrench_composer import WrenchComposer
Expand Down Expand Up @@ -167,6 +169,30 @@ def body_names(self) -> list[str]:
"""Ordered names of bodies in articulation."""
raise NotImplementedError()

@property
@abstractmethod
def backend_joint_names(self) -> list[str]:
"""Ordered names of joints as exposed by the active backend."""
raise NotImplementedError()

@property
@abstractmethod
def backend_body_names(self) -> list[str]:
"""Ordered names of bodies as exposed by the active backend."""
raise NotImplementedError()

@property
@abstractmethod
def joint_ordering(self) -> ArticulationNameMap | None:
"""Mapping between backend and public joint order."""
raise NotImplementedError()

@property
@abstractmethod
def body_ordering(self) -> ArticulationNameMap | None:
"""Mapping between backend and public body order."""
raise NotImplementedError()

@property
@abstractmethod
def root_view(self):
Expand All @@ -177,6 +203,63 @@ def root_view(self):
"""
raise NotImplementedError()

def _resolve_and_install_ordering_maps(self) -> None:
"""Resolve configured articulation name orderings and store maps on :attr:`data`."""
joint_user_names = resolve_articulation_ordering_names(
kind="joint",
backend_names=self.backend_joint_names,
ordering=self.cfg.joint_ordering,
active_backend_name=self.__backend_name__,
articulation=self,
)
body_user_names = resolve_articulation_ordering_names(
kind="body",
backend_names=self.backend_body_names,
ordering=self.cfg.body_ordering,
active_backend_name=self.__backend_name__,
articulation=self,
)

self.data.joint_ordering = build_articulation_name_map(
kind="joint",
backend_names=self.backend_joint_names,
user_names=joint_user_names,
device=self.device,
)
self.data.body_ordering = build_articulation_name_map(
kind="body",
backend_names=self.backend_body_names,
user_names=body_user_names,
device=self.device,
)
self.data.joint_names = list(self.data.joint_ordering.user_names)
self.data.body_names = list(self.data.body_ordering.user_names)
apply_ordering_maps = getattr(self.data, "_apply_ordering_maps_after_resolve", None)
if apply_ordering_maps is not None:
apply_ordering_maps()

def _cache_ordering_maps(self) -> None:
"""Cache ordering maps used by hot write paths."""
joint_ordering = self.data.joint_ordering
if joint_ordering is None:
self._joint_user_to_backend = self._ALL_JOINT_INDICES
self._joint_backend_to_user = self._ALL_JOINT_INDICES
self._has_joint_ordering = False
else:
self._joint_user_to_backend = joint_ordering.user_to_backend
self._joint_backend_to_user = joint_ordering.backend_to_user
self._has_joint_ordering = not joint_ordering.is_identity

body_ordering = self.data.body_ordering
if body_ordering is None:
self._body_user_to_backend = self._ALL_BODY_INDICES
self._body_backend_to_user = self._ALL_BODY_INDICES
self._has_body_ordering = False
else:
self._body_user_to_backend = body_ordering.user_to_backend
self._body_backend_to_user = body_ordering.backend_to_user
self._has_body_ordering = not body_ordering.is_identity

@property
def num_base_dofs(self) -> int:
"""Number of free DoFs of the floating base.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
#
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import warnings
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

import warp as wp

Expand All @@ -23,6 +26,9 @@
)
from isaaclab.utils.warp import ProxyArray

if TYPE_CHECKING:
from .ordering import ArticulationNameMap


class BaseArticulationData(ABC):
"""Data container for an articulation.
Expand Down Expand Up @@ -94,6 +100,12 @@ def _reset_velocity(self, from_com: bool = True) -> None:
joint_names: list[str] | None = None
"""Joint names in the order parsed by the simulation view."""

joint_ordering: ArticulationNameMap | None = None
"""Mapping between backend and public joint order, if ordering has been resolved."""

body_ordering: ArticulationNameMap | None = None
"""Mapping between backend and public body order, if ordering has been resolved."""

fixed_tendon_names: list[str] | None = None
"""Fixed tendon names in the order parsed by the simulation view."""

Expand Down
166 changes: 166 additions & 0 deletions source/isaaclab/isaaclab/assets/articulation/ordering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Literal

import numpy as np
import warp as wp

if TYPE_CHECKING:
from .articulation_cfg import ArticulationCfg


class ArticulationOrderingConvention(str, Enum):
"""Built-in non-default articulation name ordering conventions."""

PHYSX = "physx"
MJWARP = "mjwarp"
ROBOT_SCHEMA = "robot_schema"


@dataclass(frozen=True)
class ArticulationNameMap:
"""Mapping between backend and user articulation name order."""

kind: Literal["joint", "body"]
"""Mapped articulation element kind."""

backend_names: tuple[str, ...]
"""Names in the order exposed by the active backend."""

user_names: tuple[str, ...]
"""Names in the order exposed by the public articulation API."""

user_to_backend_indices: tuple[int, ...]
"""CPU map from public user index to backend index."""

backend_to_user_indices: tuple[int, ...]
"""CPU map from backend index to public user index."""

user_to_backend: wp.array(dtype=wp.int32)
"""Device map from public user index to backend index."""

backend_to_user: wp.array(dtype=wp.int32)
"""Device map from backend index to public user index."""

is_identity: bool
"""Whether user and backend name order are identical."""


def parse_articulation_ordering_convention(
ordering: str | ArticulationOrderingConvention | None,
) -> ArticulationOrderingConvention | None:
"""Parse a symbolic articulation ordering convention.

Args:
ordering: Ordering convention alias, enum value, or ``None``.

Returns:
Parsed ordering convention, or ``None`` when no convention is requested.

Raises:
ValueError: If :paramref:`ordering` is an unsupported string alias.
TypeError: If :paramref:`ordering` is not a supported type.
"""
if ordering is None:
return None
if isinstance(ordering, ArticulationOrderingConvention):
return ordering
if isinstance(ordering, str):
try:
return ArticulationOrderingConvention(ordering.lower())
except ValueError as exc:
valid_values = ", ".join(convention.value for convention in ArticulationOrderingConvention)
raise ValueError(
f"Unsupported articulation ordering convention '{ordering}'. Expected one of: {valid_values}."
) from exc
raise TypeError(
"Articulation ordering convention must be a string, "
f"{ArticulationOrderingConvention.__name__}, or None. Got {type(ordering).__name__}."
)


def apply_articulation_ordering_preset(
cfg: ArticulationCfg,
ordering: str | ArticulationOrderingConvention | None,
) -> ArticulationCfg:
"""Return ``cfg`` with one ordering preset applied to joints and bodies.

Args:
cfg: Articulation configuration to copy.
ordering: Ordering convention alias, enum value, or ``None``.

Returns:
A copy of :paramref:`cfg` with :attr:`joint_ordering` and
:attr:`body_ordering` set to the parsed convention. If
:paramref:`ordering` is ``None``, returns :paramref:`cfg` unchanged.
"""
parsed_ordering = parse_articulation_ordering_convention(ordering)
if parsed_ordering is None:
return cfg
return cfg.replace(joint_ordering=parsed_ordering, body_ordering=parsed_ordering)


def build_articulation_name_map(
*,
kind: Literal["joint", "body"],
backend_names: Sequence[str],
user_names: Sequence[str] | None,
device: str,
) -> ArticulationNameMap:
"""Build maps between backend and public articulation name order.

Args:
kind: Articulation element kind.
backend_names: Names in the order exposed by the active backend.
user_names: Optional complete public ordering permutation. If ``None``,
the backend order is used.
device: Device where Warp map arrays are allocated.

Returns:
Mapping metadata and optional Warp arrays for non-identity ordering.

Raises:
ValueError: If names are duplicated or :paramref:`user_names` is not a
complete permutation of :paramref:`backend_names`.
"""
backend_names = tuple(backend_names)
user_names = backend_names if user_names is None else tuple(user_names)

if len(set(backend_names)) != len(backend_names):
raise ValueError(f"Duplicate backend {kind} names are not supported: {backend_names}.")
if len(set(user_names)) != len(user_names):
raise ValueError(f"Duplicate requested {kind} names are not supported: {user_names}.")

backend_name_set = set(backend_names)
user_name_set = set(user_names)
if user_name_set != backend_name_set:
missing = sorted(backend_name_set - user_name_set)
extra = sorted(user_name_set - backend_name_set)
raise ValueError(
f"Requested {kind} names must be a complete permutation of backend names. Missing={missing}, extra={extra}."
)

backend_index_by_name = {name: index for index, name in enumerate(backend_names)}
user_to_backend_np = np.asarray([backend_index_by_name[name] for name in user_names], dtype=np.int32)
backend_to_user_np = np.empty_like(user_to_backend_np)
backend_to_user_np[user_to_backend_np] = np.arange(len(user_names), dtype=np.int32)
is_identity = user_names == backend_names

return ArticulationNameMap(
kind=kind,
backend_names=backend_names,
user_names=user_names,
user_to_backend_indices=tuple(int(index) for index in user_to_backend_np),
backend_to_user_indices=tuple(int(index) for index in backend_to_user_np),
user_to_backend=wp.array(user_to_backend_np, dtype=wp.int32, device=device),
backend_to_user=wp.array(backend_to_user_np, dtype=wp.int32, device=device),
is_identity=is_identity,
)
Loading
Loading