diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst new file mode 100644 index 000000000000..fbbfa1fb70f0 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst @@ -0,0 +1,27 @@ +Added +^^^^^ + +* Added the mass schema-fragment API: the :class:`~isaaclab.sim.schemas.MassFragment` marker and + :class:`~isaaclab.sim.schemas.MassCfg` (writes ``physics:mass`` / ``physics:density`` via + ``UsdPhysics.MassAPI``). The legacy :class:`~isaaclab.sim.schemas.MassPropertiesCfg` remains the + canonical name and continues to work unchanged. +* Added :func:`~isaaclab.sim.schemas.apply_mass_properties`, which applies a list of mass fragments + with ``UsdPhysics.MassAPI`` as the implicit anchor. + +Changed +^^^^^^^ + +* Changed the spawner ``mass_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.mass_props`) to also accept a single + :class:`~isaaclab.sim.schemas.MassFragment` or a list of them. Legacy + :class:`~isaaclab.sim.schemas.MassPropertiesCfg` cfgs continue to work through a transition bridge + in the spawn writers. + +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.sim.schemas.apply_mass_properties` to raise ``ValueError`` on an invalid + prim path and to aggregate per-fragment results instead of always returning ``True``, matching + :func:`~isaaclab.sim.schemas.apply_rigid_body_properties`. +* Fixed the spawn writers so an empty ``mass_props`` list is a harmless no-op rather than being + forwarded to :func:`~isaaclab.sim.schemas.define_mass_properties` as an unexpected list. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index e0365d3e759c..dc5d6207e855 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -45,6 +45,8 @@ __all__ = [ "DeformableBodyPropertiesCfg", "FixedTendonPropertiesCfg", "JointDriveBaseCfg", + "MassCfg", + "MassFragment", "MassPropertiesCfg", "MeshCollisionPropertiesCfg", "MujocoJointDrivePropertiesCfg", @@ -62,6 +64,7 @@ __all__ = [ "RigidBodyFragment", "SchemaFragment", "UsdPhysicsRigidBodyCfg", + "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", "SDFMeshPropertiesCfg", @@ -221,6 +224,8 @@ from .schemas import ( DeformableBodyPropertiesCfg, FixedTendonPropertiesCfg, JointDriveBaseCfg, + MassCfg, + MassFragment, MassPropertiesCfg, MeshCollisionPropertiesCfg, PhysxJointDrivePropertiesCfg, @@ -234,6 +239,7 @@ from .schemas import ( TriangleMeshSimplificationPropertiesCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, + apply_mass_properties, apply_namespaced, apply_rigid_body_properties, define_articulation_root_properties, diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py index f814e4fdb877..c9ee2cef3245 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py @@ -12,9 +12,9 @@ from isaacsim.core.experimental.utils.app import enable_extension from pxr import Gf, Tf, Usd, UsdGeom, UsdPhysics, UsdUtils +from isaaclab.sim import schemas from isaaclab.sim.converters.asset_converter_base import AssetConverterBase from isaaclab.sim.converters.mesh_converter_cfg import MeshConverterCfg -from isaaclab.sim.schemas import schemas from isaaclab.sim.utils import delete_prim, export_prim_to_file # import logger @@ -182,9 +182,14 @@ def _convert_asset(self, cfg: MeshConverterCfg): # Apply mass and rigid body properties after everything else # Properties are applied to the top level prim to avoid the case where all instances of this # asset unintentionally share the same rigid body properties - # apply mass properties + # apply mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.mass_props is not None: - schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(str(xform_prim.GetPath()), mass_frags, stage=stage) + else: + schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) # apply rigid body properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.rigid_props is not None: rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index af153a60fc63..a6ea01a293d4 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -8,6 +8,7 @@ __all__ = [ "PHYSX_MESH_COLLISION_CFGS", "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", + "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", "define_actuator_properties", @@ -33,6 +34,8 @@ __all__ = [ "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", "JointDriveBaseCfg", + "MassCfg", + "MassFragment", "MassPropertiesCfg", "MeshCollisionBaseCfg", "RigidBodyFragment", @@ -55,6 +58,7 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_mass_properties, apply_namespaced, apply_rigid_body_properties, define_articulation_root_properties, @@ -84,6 +88,8 @@ from .schemas_cfg import ( DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, JointDriveBaseCfg, + MassCfg, + MassFragment, MassPropertiesCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 6617543cad61..a4a6b9f74c82 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -651,6 +651,39 @@ def modify_collision_properties( """ +def apply_mass_properties( + prim_path: str, fragments: Iterable[schemas_cfg.MassFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of mass fragments to a prim. + + Applies ``UsdPhysics.MassAPI`` as the implicit anchor (the defining schema for mass properties), + then dispatches each fragment via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + + Args: + prim_path: The prim path to apply the mass schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.MassFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the properties were successfully set. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the legacy define_mass_properties writer) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + if not UsdPhysics.MassAPI(prim): + UsdPhysics.MassAPI.Apply(prim) + # aggregate per-fragment results so a reported failure is not masked by the always-applied anchor + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + def define_mass_properties(prim_path: str, cfg: schemas_cfg.MassPropertiesCfg, stage: Usd.Stage | None = None): """Apply the mass schema on the input prim and set its properties. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index ac5d8b22c020..529182915a38 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -405,6 +405,48 @@ class MassPropertiesCfg: """ +@configclass +class MassFragment(SchemaFragment): + """Marker base for mass fragments; types the ``mass_props`` slot.""" + + pass + + +@configclass +class MassCfg(MassFragment): + """``physics:*`` mass attributes from `UsdPhysics.MassAPI`_. + + The ``UsdPhysics.MassAPI`` schema is applied as the implicit anchor by the mass family writer + (:func:`~isaaclab.sim.schemas.apply_mass_properties`), so this fragment owns no applied schema + of its own. Mirrors the legacy :class:`MassPropertiesCfg`. + + .. note:: + A fragment present in a spawner slot means its schema is applied. ``None`` fields are left + unchanged on the prim (partial update). + + .. _UsdPhysics.MassAPI: https://openusd.org/dev/api/class_usd_physics_mass_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # MassAPI applied by the family anchor + + mass: float | None = None + """The mass of the rigid body [kg]. + + Writes ``physics:mass`` via :class:`UsdPhysics.MassAPI`. + + Note: + If ``density`` is non-zero, it takes precedence and is used to compute the mass instead. + """ + + density: float | None = None + """The density of the rigid body [kg/m^3]. + + Writes ``physics:density`` via :class:`UsdPhysics.MassAPI`. The density indirectly defines the + mass of the rigid body. It is generally computed using the collision approximation of the body. + """ + + @configclass class JointDriveBaseCfg: """Solver-common properties to define the drive mechanism of a joint. diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index 4545eb23e115..767a3e14ab48 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -353,9 +353,14 @@ def _spawn_from_usd_file( # modify collision properties if cfg.collision_props is not None: schemas.modify_collision_properties(prim_path, cfg.collision_props) - # modify mass properties + # modify mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> modify_*) if cfg.mass_props is not None: - schemas.modify_mass_properties(prim_path, cfg.mass_props) + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(prim_path, mass_frags) + else: + schemas.modify_mass_properties(prim_path, cfg.mass_props) # modify articulation root properties if cfg.articulation_props is not None: diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index 27f815343c0a..6e930e2530b2 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -441,9 +441,14 @@ def _spawn_mesh_geom_from_mesh( # note: we apply the rigid properties to the parent prim in case of rigid objects. if cfg.rigid_props is not None: - # apply mass properties + # apply mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.mass_props is not None: - schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(prim_path, mass_frags, stage=stage) + else: + schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) # apply rigid properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 0601cbed5411..5d841e1c236b 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -319,7 +319,13 @@ def _spawn_geom_from_prim_type( # note: we apply rigid properties in the end to later make the instanceable prim # apply mass properties if cfg.mass_props is not None: - schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) + # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> define_* + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(prim_path, mass_frags, stage=stage) + else: + schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) # apply rigid body properties if cfg.rigid_props is not None: # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* diff --git a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py index 1d52d451ba1b..d492e0cae1db 100644 --- a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py @@ -82,8 +82,14 @@ class RigidObjectSpawnerCfg(SpawnerCfg): to the prim outside of the properties available by default when spawning the prim. """ - mass_props: schemas.MassPropertiesCfg | None = None - """Mass properties.""" + mass_props: schemas.MassPropertiesCfg | schemas.MassFragment | list[schemas.MassFragment] | None = None + """Mass properties. + + Accepts either a single legacy :class:`~isaaclab.sim.schemas.MassPropertiesCfg` or a list of + :class:`~isaaclab.sim.schemas.MassFragment` fragments (e.g. ``[MassCfg(...)]``). When a fragment + list is given, ``UsdPhysics.MassAPI`` is applied as the implicit anchor and each fragment writes + its own namespace. + """ rigid_props: schemas.RigidBodyBaseCfg | schemas.RigidBodyFragment | list[schemas.RigidBodyFragment] | None = None """Rigid body properties. diff --git a/source/isaaclab/test/sim/test_mass_fragments.py b/source/isaaclab/test/sim/test_mass_fragments.py new file mode 100644 index 000000000000..1e4a28a6121e --- /dev/null +++ b/source/isaaclab/test/sim/test_mass_fragments.py @@ -0,0 +1,183 @@ +# 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 + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import pytest + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_xform(stage, path="/World/Body"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# Fragment metadata -- MassFragment marker, MassCfg +# ------------------------------------------------------------------------------------- + + +def test_fragment_metadata_defaults(): + from isaaclab.sim.schemas import MassCfg, MassFragment, SchemaFragment + + cfg = MassCfg(mass=2.0) + assert isinstance(cfg, MassFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # anchor applies MassAPI, not the fragment + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.mass == 2.0 and cfg.density is None + + +# ------------------------------------------------------------------------------------- +# apply_namespaced writes only the set fields under the physics namespace +# ------------------------------------------------------------------------------------- + + +def test_apply_namespaced_writes_only_set_fields(): + from isaaclab.sim.schemas import MassCfg, apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage) + UsdPhysics.MassAPI.Apply(prim) + apply_namespaced(MassCfg(mass=3.0), "/World/Body", stage) + assert abs(prim.GetAttribute("physics:mass").Get() - 3.0) < 1e-6 + # None field must not be authored (density exists as a MassAPI fallback attr) + assert not prim.GetAttribute("physics:density").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# apply_mass_properties dispatch (implicit MassAPI anchor) +# ------------------------------------------------------------------------------------- + + +def test_apply_mass_properties_applies_anchor_and_writes_fields(): + from isaaclab.sim.schemas import MassCfg, apply_mass_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/B2") + apply_mass_properties("/World/B2", [MassCfg(mass=5.0, density=100.0)], stage) + prim = stage.GetPrimAtPath("/World/B2") + assert bool(UsdPhysics.MassAPI(prim)) # implicit anchor applied + assert abs(prim.GetAttribute("physics:mass").Get() - 5.0) < 1e-6 + assert abs(prim.GetAttribute("physics:density").Get() - 100.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# spawner slot accepts a fragment list + transition routing +# ------------------------------------------------------------------------------------- + + +def test_spawn_shape_with_mass_fragment_list(): + from isaaclab.sim.schemas import MassCfg, UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], + mass_props=[MassCfg(mass=4.0)], + ) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") + assert bool(UsdPhysics.MassAPI(prim)) + assert abs(prim.GetAttribute("physics:mass").Get() - 4.0) < 1e-6 + + +def test_spawn_shape_with_single_mass_fragment(): + # the ``mass_props`` slot advertises a single fragment (convenience form), not only a list; + # the spawn shim must route a bare fragment through ``apply_mass_properties`` (not the legacy writer) + from isaaclab.sim.schemas import MassCfg, UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], + mass_props=MassCfg(mass=4.0), + ) + cfg.func("/World/CubeSingle", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/CubeSingle") + assert bool(UsdPhysics.MassAPI(prim)) + assert abs(prim.GetAttribute("physics:mass").Get() - 4.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# Review follow-ups -- prim-validity guard, aggregated return, empty-list no-op +# ------------------------------------------------------------------------------------- + + +def test_apply_mass_properties_raises_on_invalid_prim(): + from isaaclab.sim.schemas import MassCfg, apply_mass_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # no prim authored at this path -> GetPrimAtPath returns an invalid prim + with pytest.raises(ValueError): + apply_mass_properties("/World/DoesNotExist", [MassCfg(mass=1.0)], stage) + + +def test_apply_mass_properties_aggregates_fragment_results(): + from isaaclab.sim.schemas import MassCfg, apply_mass_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Agg") + + # a fragment whose applier reports failure must make the aggregate return False + failing = MassCfg(mass=1.0) + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_mass_properties("/World/Agg", [failing], stage) is False + + # all-succeeding fragments return True + ok = MassCfg(mass=1.0) + assert apply_mass_properties("/World/Agg", [ok], stage) is True + + +def test_spawn_shape_with_empty_mass_list_is_noop(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], + mass_props=[], + ) + # an empty fragment list routes through the fragment path and applies nothing (no exception) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") + # mass anchor is not required when there are zero fragments to apply + assert not prim.GetAttribute("physics:mass").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab.sim.schemas import ( # noqa: F401 + MassCfg, + MassFragment, + SchemaFragment, + apply_mass_properties, + )