Source code for isaaclab.sim.converters.asset_converter_base

# 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

import abc
import hashlib
import json
import logging
import os
import pathlib
import random
import tempfile
from datetime import datetime

from isaaclab.sim.converters.asset_converter_base_cfg import AssetConverterBaseCfg
from isaaclab.utils.assets import check_file_path
from isaaclab.utils.io import dump_yaml

logger = logging.getLogger(__name__)


[docs] class AssetConverterBase(abc.ABC): """Base class for converting an asset file from different formats into USD format. This class provides a common interface for converting an asset file into USD. It does not provide any implementation for the conversion. The derived classes must implement the :meth:`_convert_asset` method to provide the actual conversion. The file conversion is lazy if the output directory (:obj:`AssetConverterBaseCfg.usd_dir`) is provided. In the lazy conversion, the USD file is re-generated only if: * The asset file is modified. * The configuration parameters are modified. * The USD file does not exist. To override this behavior to force conversion, the flag :obj:`AssetConverterBaseCfg.force_usd_conversion` can be set to True. When no output directory is defined, lazy conversion is deactivated and the generated USD file is stored in folder ``<tempdir>/IsaacLab/usd_{date}_{time}_{random}``, where ``<tempdir>`` is the system temporary directory (e.g. ``/tmp`` on POSIX, ``%TEMP%`` on Windows) and the parameters in braces are generated at runtime. The random identifiers help avoid a race condition where two simultaneously triggered conversions try to use the same directory for reading/writing the generated files. .. note:: Changes to the parameters :obj:`AssetConverterBaseCfg.asset_path`, :obj:`AssetConverterBaseCfg.usd_dir`, and :obj:`AssetConverterBaseCfg.usd_file_name` are not considered as modifications in the configuration instance that trigger the USD file re-generation. """
[docs] def __init__(self, cfg: AssetConverterBaseCfg): """Initializes the class. Args: cfg: The configuration instance for converting an asset file to USD format. Raises: ValueError: When provided asset file does not exist. """ # check that the config is valid cfg.validate() # check if the asset file exists if not check_file_path(cfg.asset_path): raise ValueError(f"The asset path does not exist: {cfg.asset_path}") # save the inputs self.cfg = cfg # resolve USD directory name if cfg.usd_dir is None: # a folder in the system temp dir by the name: IsaacLab/usd_{date}_{time}_{random} time_tag = datetime.now().strftime("%Y%m%d_%H%M%S") self._usd_dir = os.path.join(tempfile.gettempdir(), "IsaacLab", f"usd_{time_tag}_{random.randrange(10000)}") else: self._usd_dir = cfg.usd_dir # resolve the file name from asset file name if not provided if cfg.usd_file_name is None: usd_file_name = pathlib.PurePath(cfg.asset_path).stem else: usd_file_name = cfg.usd_file_name # add USD extension if not provided if not (usd_file_name.endswith(".usd") or usd_file_name.endswith(".usda")): self._usd_file_name = usd_file_name + ".usd" else: self._usd_file_name = usd_file_name # create the USD directory os.makedirs(self.usd_dir, exist_ok=True) # check if usd files exist self._usd_file_exists = os.path.isfile(self.usd_path) # path to read/write asset hash file self._dest_hash_path = os.path.join(self.usd_dir, ".asset_hash") # create asset hash to check if the asset has changed self._asset_hash = self._config_to_hash(cfg) # read the saved hash try: with open(self._dest_hash_path) as f: existing_asset_hash = f.readline() self._is_same_asset = existing_asset_hash == self._asset_hash except FileNotFoundError: self._is_same_asset = False # 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: # convert the asset to USD self._convert_asset(cfg) # 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 current_date = datetime.now().strftime("%Y-%m-%d") current_time = datetime.now().strftime("%H:%M:%S") generation_comment = ( f"##\n# Generated by {self.__class__.__name__} on {current_date} at {current_time}.\n##\n" ) with open(os.path.join(self.usd_dir, "config.yaml"), "a") as f: f.write(generation_comment)
""" Properties. """ @property def usd_dir(self) -> str: """The absolute path to the directory where the generated USD files are stored.""" return self._usd_dir @property def usd_file_name(self) -> str: """The file name of the generated USD file.""" return self._usd_file_name @property def usd_path(self) -> str: """The absolute path to the generated USD file.""" return os.path.join(self.usd_dir, self.usd_file_name) @property def usd_instanceable_meshes_path(self) -> str: """The relative path to the USD file with meshes. The path is with respect to the USD directory :attr:`usd_dir`. This is to ensure that the mesh references in the generated USD file are resolved relatively. Otherwise, it becomes difficult to move the USD asset to a different location. """ return os.path.join(".", "Props", "instanceable_meshes.usd") """ Implementation specifics. """ @abc.abstractmethod def _convert_asset(self, cfg: AssetConverterBaseCfg): """Converts the asset file to USD. Args: cfg: The configuration instance for the input asset to USD conversion. """ raise NotImplementedError() """ Private helpers. """ def _select_physics_variant(self, variant: str): """Author a selection for the ``"Physics"`` variant set on the converted asset. 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. 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 stage = Usd.Stage.Open(self.usd_path) prim = stage.GetDefaultPrim() if not prim or "Physics" not in prim.GetVariantSets().GetNames(): return variant_set = prim.GetVariantSets().GetVariantSet("Physics") 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() @staticmethod def _config_to_hash(cfg: AssetConverterBaseCfg) -> str: """Converts the configuration object and asset file to an MD5 hash of a string. .. warning:: It only checks the main asset file (:attr:`cfg.asset_path`). Args: config : The asset converter configuration object. Returns: An MD5 hash of a string. """ # convert to dict and remove path related info config_dic = cfg.to_dict() _ = config_dic.pop("asset_path") _ = config_dic.pop("usd_dir") _ = config_dic.pop("usd_file_name") # convert config dic to bytes config_bytes = json.dumps(config_dic).encode() # hash config md5 = hashlib.md5() md5.update(config_bytes) # read the asset file to observe changes with open(cfg.asset_path, "rb") as f: while True: # read 64kb chunks to avoid memory issues for the large files! data = f.read(65536) if not data: break md5.update(data) # return the hash return md5.hexdigest()