Skip to content
Closed
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
6 changes: 3 additions & 3 deletions docs/source/api/lab/isaaclab.sim.converters.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Mesh Converter
:members:
:inherited-members:
:show-inheritance:
:exclude-members: __init__
:exclude-members: __init__, PhysicsVariant


URDF Converter
Expand All @@ -53,7 +53,7 @@ URDF Converter
:members:
:inherited-members:
:show-inheritance:
:exclude-members: __init__
:exclude-members: __init__, PhysicsVariant

MJCF Converter
--------------
Expand All @@ -67,4 +67,4 @@ MJCF Converter
:members:
:inherited-members:
:show-inheritance:
:exclude-members: __init__
:exclude-members: __init__, PhysicsVariant
6 changes: 2 additions & 4 deletions docs/source/how-to/import_new_asset.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,11 @@ Standalone URDF/MJCF importers
------------------------------

The URDF and MJCF converter scripts can run without Isaac Sim when the standalone
``isaacsim-asset-isolated`` wheel is installed in the active environment. The wheel is not
published on PyPI, so replace ``PACKAGE_INDEX_URL`` with the package index that hosts it:
``isaacsim-asset-isolated`` wheel is installed in the active environment:

.. code-block:: bash

uv pip install "isaacsim-asset-isolated>=6.0,<6.1" \
--extra-index-url "PACKAGE_INDEX_URL"
uv pip install isaacsim-asset-isolated

After installing the wheel, run conversion in the kit-less environment. Optionally pass
``--viz newton`` (or ``rerun`` / ``viser``) to preview the converted asset in a kit-less
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Added
^^^^^

* Added :attr:`~isaaclab.sim.converters.AssetConverterBaseCfg.physics_variant` to choose which
``"Physics"`` variant the URDF and MJCF converters select on the generated USD file. Defaults to
the backend-portable :attr:`~isaaclab.sim.converters.AssetConverterBaseCfg.PhysicsVariant.PHYSICS`;
select :attr:`~isaaclab.sim.converters.AssetConverterBaseCfg.PhysicsVariant.PHYSX` or
:attr:`~isaaclab.sim.converters.AssetConverterBaseCfg.PhysicsVariant.MUJOCO` for solver-specific
tuning, or :attr:`~isaaclab.sim.converters.AssetConverterBaseCfg.PhysicsVariant.NONE` to convert
without physics.

Fixed
^^^^^

* Fixed URDF and MJCF conversion producing assets with no joints, articulation roots, or mass
properties, by selecting a physics variant on the generated USD file. Conversion now raises when
the asset does not offer the requested variant, as happens when requesting ``"physx"`` for a URDF
whose joints are all fixed.

* Fixed :meth:`~isaaclab.utils.dict.class_to_dict` expanding enum values into their internal
members, which wrote unusable entries into serialized configurations.

Changed
^^^^^^^

* Changed :func:`~isaaclab.sim.utils.select_usd_variants` to raise when a variant set listed in
:obj:`~isaaclab.sim.utils.REQUIRED_VARIANT_SETS` is absent from the prim or does not offer the
requested variant. ``"Physics"`` is the only such set today: USD accepts a selection naming a
variant that does not exist and composes the prim as if nothing were selected, so the asset
spawned as plain geometry with no diagnostic. Other variant sets keep the previous behaviour of
logging a warning and continuing.
45 changes: 30 additions & 15 deletions source/isaaclab/isaaclab/sim/converters/asset_converter_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import abc
import hashlib
import json
import logging
import os
import pathlib
import random
Expand All @@ -16,6 +17,8 @@
from isaaclab.utils.assets import check_file_path
from isaaclab.utils.io import dump_yaml

logger = logging.getLogger(__name__)


class AssetConverterBase(abc.ABC):
"""Base class for converting an asset file from different formats into USD format.
Expand Down Expand Up @@ -101,14 +104,15 @@ def __init__(self, cfg: AssetConverterBaseCfg):

# convert the asset to USD if the hash is different or USD file does not exist
if cfg.force_usd_conversion or not self._usd_file_exists or not self._is_same_asset:
# write the updated hash
with open(self._dest_hash_path, "w") as f:
f.write(self._asset_hash)
# convert the asset to USD
self._convert_asset(cfg)
# the importers emit the physics payloads behind a "Physics" variant set but leave it
# unselected, which composes the asset with geometry only
self._select_physics_variant()
# importers put the physics payloads behind a "Physics" variant set and disagree on
# which variant to select, so settle it here
self._select_physics_variant(cfg.physics_variant)
# record the hash only now: writing it earlier would let a conversion that raised
# still count as cached, so an identical retry would skip it and return the asset
with open(self._dest_hash_path, "w") as f:
f.write(self._asset_hash)
# dump the configuration to a file
dump_yaml(os.path.join(self.usd_dir, "config.yaml"), cfg.to_dict())
# add comment to top of the saved config file with information about the converter
Expand Down Expand Up @@ -166,20 +170,25 @@ def _convert_asset(self, cfg: AssetConverterBaseCfg):
Private helpers.
"""

def _select_physics_variant(self, variant: str = "physx"):
def _select_physics_variant(self, variant: str):
"""Author a selection for the ``"Physics"`` variant set on the converted asset.

The URDF and MJCF importers write the physics description into a ``"Physics"`` variant set
without selecting a variant, so opening the asset composes it without joints, articulation
roots, or mass properties. Selecting a variant here restores physics for every consumer. The
selection is authored on the asset, so a spawner that selects a different variant on the
referencing prim still wins.
Importers put the physics description behind a ``"Physics"`` variant set, and which variant
they select is not consistent: the Isaac Sim importer extensions leave the set unselected,
which composes the asset without joints, articulation roots, or mass properties, while the
standalone importer wheel selects one of its own. Authoring the configured variant here makes
the outcome the same either way. The selection is authored on the asset, so a spawner that
selects a different variant on the referencing prim still wins.

Does nothing when the asset has no such variant set or the importer already authored a
selection.
Does nothing when the asset has no such variant set.

Args:
variant: The variant to select.

Raises:
ValueError: When the asset offers a ``"Physics"`` variant set without the requested
variant. Substituting another one would silently hand back an asset configured for a
different backend than the caller asked for.
"""
from pxr import Usd

Expand All @@ -188,7 +197,13 @@ def _select_physics_variant(self, variant: str = "physx"):
if not prim or "Physics" not in prim.GetVariantSets().GetNames():
return
variant_set = prim.GetVariantSets().GetVariantSet("Physics")
if variant_set.GetVariantSelection() or variant not in variant_set.GetVariantNames():
available = variant_set.GetVariantNames()
if variant not in available:
raise ValueError(
f"The converted asset has no '{variant}' physics variant. Set"
f" {type(self.cfg).__name__}.physics_variant to one of: {available}."
)
if variant == variant_set.GetVariantSelection():
return
variant_set.SetVariantSelection(variant)
stage.GetRootLayer().Save()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

from dataclasses import MISSING
from enum import StrEnum

from isaaclab.utils.configclass import configclass

Expand All @@ -14,6 +15,21 @@
class AssetConverterBaseCfg:
"""The base configuration class for asset converters."""

class PhysicsVariant(StrEnum):
"""Variants offered by the ``"Physics"`` variant set that the URDF and MJCF importers author."""

PHYSICS = "physics"
"""Backend-portable physics: joints, articulation roots, mass, and the Newton schemas."""

PHYSX = "physx"
""":attr:`PHYSICS` plus PhysX-specific tuning."""

MUJOCO = "mujoco"
""":attr:`PHYSICS` plus MuJoCo-specific tuning."""

NONE = "none"
"""No physics at all."""

asset_path: str = MISSING
"""The absolute path to the asset file to convert into USD."""

Expand Down Expand Up @@ -48,3 +64,26 @@ class AssetConverterBaseCfg:
used in the scene. For more information, please check the USD documentation on
`scene-graph instancing <https://openusd.org/dev/api/_usd__page__scenegraph_instancing.html>`_.
"""

physics_variant: PhysicsVariant | str = PhysicsVariant.PHYSICS
"""The ``"Physics"`` variant to select on the generated USD file. Defaults to
:attr:`PhysicsVariant.PHYSICS`.

The URDF and MJCF importers emit physics as payloads behind a ``"Physics"`` variant set, and
disagree on which variant to select: the Isaac Sim importer extensions leave the set unselected,
which composes the asset without joints, articulation roots, or mass properties, while the
standalone importer wheel picks one of its own. The converter authors this selection so that the
asset carries the same physics either way.

The default holds the backend-portable description, and :attr:`PhysicsVariant.PHYSX` /
:attr:`PhysicsVariant.MUJOCO` sublayer it, so select one of those to author an asset for a
specific backend. When the asset offers the variant set but not the requested variant,
conversion raises rather than substituting one authored for a different backend.

This applies only to assets that carry a ``"Physics"`` variant set. Conversions that emit a
flat asset instead, such as the URDF and MJCF converters with ``run_asset_transformer`` set to
False, keep whatever physics the importer wrote and ignore this field.

Every variant stays in the generated USD file, so this only decides what composes by default:
:attr:`~isaaclab.sim.UsdFileCfg.variants` overrides the selection at spawn time.
"""
2 changes: 2 additions & 0 deletions source/isaaclab/isaaclab/sim/utils/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ __all__ = [
"add_usd_reference",
"get_usd_references",
"select_usd_variants",
"REQUIRED_VARIANT_SETS",
"get_next_free_prim_path",
"get_first_matching_ancestor_prim",
"get_first_matching_child_prim",
Expand Down Expand Up @@ -95,6 +96,7 @@ from .prims import (
bind_physics_material,
add_usd_reference,
get_usd_references,
REQUIRED_VARIANT_SETS,
select_usd_variants,
)
from .queries import (
Expand Down
35 changes: 33 additions & 2 deletions source/isaaclab/isaaclab/sim/utils/prims.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@
# import logger
logger = logging.getLogger(__name__)

REQUIRED_VARIANT_SETS = frozenset({"Physics"})
"""Variant sets whose absence is an error rather than a skip.

Selecting a variant set the asset does not have is normally harmless -- one spawn configuration
may cover assets that expose different options, such as a payload or a colour, so a missing set is
skipped with a warning. The sets listed here instead carry a description the asset is unusable
without: ``"Physics"`` holds the joints, articulation roots, and mass properties that the URDF and
MJCF importers author as payloads, and an unselected variant set contributes nothing, so the asset
would spawn as plain geometry with no indication that anything was lost.
"""


"""
General Utils
Expand Down Expand Up @@ -1033,13 +1044,18 @@ class TableVariants:
variants=TableVariants(),
)

A variant set the prim does not have is skipped with a warning, so one configuration can spawn
assets that expose different options. Sets named in :obj:`REQUIRED_VARIANT_SETS` are the
exception: they carry a description the asset is unusable without, so they raise instead.

Args:
prim_path: The path of the USD prim.
variants: A dictionary or config class mapping variant set names to variant selections.
stage: The USD stage. Defaults to None, in which case, the current stage is used.

Raises:
ValueError: If the prim at the specified path is not valid.
ValueError: If the prim at the specified path is not valid, or if a variant set in
:obj:`REQUIRED_VARIANT_SETS` is absent or lacks the requested variant.

.. _USD Variants: https://graphics.pixar.com/usd/docs/USD-Glossary.html#USDGlossary-Variant
"""
Expand All @@ -1057,12 +1073,27 @@ class TableVariants:

existing_variant_sets = prim.GetVariantSets()
for variant_set_name, variant_selection in variants.items(): # type: ignore
required = variant_set_name in REQUIRED_VARIANT_SETS
# Check if the variant set exists on the prim.
if not existing_variant_sets.HasVariantSet(variant_set_name):
logger.warning(f"Variant set '{variant_set_name}' does not exist on prim '{prim_path}'.")
message = (
f"Variant set '{variant_set_name}' does not exist on prim '{prim_path}'."
f" Available: {existing_variant_sets.GetNames()}."
)
if required:
raise ValueError(message)
logger.warning(message)
continue

variant_set = existing_variant_sets.GetVariantSet(variant_set_name)
# USD accepts a selection naming a variant the set does not offer, and the prim then
# composes as if nothing were selected. For a required set that silently drops the
# description it exists to carry, so reject it instead.
if required and variant_selection not in variant_set.GetVariantNames():
raise ValueError(
f"Variant set '{variant_set_name}' on prim '{prim_path}' has no variant"
f" '{variant_selection}'. Available: {variant_set.GetVariantNames()}."
)
# Only set the variant selection if it is different from the current selection.
if variant_set.GetVariantSelection() != variant_selection:
variant_set.SetVariantSelection(variant_selection)
Expand Down
9 changes: 9 additions & 0 deletions source/isaaclab/isaaclab/utils/dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import hashlib
import json
from collections.abc import Iterable, Mapping, Sized
from enum import Enum
from typing import Any

import torch
Expand Down Expand Up @@ -42,6 +43,9 @@ def class_to_dict(obj: object) -> dict[str, Any]:
# ResolvableString is a str subclass — serialize as plain str so OmegaConf accepts it.
if isinstance(obj, ResolvableString):
return str(obj)
# Enum members carry a ``__dict__`` of internals, so serialize the value they stand for.
if isinstance(obj, Enum):
return obj.value
# convert object to dictionary
if isinstance(obj, dict):
obj_dict = obj
Expand Down Expand Up @@ -158,6 +162,11 @@ def update_class_from_dict(obj, data: dict[str, Any], _ns: str = "") -> None:
f" Expected callable or callable-string, Received: {type(value)}."
)

# -- 3b) enum attribute → rebuild the member from its value ------------
elif isinstance(obj_mem, Enum) and value is not None:
# class_to_dict serializes members as their value, so restore the member here
value = type(obj_mem)(value)

# -- 4) simple scalar / explicit None ---------------------
elif value is None or isinstance(value, type(obj_mem)):
pass
Expand Down
Loading
Loading