From 2a7027d8b2937011710de556cce19ad02644f67d Mon Sep 17 00:00:00 2001 From: tstuyck Date: Wed, 29 Jul 2026 17:49:55 -0700 Subject: [PATCH 1/2] Add Franka soft lift scene Signed-off-by: tstuyck --- isaaclab_arena/assets/object_library.py | 80 +++++ .../usd/franka_soft_lift_block_tet.usda | 15 + .../usd/generate_deformable_tet_meshes.py | 3 + isaaclab_arena/embodiments/franka/franka.py | 79 ++++ .../metrics/deformable_goal_reached_rate.py | 143 ++++++++ isaaclab_arena/tasks/franka_soft_lift_task.py | 232 ++++++++++++ isaaclab_arena/tasks/task_library.py | 1 + isaaclab_arena/tests/test_franka_soft_lift.py | 339 ++++++++++++++++++ .../franka_soft_lift_environment.py | 87 +++++ 9 files changed, 979 insertions(+) create mode 100644 isaaclab_arena/assets/usd/franka_soft_lift_block_tet.usda create mode 100644 isaaclab_arena/metrics/deformable_goal_reached_rate.py create mode 100644 isaaclab_arena/tasks/franka_soft_lift_task.py create mode 100644 isaaclab_arena/tests/test_franka_soft_lift.py create mode 100644 isaaclab_arena_environments/franka_soft_lift_environment.py diff --git a/isaaclab_arena/assets/object_library.py b/isaaclab_arena/assets/object_library.py index 69d04e4576..a4b255ed49 100644 --- a/isaaclab_arena/assets/object_library.py +++ b/isaaclab_arena/assets/object_library.py @@ -50,6 +50,16 @@ _DEFORMABLE_CUBE_TET_USD = str(_LOCAL_ASSET_DIR / "procedural_deformable_cube_tet.usda") _DEFORMABLE_VOLUME_BLOCK_TET_USD = str(_LOCAL_ASSET_DIR / "procedural_deformable_volume_block_tet.usda") _DEFORMABLE_CABLE_TET_USD = str(_LOCAL_ASSET_DIR / "procedural_deformable_cable_tet.usda") +_FRANKA_SOFT_LIFT_BLOCK_TET_USD = str(_LOCAL_ASSET_DIR / "franka_soft_lift_block_tet.usda") + +_FRANKA_SOFT_LIFT_YOUNGS_MODULUS = 8.0e4 +_FRANKA_SOFT_LIFT_POISSONS_RATIO = 0.25 +_FRANKA_SOFT_LIFT_BLOCK_SIZE = (0.3, 0.05, 0.05) +_FRANKA_SOFT_LIFT_BLOCK_INITIAL_POSE = Pose(position_xyz=(0.5, 0.0, 0.05)) +_FRANKA_SOFT_LIFT_TABLE_INITIAL_POSE = Pose( + position_xyz=(0.5, 0.0, 0.0), + rotation_xyzw=(0.0, 0.0, 0.707, 0.707), +) class LibraryObject(Object): @@ -346,6 +356,31 @@ def __init__( ) +@register_asset +class FrankaSoftLiftTable(LibraryObject): + """Seattle lab table used by Isaac-Lift-Soft-Franka.""" + + name = "franka_soft_lift_table" + tags = ["object", "table", "franka_soft_lift"] + usd_path = None + table_usd_path = f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/SeattleLabTable/table_instanceable.usd" + object_type = ObjectType.BASE + default_prim_path = "{ENV_REGEX_NS}/Table" + + def __init__( + self, + instance_name: str | None = None, + prim_path: str | None = default_prim_path, + initial_pose: Pose | None = None, + ): + super().__init__( + instance_name=instance_name, + prim_path=prim_path, + initial_pose=initial_pose if initial_pose is not None else _FRANKA_SOFT_LIFT_TABLE_INITIAL_POSE, + spawner_cfg=sim_utils.UsdFileCfg(usd_path=self.table_usd_path), + ) + + @register_asset class Sphere(LibraryObject): """ @@ -512,6 +547,51 @@ def __init__( ) +@register_asset +class FrankaSoftLiftBlock(DeformableObject): + """Volume-deformable cuboid used by Isaac-Lift-Soft-Franka.""" + + name = "franka_soft_lift_block" + tags = ["object", "procedural", "deformable", "volume", "franka_soft_lift"] + default_prim_path = "{ENV_REGEX_NS}/Deformable" + + def __init__( + self, + instance_name: str | None = None, + prim_path: str | None = None, + initial_pose: Pose | None = None, + ): + from isaaclab_arena.variations.deformable_initial_pose_variation import DeformableInitialPoseVariation + + half_extents = tuple(size * 0.5 for size in _FRANKA_SOFT_LIFT_BLOCK_SIZE) + super().__init__( + name=instance_name if instance_name is not None else self.name, + tags=self.tags, + prim_path=prim_path if prim_path is not None else self.default_prim_path, + usd_path=_FRANKA_SOFT_LIFT_BLOCK_TET_USD, + material=DeformableMaterial( + youngs_modulus=_FRANKA_SOFT_LIFT_YOUNGS_MODULUS, + poissons_ratio=_FRANKA_SOFT_LIFT_POISSONS_RATIO, + density=300.0, + physx=PhysxDeformableTuning( + rest_offset=None, + contact_offset=None, + linear_damping=None, + static_friction=10.0, + dynamic_friction=5.0, + ), + newton=NewtonDeformableTuning(particle_radius=0.01), + ), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.95, 0.85, 0.1)), + local_bounding_box=AxisAlignedBoundingBox( + min_point=tuple(-extent for extent in half_extents), + max_point=half_extents, + ), + initial_pose=initial_pose if initial_pose is not None else _FRANKA_SOFT_LIFT_BLOCK_INITIAL_POSE, + ) + self.add_variation(DeformableInitialPoseVariation(self.name)) + + _PROCEDURAL_DEFORMABLE_CLOTH_SIZE = (0.22, 0.22) _PROCEDURAL_DEFORMABLE_CABLE_LENGTH = 0.4 _PROCEDURAL_DEFORMABLE_CABLE_RADIUS = 0.012 diff --git a/isaaclab_arena/assets/usd/franka_soft_lift_block_tet.usda b/isaaclab_arena/assets/usd/franka_soft_lift_block_tet.usda new file mode 100644 index 0000000000..bb583692c7 --- /dev/null +++ b/isaaclab_arena/assets/usd/franka_soft_lift_block_tet.usda @@ -0,0 +1,15 @@ +#usda 1.0 +( + defaultPrim = "FrankaSoftLiftBlock" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "FrankaSoftLiftBlock" +{ + def TetMesh "sim_mesh" + { + point3f[] points = [(-0.15, -0.025, -0.025), (-0.15, -0.025, 0.025), (-0.15, 0.025, -0.025), (-0.15, 0.025, 0.025), (-0.13, -0.025, -0.025), (-0.13, -0.025, 0.025), (-0.13, 0.025, -0.025), (-0.13, 0.025, 0.025), (-0.11, -0.025, -0.025), (-0.11, -0.025, 0.025), (-0.11, 0.025, -0.025), (-0.11, 0.025, 0.025), (-0.09, -0.025, -0.025), (-0.09, -0.025, 0.025), (-0.09, 0.025, -0.025), (-0.09, 0.025, 0.025), (-0.07, -0.025, -0.025), (-0.07, -0.025, 0.025), (-0.07, 0.025, -0.025), (-0.07, 0.025, 0.025), (-0.05, -0.025, -0.025), (-0.05, -0.025, 0.025), (-0.05, 0.025, -0.025), (-0.05, 0.025, 0.025), (-0.03, -0.025, -0.025), (-0.03, -0.025, 0.025), (-0.03, 0.025, -0.025), (-0.03, 0.025, 0.025), (-0.01, -0.025, -0.025), (-0.01, -0.025, 0.025), (-0.01, 0.025, -0.025), (-0.01, 0.025, 0.025), (0.01, -0.025, -0.025), (0.01, -0.025, 0.025), (0.01, 0.025, -0.025), (0.01, 0.025, 0.025), (0.03, -0.025, -0.025), (0.03, -0.025, 0.025), (0.03, 0.025, -0.025), (0.03, 0.025, 0.025), (0.05, -0.025, -0.025), (0.05, -0.025, 0.025), (0.05, 0.025, -0.025), (0.05, 0.025, 0.025), (0.07, -0.025, -0.025), (0.07, -0.025, 0.025), (0.07, 0.025, -0.025), (0.07, 0.025, 0.025), (0.09, -0.025, -0.025), (0.09, -0.025, 0.025), (0.09, 0.025, -0.025), (0.09, 0.025, 0.025), (0.11, -0.025, -0.025), (0.11, -0.025, 0.025), (0.11, 0.025, -0.025), (0.11, 0.025, 0.025), (0.13, -0.025, -0.025), (0.13, -0.025, 0.025), (0.13, 0.025, -0.025), (0.13, 0.025, 0.025), (0.15, -0.025, -0.025), (0.15, -0.025, 0.025), (0.15, 0.025, -0.025), (0.15, 0.025, 0.025)] + int4[] tetVertexIndices = [(0, 1, 7, 3), (0, 3, 7, 2), (0, 2, 7, 6), (0, 6, 7, 4), (0, 4, 7, 5), (0, 5, 7, 1), (4, 5, 11, 7), (4, 7, 11, 6), (4, 6, 11, 10), (4, 10, 11, 8), (4, 8, 11, 9), (4, 9, 11, 5), (8, 9, 15, 11), (8, 11, 15, 10), (8, 10, 15, 14), (8, 14, 15, 12), (8, 12, 15, 13), (8, 13, 15, 9), (12, 13, 19, 15), (12, 15, 19, 14), (12, 14, 19, 18), (12, 18, 19, 16), (12, 16, 19, 17), (12, 17, 19, 13), (16, 17, 23, 19), (16, 19, 23, 18), (16, 18, 23, 22), (16, 22, 23, 20), (16, 20, 23, 21), (16, 21, 23, 17), (20, 21, 27, 23), (20, 23, 27, 22), (20, 22, 27, 26), (20, 26, 27, 24), (20, 24, 27, 25), (20, 25, 27, 21), (24, 25, 31, 27), (24, 27, 31, 26), (24, 26, 31, 30), (24, 30, 31, 28), (24, 28, 31, 29), (24, 29, 31, 25), (28, 29, 35, 31), (28, 31, 35, 30), (28, 30, 35, 34), (28, 34, 35, 32), (28, 32, 35, 33), (28, 33, 35, 29), (32, 33, 39, 35), (32, 35, 39, 34), (32, 34, 39, 38), (32, 38, 39, 36), (32, 36, 39, 37), (32, 37, 39, 33), (36, 37, 43, 39), (36, 39, 43, 38), (36, 38, 43, 42), (36, 42, 43, 40), (36, 40, 43, 41), (36, 41, 43, 37), (40, 41, 47, 43), (40, 43, 47, 42), (40, 42, 47, 46), (40, 46, 47, 44), (40, 44, 47, 45), (40, 45, 47, 41), (44, 45, 51, 47), (44, 47, 51, 46), (44, 46, 51, 50), (44, 50, 51, 48), (44, 48, 51, 49), (44, 49, 51, 45), (48, 49, 55, 51), (48, 51, 55, 50), (48, 50, 55, 54), (48, 54, 55, 52), (48, 52, 55, 53), (48, 53, 55, 49), (52, 53, 59, 55), (52, 55, 59, 54), (52, 54, 59, 58), (52, 58, 59, 56), (52, 56, 59, 57), (52, 57, 59, 53), (56, 57, 63, 59), (56, 59, 63, 58), (56, 58, 63, 62), (56, 62, 63, 60), (56, 60, 63, 61), (56, 61, 63, 57)] + } +} diff --git a/isaaclab_arena/assets/usd/generate_deformable_tet_meshes.py b/isaaclab_arena/assets/usd/generate_deformable_tet_meshes.py index 7f3564eff1..01c46179ec 100644 --- a/isaaclab_arena/assets/usd/generate_deformable_tet_meshes.py +++ b/isaaclab_arena/assets/usd/generate_deformable_tet_meshes.py @@ -108,6 +108,9 @@ def main() -> None: pts, tets = _structured_box_tets(length=0.08, half_width=0.02, half_height=0.02, num_segments=4) _write_tet_usd(pts, tets, _OUT_DIR / "procedural_deformable_volume_block_tet.usda", "DeformableVolumeBlock") + pts, tets = _structured_box_tets(length=0.3, half_width=0.025, half_height=0.025, num_segments=15) + _write_tet_usd(pts, tets, _OUT_DIR / "franka_soft_lift_block_tet.usda", "FrankaSoftLiftBlock") + pts, tets = _structured_box_tets(length=0.4, half_width=0.012, half_height=0.012, num_segments=8) _write_tet_usd(pts, tets, _OUT_DIR / "procedural_deformable_cable_tet.usda", "DeformableCable") diff --git a/isaaclab_arena/embodiments/franka/franka.py b/isaaclab_arena/embodiments/franka/franka.py index 8ec1b1d43f..b5b7ee4170 100644 --- a/isaaclab_arena/embodiments/franka/franka.py +++ b/isaaclab_arena/embodiments/franka/franka.py @@ -261,6 +261,85 @@ class FrankaCameraCfg(ArenaCameraCfg): ) +@configclass +class FrankaSoftLiftSceneCfg: + """Source-parity Franka scene config for Isaac-Lift-Soft-Franka.""" + + robot: ArticulationCfg = FRANKA_PANDA_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + ee_frame: FrameTransformerCfg = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/panda_link0", + debug_vis=False, + target_frames=[ + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/panda_hand", + name="end_effector", + offset=OffsetCfg(pos=[0.0, 0.0, 0.1034]), + ), + ], + ) + + def __post_init__(self) -> None: + self.robot.spawn.usd_path = _FRANKA_ROBOT_PRIM.robot_usd_path + self.robot.spawn.rigid_props.disable_gravity = True + self.robot.actuators["panda_hand"].effort_limit_sim = 500.0 + self.robot.actuators["panda_hand"].stiffness = 1000.0 + self.robot.actuators["panda_hand"].damping = 100.0 + + +@configclass +class FrankaSoftLiftActionCfg: + """Absolute pose IK plus binary gripper for Isaac-Lift-Soft-Franka.""" + + arm_action: ActionTermCfg = DifferentialInverseKinematicsActionCfg( + asset_name="robot", + joint_names=["panda_joint.*"], + body_name="panda_hand", + controller=DifferentialIKControllerCfg( + command_type="pose", + use_relative_mode=False, + ik_method="dls", + ik_params={"lambda_val": 0.6}, + ), + body_offset=DifferentialInverseKinematicsActionCfg.OffsetCfg(pos=[0.0, 0.0, 0.107]), + ) + + gripper_action: ActionTermCfg = BinaryJointPositionActionCfg( + asset_name="robot", + joint_names=["panda_finger.*"], + open_command_expr={"panda_finger_.*": 0.05}, + close_command_expr={"panda_finger_.*": 0.0}, + ) + + +@register_asset +class FrankaSoftLiftPandaEmbodiment(EmbodimentBase): + """Plain Franka Panda embodiment for the soft-lift evaluation scene.""" + + name = "franka_soft_lift_panda" + tags = ["embodiment", "franka", "franka_soft_lift"] + default_arm_mode = ArmMode.SINGLE_ARM + + def __init__( + self, + enable_cameras: bool = False, + initial_pose: Pose | None = None, + concatenate_observation_terms: bool = False, + arm_mode: ArmMode | None = None, + ): + super().__init__(enable_cameras, initial_pose, concatenate_observation_terms, arm_mode) + self.scene_config = FrankaSoftLiftSceneCfg() + self.action_config = FrankaSoftLiftActionCfg() + self.camera_config = FrankaCameraCfg() + self.add_camera_variations(self.camera_config) + + def get_ee_frame_name(self, arm_mode: ArmMode) -> str: + return "ee_frame" + + def get_command_body_name(self) -> str: + return self.action_config.arm_action.body_name + + @configclass class FrankaObservationsCfg: """Observation specifications for the MDP.""" diff --git a/isaaclab_arena/metrics/deformable_goal_reached_rate.py b/isaaclab_arena/metrics/deformable_goal_reached_rate.py new file mode 100644 index 0000000000..b6c97ae834 --- /dev/null +++ b/isaaclab_arena/metrics/deformable_goal_reached_rate.py @@ -0,0 +1,143 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Goal-reached metric for deformable lift evaluation.""" + +from __future__ import annotations + +import numpy as np +import torch + +import warp as wp +from isaaclab.envs.manager_based_rl_env import ManagerBasedEnv +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers.recorder_manager import RecorderTerm, RecorderTermCfg +from isaaclab.utils.configclass import configclass +from isaaclab.utils.math import combine_frame_transforms + +from isaaclab_arena.metrics.metric_base import MetricBase +from isaaclab_arena.metrics.metric_term_cfg import MetricTermCfg + + +def _deformable_goal_reached( + env: ManagerBasedEnv, + *, + command_name: str, + minimal_height: float, + position_tolerance: float, + robot_cfg: SceneEntityCfg, + asset_cfg: SceneEntityCfg, +) -> torch.Tensor: + robot = env.scene[robot_cfg.name] + asset = env.scene[asset_cfg.name] + command = env.command_manager.get_command(command_name) + desired_pos_b = command[:, :3] + desired_pos_w, _ = combine_frame_transforms( + wp.to_torch(robot.data.root_pos_w), + wp.to_torch(robot.data.root_quat_w), + desired_pos_b, + ) + com_w = wp.to_torch(asset.data.root_pos_w) + distance = torch.linalg.norm(desired_pos_w - com_w, dim=1) + return (com_w[:, 2] > minimal_height) & (distance < position_tolerance) + + +class DeformableGoalReachedRecorder(RecorderTerm): + """Record whether the deformable reached its command goal at any point in the episode.""" + + def __init__(self, cfg: RecorderTermCfg, env: ManagerBasedEnv): + super().__init__(cfg, env) + self.name = cfg.name + self.command_name = cfg.command_name + self.minimal_height = cfg.minimal_height + self.position_tolerance = cfg.position_tolerance + self.robot_cfg = cfg.robot_cfg + self.asset_cfg = cfg.asset_cfg + self._ever_reached = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + self._first_reset = True + + def _update_state(self) -> None: + self._ever_reached |= _deformable_goal_reached( + self._env, + command_name=self.command_name, + minimal_height=self.minimal_height, + position_tolerance=self.position_tolerance, + robot_cfg=self.robot_cfg, + asset_cfg=self.asset_cfg, + ) + + def record_post_step(self): + self._update_state() + return None, None + + def record_pre_reset(self, env_ids): + if self._first_reset: + self._first_reset = False + return None, None + self._update_state() + reached = self._ever_reached[env_ids].clone() + self._ever_reached[env_ids] = False + return self.name, reached + + +@configclass +class DeformableGoalReachedRecorderCfg(RecorderTermCfg): + """Recorder config for the deformable goal-reached metric.""" + + class_type: type[RecorderTerm] = DeformableGoalReachedRecorder + name: str = "deformable_goal_reached" + command_name: str = "deformable_pose" + minimal_height: float = 0.075 + position_tolerance: float = 0.05 + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot") + asset_cfg: SceneEntityCfg = SceneEntityCfg("deformable") + + +def compute_deformable_goal_reached_rate(recorded_metric_data: list[np.ndarray]) -> float: + """Compute the fraction of episodes whose deformable reached the commanded goal.""" + if len(recorded_metric_data) == 0: + return 0.0 + goal_reached = np.concatenate([np.asarray(data, dtype=bool).reshape(-1) for data in recorded_metric_data]) + if goal_reached.size == 0: + return 0.0 + return float(np.mean(goal_reached)) + + +class DeformableGoalReachedRateMetric(MetricBase): + """Non-terminating goal-reached rate for deformable lift evaluation.""" + + name = "deformable_goal_reached_rate" + recorder_term_name = "deformable_goal_reached" + + def __init__( + self, + command_name: str = "deformable_pose", + minimal_height: float = 0.075, + position_tolerance: float = 0.05, + robot_cfg: SceneEntityCfg | None = None, + asset_cfg: SceneEntityCfg | None = None, + ): + self.command_name = command_name + self.minimal_height = minimal_height + self.position_tolerance = position_tolerance + self.robot_cfg = robot_cfg if robot_cfg is not None else SceneEntityCfg("robot") + self.asset_cfg = asset_cfg if asset_cfg is not None else SceneEntityCfg("deformable") + + def get_recorder_term_cfg(self) -> RecorderTermCfg: + return DeformableGoalReachedRecorderCfg( + name=self.recorder_term_name, + command_name=self.command_name, + minimal_height=self.minimal_height, + position_tolerance=self.position_tolerance, + robot_cfg=self.robot_cfg, + asset_cfg=self.asset_cfg, + ) + + def get_metric_term_cfg(self) -> MetricTermCfg: + return MetricTermCfg( + compute_metric_func=compute_deformable_goal_reached_rate, + params={}, + recorder_term_name=self.recorder_term_name, + ) diff --git a/isaaclab_arena/tasks/franka_soft_lift_task.py b/isaaclab_arena/tasks/franka_soft_lift_task.py new file mode 100644 index 0000000000..52237467c0 --- /dev/null +++ b/isaaclab_arena/tasks/franka_soft_lift_task.py @@ -0,0 +1,232 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Franka volume-deformable lift task for policy evaluation.""" + +from __future__ import annotations + +import isaaclab.envs.mdp as mdp +import isaaclab.sim as sim_utils +from isaaclab.envs.common import ViewerCfg +from isaaclab.managers import CommandTermCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.markers import VisualizationMarkersCfg +from isaaclab.utils.configclass import configclass +from isaaclab_tasks.manager_based.manipulation.lift_franka_soft import mdp as soft_lift_mdp +from isaaclab_tasks.manager_based.manipulation.lift_franka_soft.mdp.observations import ( + DeformableSampledPointsInRobotRootFrame, +) + +from isaaclab_arena.assets.deformable_object import DeformableObject +from isaaclab_arena.assets.object import Object +from isaaclab_arena.assets.register import register_task +from isaaclab_arena.metrics.deformable_goal_reached_rate import DeformableGoalReachedRateMetric +from isaaclab_arena.metrics.metric_base import MetricBase +from isaaclab_arena.tasks.task_base import TaskBase + + +@configclass +class FrankaSoftLiftCommandsCfg: + """Commanded deformable goal pose.""" + + deformable_pose: CommandTermCfg = mdp.UniformPoseCommandCfg( + asset_name="robot", + body_name="panda_hand", + resampling_time_range=(5.0, 5.0), + debug_vis=True, + ranges=mdp.UniformPoseCommandCfg.Ranges( + pos_x=(0.4, 0.6), + pos_y=(-0.25, 0.25), + pos_z=(0.25, 0.5), + roll=(0.0, 0.0), + pitch=(0.0, 0.0), + yaw=(0.0, 0.0), + ), + goal_pose_visualizer_cfg=VisualizationMarkersCfg( + prim_path="/Visuals/Command/goal_pose", + markers={ + "sphere": sim_utils.SphereCfg( + radius=0.03, + visual_material=sim_utils.PreviewSurfaceCfg( + diffuse_color=(0.1, 0.9, 0.2), + opacity=0.4, + ), + ), + }, + ), + ) + + +@configclass +class FrankaSoftLiftObservationsCfg: + """Policy observations for the Franka soft-lift MDP.""" + + @configclass + class PolicyCfg(ObsGroup): + joint_pos = ObsTerm(func=mdp.joint_pos_rel) + joint_vel = ObsTerm(func=mdp.joint_vel_rel) + deformable_sampled_points = ObsTerm( + func=DeformableSampledPointsInRobotRootFrame, + params={"asset_cfg": SceneEntityCfg("deformable"), "num_points": 20}, + ) + target_position = ObsTerm(func=mdp.generated_commands, params={"command_name": "deformable_pose"}) + actions = ObsTerm(func=mdp.last_action) + + def __post_init__(self) -> None: + self.enable_corruption = True + self.concatenate_terms = True + + policy: PolicyCfg = PolicyCfg() + + +@configclass +class FrankaSoftLiftEventsCfg: + """Reset events for the soft-lift task.""" + + reset_robot_joints = EventTerm( + func=mdp.reset_joints_by_scale, + mode="reset", + params={"position_range": (0.9, 1.1), "velocity_range": (0.0, 0.0)}, + ) + + reset_deformable = EventTerm( + func=mdp.reset_nodal_state_uniform, + mode="reset", + params={ + "position_range": {"x": (0.0, 0.0), "y": (0.0, 0.0), "z": (0.0, 0.0)}, + "velocity_range": {}, + "asset_cfg": SceneEntityCfg("deformable"), + }, + ) + + +@configclass +class FrankaSoftLiftRewardsCfg: + """Source-parity reward diagnostics for the deformable lift task.""" + + reaching_deformable = RewTerm( + func=soft_lift_mdp.deformable_ee_distance, + params={"std": 0.1, "asset_cfg": SceneEntityCfg("deformable")}, + weight=5.0, + ) + lifting_deformable = RewTerm( + func=soft_lift_mdp.deformable_lifted, + params={"minimal_height": 0.04, "asset_cfg": SceneEntityCfg("deformable")}, + weight=5.0, + ) + deformable_goal_tracking = RewTerm( + func=soft_lift_mdp.deformable_com_goal_distance, + params={ + "std": 0.3, + "minimal_height": 0.075, + "command_name": "deformable_pose", + "asset_cfg": SceneEntityCfg("deformable"), + }, + weight=16.0, + ) + deformable_goal_tracking_fine_grained = RewTerm( + func=soft_lift_mdp.deformable_com_goal_distance, + params={ + "std": 0.05, + "minimal_height": 0.075, + "command_name": "deformable_pose", + "asset_cfg": SceneEntityCfg("deformable"), + }, + weight=5.0, + ) + + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-1.0e-2) + gripper_close = RewTerm( + func=soft_lift_mdp.gripper_close_action, + params={"action_name": "gripper_action"}, + weight=-1.0, + ) + joint_vel = RewTerm(func=mdp.joint_vel_l2, weight=-1.0e-2) + joint_torque = RewTerm(func=mdp.joint_torques_l2, weight=-1.0e-4) + joint_acc = RewTerm(func=mdp.joint_acc_l2, weight=-1.0e-4) + + +@configclass +class FrankaSoftLiftTerminationsCfg: + """Time-out and source safety terminations; no success termination.""" + + time_out = DoneTerm(func=mdp.time_out, time_out=True) + deformable_outside_table = DoneTerm( + func=soft_lift_mdp.deformable_outside_table_bounds, + params={ + "x_bounds": (0.0, 1.0), + "y_bounds": (-0.5, 0.5), + "asset_cfg": SceneEntityCfg("deformable"), + }, + ) + deformable_dropped = DoneTerm( + func=soft_lift_mdp.deformable_com_below_minimum, + params={"minimum_height": -0.1, "asset_cfg": SceneEntityCfg("deformable")}, + ) + ee_below_table = DoneTerm( + func=soft_lift_mdp.ee_below_minimum, + params={"minimum_height": 0.0, "ee_frame_cfg": SceneEntityCfg("ee_frame")}, + ) + + +@register_task +class FrankaSoftLiftTask(TaskBase): + """Evaluation task for Franka lifting a volume deformable block.""" + + def __init__( + self, + deformable: DeformableObject, + table: Object, + episode_length_s: float = 5.0, + ): + super().__init__( + episode_length_s=episode_length_s, + task_description="Lift the deformable block to the commanded target pose.", + ) + self.deformable = deformable + self.table = table + self.commands_cfg = FrankaSoftLiftCommandsCfg() + self.observations_cfg = FrankaSoftLiftObservationsCfg() + self.events_cfg = FrankaSoftLiftEventsCfg() + self.rewards_cfg = FrankaSoftLiftRewardsCfg() + self.terminations_cfg = FrankaSoftLiftTerminationsCfg() + + def get_scene_cfg(self): + return None + + def get_observation_cfg(self): + return self.observations_cfg + + def get_commands_cfg(self): + return self.commands_cfg + + def get_events_cfg(self): + return self.events_cfg + + def get_rewards_cfg(self): + return self.rewards_cfg + + def get_termination_cfg(self): + return self.terminations_cfg + + def get_metrics(self) -> list[MetricBase]: + return [DeformableGoalReachedRateMetric()] + + def get_mimic_env_cfg(self, arm_mode): + raise NotImplementedError("Franka soft lift does not define a mimic workflow.") + + def get_viewer_cfg(self) -> ViewerCfg: + viewer = ViewerCfg() + viewer.origin_type = "asset_root" + viewer.asset_name = "robot" + viewer.env_index = 0 + viewer.eye = (1.25, -1.5, 0.75) + viewer.resolution = (1920, 1080) + return viewer diff --git a/isaaclab_arena/tasks/task_library.py b/isaaclab_arena/tasks/task_library.py index 1273b69964..9faba29d38 100644 --- a/isaaclab_arena/tasks/task_library.py +++ b/isaaclab_arena/tasks/task_library.py @@ -11,6 +11,7 @@ from isaaclab_arena.tasks import ( # noqa: F401 assembly_task, close_door_task, + franka_soft_lift_task, goal_pose_task, lift_object_task, no_task, diff --git a/isaaclab_arena/tests/test_franka_soft_lift.py b/isaaclab_arena/tests/test_franka_soft_lift.py new file mode 100644 index 0000000000..b2cfb3cec0 --- /dev/null +++ b/isaaclab_arena/tests/test_franka_soft_lift.py @@ -0,0 +1,339 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Config-level coverage for the Franka soft-lift evaluation scene.""" + +from __future__ import annotations + +import numpy as np +from dataclasses import fields + +import pytest + + +def _cfg_field_names(cfg, value_type: type | tuple[type, ...] | None = None) -> list[str]: + if value_type is None: + return [field.name for field in fields(cfg)] + return [field.name for field in fields(cfg) if isinstance(getattr(cfg, field.name), value_type)] + + +def test_franka_soft_lift_registered_components() -> None: + from isaaclab_arena.assets.registries import AssetRegistry, EnvironmentRegistry, TaskRegistry + from isaaclab_arena_environments.cli import ensure_environments_registered + + asset_registry = AssetRegistry() + for asset_name in ("franka_soft_lift_panda", "franka_soft_lift_block", "franka_soft_lift_table"): + assert asset_registry.is_registered(asset_name) + + ensure_environments_registered() + environment_registry = EnvironmentRegistry() + assert environment_registry.is_registered("franka_soft_lift") + assert environment_registry.get_component_by_name("franka_soft_lift").name == "franka_soft_lift" + assert TaskRegistry().is_registered("FrankaSoftLiftTask") + + +def test_franka_soft_lift_scene_assets_match_source() -> None: + import isaaclab.sim as sim_utils + from isaaclab.assets import AssetBaseCfg, DeformableObjectCfg + from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg + from isaaclab_tasks.utils.hydra import resolve_presets + + from isaaclab_arena.assets.deformable_spawn import SimulationBackend + from isaaclab_arena.assets.object_base import ObjectType + from isaaclab_arena.assets.registries import AssetRegistry + from isaaclab_arena_environments.franka_soft_lift_environment import ( + FrankaSoftLiftEnvironment, + FrankaSoftLiftEnvironmentCfg, + ) + + arena_env = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg()) + assert list(arena_env.scene.assets) == ["table", "deformable", "ground", "sky_light"] + + table = arena_env.scene.assets["table"] + deformable = arena_env.scene.assets["deformable"] + ground = arena_env.scene.assets["ground"] + sky_light = arena_env.scene.assets["sky_light"] + + assert table.object_type == ObjectType.BASE + assert table.prim_path == "{ENV_REGEX_NS}/Table" + assert table.object_cfg.init_state.pos == (0.5, 0.0, 0.0) + assert table.object_cfg.init_state.rot == (0.0, 0.0, 0.707, 0.707) + assert isinstance(table.object_cfg.spawn, UsdFileCfg) + assert table.object_cfg.spawn.usd_path.endswith("/Props/Mounts/SeattleLabTable/table_instanceable.usd") + + assert deformable.prim_path == "{ENV_REGEX_NS}/Deformable" + assert deformable.get_event_cfg()[1] is None + assert deformable.soft_body_kinds() == frozenset({"volume"}) + bbox = deformable.get_bounding_box() + assert bbox.min_point[0].tolist() == pytest.approx([-0.15, -0.025, -0.025]) + assert bbox.max_point[0].tolist() == pytest.approx([0.15, 0.025, 0.025]) + + for preset in ("physx", "newton_mjwarp_vbd_proxy", "newton_mjwarp_vbd"): + block_cfg = resolve_presets(deformable.object_cfg, selected=(preset,)) + assert isinstance(block_cfg, DeformableObjectCfg) + assert isinstance(block_cfg.spawn, UsdFileCfg) + assert block_cfg.prim_path == "{ENV_REGEX_NS}/Deformable" + assert block_cfg.init_state.pos == (0.5, 0.0, 0.05) + assert block_cfg.spawn.usd_path.endswith("/franka_soft_lift_block_tet.usda") + assert block_cfg.spawn.visual_material.diffuse_color == (0.95, 0.85, 0.1) + assert block_cfg.spawn.physics_material.density == 300.0 + + physx_cfg = deformable._make_deformable_cfg(SimulationBackend.PHYSX).spawn + assert physx_cfg.deformable_props.rest_offset is None + assert physx_cfg.deformable_props.contact_offset is None + assert physx_cfg.deformable_props.solver_position_iteration_count == 16 + assert physx_cfg.deformable_props.linear_damping is None + assert physx_cfg.physics_material.youngs_modulus == 8.0e4 + assert physx_cfg.physics_material.poissons_ratio == 0.25 + assert physx_cfg.physics_material.static_friction == 10.0 + assert physx_cfg.physics_material.dynamic_friction == 5.0 + + newton_cfg = deformable._make_deformable_cfg(SimulationBackend.NEWTON).spawn + assert newton_cfg.physics_material.particle_radius == 0.01 + + assert ground.prim_path == "/World/GroundPlane" + assert ground.object_cfg.init_state.pos == (0.0, 0.0, -1.05) + assert isinstance(ground.object_cfg.spawn, GroundPlaneCfg) + + assert isinstance(sky_light.object_cfg, AssetBaseCfg) + assert sky_light.prim_path == "/World/skyLight" + assert isinstance(sky_light.object_cfg.spawn, sim_utils.DomeLightCfg) + assert sky_light.object_cfg.spawn.intensity == 750.0 + assert sky_light.object_cfg.spawn.texture_file.endswith( + "/Materials/Textures/Skies/PolyHaven/kloofendal_43d_clear_puresky_4k.hdr" + ) + assert AssetRegistry().get_asset_by_name("franka_soft_lift_block").name == "franka_soft_lift_block" + + +def test_franka_soft_lift_embodiment_matches_source() -> None: + from isaaclab_arena.assets.registries import AssetRegistry + from isaaclab_arena.embodiments.franka.franka import FrankaEmbodimentBase + + embodiment = AssetRegistry().get_asset_by_name("franka_soft_lift_panda")() + + assert not isinstance(embodiment, FrankaEmbodimentBase) + assert embodiment.observation_config is None + assert embodiment.event_config is None + assert embodiment.reward_config is None + + robot = embodiment.scene_config.robot + assert robot.prim_path == "{ENV_REGEX_NS}/Robot" + assert robot.spawn.usd_path.endswith("/Robots/FrankaEmika/Legacy/panda_instanceable.usd") + assert robot.spawn.rigid_props.disable_gravity is True + assert robot.actuators["panda_hand"].effort_limit_sim == 500.0 + assert robot.actuators["panda_hand"].stiffness == 1000.0 + assert robot.actuators["panda_hand"].damping == 100.0 + + ee_frame = embodiment.scene_config.ee_frame + assert ee_frame.prim_path == "{ENV_REGEX_NS}/Robot/panda_link0" + assert len(ee_frame.target_frames) == 1 + assert ee_frame.target_frames[0].prim_path == "{ENV_REGEX_NS}/Robot/panda_hand" + assert ee_frame.target_frames[0].offset.pos == [0.0, 0.0, 0.1034] + + actions = embodiment.action_config + assert _cfg_field_names(actions) == ["arm_action", "gripper_action"] + assert actions.arm_action.controller.command_type == "pose" + assert actions.arm_action.controller.use_relative_mode is False + assert actions.arm_action.controller.ik_method == "dls" + assert actions.arm_action.controller.ik_params == {"lambda_val": 0.6} + assert actions.arm_action.body_offset.pos == [0.0, 0.0, 0.107] + assert actions.gripper_action.open_command_expr == {"panda_finger_.*": 0.05} + assert actions.gripper_action.close_command_expr == {"panda_finger_.*": 0.0} + + +def test_franka_soft_lift_task_mdp_matches_source() -> None: + import isaaclab.envs.mdp as mdp + from isaaclab.managers import ObservationTermCfg as ObsTerm + from isaaclab_tasks.manager_based.manipulation.lift_franka_soft import mdp as soft_lift_mdp + from isaaclab_tasks.manager_based.manipulation.lift_franka_soft.mdp.observations import ( + DeformableSampledPointsInRobotRootFrame, + ) + + from isaaclab_arena_environments.franka_soft_lift_environment import ( + FrankaSoftLiftEnvironment, + FrankaSoftLiftEnvironmentCfg, + ) + + task = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg()).task + + command = task.get_commands_cfg().deformable_pose + assert command.asset_name == "robot" + assert command.body_name == "panda_hand" + assert command.resampling_time_range == (5.0, 5.0) + assert command.debug_vis is True + assert command.ranges.pos_x == (0.4, 0.6) + assert command.ranges.pos_y == (-0.25, 0.25) + assert command.ranges.pos_z == (0.25, 0.5) + assert command.goal_pose_visualizer_cfg.markers["sphere"].visual_material.diffuse_color == (0.1, 0.9, 0.2) + + policy = task.get_observation_cfg().policy + assert _cfg_field_names(policy, ObsTerm) == [ + "joint_pos", + "joint_vel", + "deformable_sampled_points", + "target_position", + "actions", + ] + assert policy.enable_corruption is True + assert policy.concatenate_terms is True + assert policy.joint_pos.func is mdp.joint_pos_rel + assert policy.joint_vel.func is mdp.joint_vel_rel + assert policy.deformable_sampled_points.func is DeformableSampledPointsInRobotRootFrame + assert policy.deformable_sampled_points.params["num_points"] == 20 + assert policy.target_position.params == {"command_name": "deformable_pose"} + assert policy.actions.func is mdp.last_action + + rewards = task.get_rewards_cfg() + assert _cfg_field_names(rewards) == [ + "reaching_deformable", + "lifting_deformable", + "deformable_goal_tracking", + "deformable_goal_tracking_fine_grained", + "action_rate", + "gripper_close", + "joint_vel", + "joint_torque", + "joint_acc", + ] + assert rewards.reaching_deformable.func is soft_lift_mdp.deformable_ee_distance + assert rewards.reaching_deformable.weight == 5.0 + assert rewards.lifting_deformable.params["minimal_height"] == 0.04 + assert rewards.deformable_goal_tracking.weight == 16.0 + assert rewards.deformable_goal_tracking.params["minimal_height"] == 0.075 + assert rewards.deformable_goal_tracking_fine_grained.params["std"] == 0.05 + assert rewards.action_rate.weight == -1.0e-2 + assert rewards.gripper_close.func is soft_lift_mdp.gripper_close_action + assert rewards.joint_torque.weight == -1.0e-4 + assert rewards.joint_acc.weight == -1.0e-4 + + terminations = task.get_termination_cfg() + assert _cfg_field_names(terminations) == [ + "time_out", + "deformable_outside_table", + "deformable_dropped", + "ee_below_table", + ] + assert not hasattr(terminations, "success") + assert terminations.deformable_outside_table.params["x_bounds"] == (0.0, 1.0) + assert terminations.deformable_outside_table.params["y_bounds"] == (-0.5, 0.5) + assert terminations.deformable_dropped.params["minimum_height"] == -0.1 + assert terminations.ee_below_table.params["minimum_height"] == 0.0 + + +def test_franka_soft_lift_default_preset_and_explicit_overrides() -> None: + from isaaclab_arena.environments.arena_env_builder import ArenaEnvBuilder + from isaaclab_arena.environments.arena_env_builder_cfg import ArenaEnvBuilderCfg + from isaaclab_arena.environments.physics_presets import ARENA_PHYSICS_PRESETS + from isaaclab_arena_environments.franka_soft_lift_environment import ( + FrankaSoftLiftEnvironment, + FrankaSoftLiftEnvironmentCfg, + ) + + arena_env = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg()) + assert arena_env.default_physics_preset == "newton_mjwarp_vbd_proxy" + assert arena_env.rl_framework_entry_point is None + assert arena_env.rl_policy_cfg is None + + builder = ArenaEnvBuilder(arena_env, ArenaEnvBuilderCfg(num_envs=1, solve_relations=False)) + assert builder._select_backend_preset(None, needs_soft_body=True) == "newton_mjwarp_vbd_proxy" + env_cfg, _ = builder.compose_manager_cfg() + assert env_cfg.scene.replicate_physics is True + assert env_cfg.sim.physics == ARENA_PHYSICS_PRESETS["newton_mjwarp_vbd_proxy"].cfg + assert env_cfg.sim.physics.solver_cfg.rigid_solver_cfg.njmax == 40 + assert env_cfg.sim.physics.solver_cfg.rigid_solver_cfg.nconmax == 20 + assert env_cfg.sim.gravity == (0.0, 0.0, -9.81) + assert env_cfg.sim.dt == 1.0 / 60.0 + assert env_cfg.decimation == 1 + assert env_cfg.episode_length_s == 5.0 + assert env_cfg.sync_deformable_visual_meshes_from_sim is True + + physx_env = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg()) + physx_cfg, _ = ArenaEnvBuilder( + physx_env, + ArenaEnvBuilderCfg(num_envs=1, solve_relations=False, presets="physx"), + ).compose_manager_cfg() + assert physx_cfg.scene.replicate_physics is False + assert physx_cfg.sim.physics == ARENA_PHYSICS_PRESETS["physx"].cfg + + vbd_env = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg()) + vbd_cfg, _ = ArenaEnvBuilder( + vbd_env, + ArenaEnvBuilderCfg(num_envs=1, solve_relations=False, presets="newton_mjwarp_vbd"), + ).compose_manager_cfg() + assert vbd_cfg.scene.replicate_physics is True + assert vbd_cfg.sim.physics == ARENA_PHYSICS_PRESETS["newton_mjwarp_vbd"].cfg + + surface_env = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg()) + with pytest.raises(NotImplementedError, match="does not support"): + ArenaEnvBuilder( + surface_env, + ArenaEnvBuilderCfg(num_envs=1, solve_relations=False, presets="newton_mjwarp_vbd_surface"), + ).compose_manager_cfg() + + +def test_franka_soft_lift_variation_defaults_hydra_and_catalogue() -> None: + from isaaclab_arena.environments.arena_env_builder import ArenaEnvBuilder + from isaaclab_arena.environments.arena_env_builder_cfg import ArenaEnvBuilderCfg + from isaaclab_arena_environments.franka_soft_lift_environment import ( + FrankaSoftLiftEnvironment, + FrankaSoftLiftEnvironmentCfg, + ) + + arena_env = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg()) + variation = arena_env.scene.assets["deformable"].get_variation("initial_pose") + assert variation.enabled is True + assert variation.cfg.sampler_cfg.low == [-0.05, -0.05, 0.0] + assert variation.cfg.sampler_cfg.high == [0.05, 0.05, 0.0] + + builder = ArenaEnvBuilder(arena_env, ArenaEnvBuilderCfg(num_envs=1, solve_relations=False)) + catalogue = builder.get_variations_catalogue_as_string() + assert "Asset: deformable" in catalogue + assert "deformable.initial_pose.enabled=true (default: True)" in catalogue + assert "deformable.initial_pose.sampler_cfg.low = [-0.05,-0.05,0.0]" in catalogue + + disabled_env = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg(pose_variation_enabled=False)) + disabled_variation = disabled_env.scene.assets["deformable"].get_variation("initial_pose") + assert disabled_variation.enabled is False + disabled_cfg, _ = ArenaEnvBuilder( + disabled_env, + ArenaEnvBuilderCfg(num_envs=1, solve_relations=False), + ).compose_manager_cfg() + assert not hasattr(disabled_cfg.events, "deformable_initial_pose_variation") + + override_env = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg()) + override_cfg, _ = ArenaEnvBuilder( + override_env, + ArenaEnvBuilderCfg(num_envs=1, solve_relations=False), + hydra_overrides=[ + "deformable.initial_pose.sampler_cfg.low=[-0.01,-0.02,0.0]", + "deformable.initial_pose.sampler_cfg.high=[0.01,0.02,0.0]", + ], + ).compose_manager_cfg() + event = override_cfg.events.deformable_initial_pose_variation + sampler = event.params["sampler"] + assert sampler.low.tolist() == pytest.approx([-0.01, -0.02, 0.0]) + assert sampler.high.tolist() == pytest.approx([0.01, 0.02, 0.0]) + + +def test_franka_soft_lift_metric_config_and_compute() -> None: + from isaaclab_arena.metrics.deformable_goal_reached_rate import ( + DeformableGoalReachedRateMetric, + compute_deformable_goal_reached_rate, + ) + + metric = DeformableGoalReachedRateMetric() + recorder_cfg = metric.get_recorder_term_cfg() + metric_cfg = metric.get_metric_term_cfg() + + assert metric.name == "deformable_goal_reached_rate" + assert recorder_cfg.name == "deformable_goal_reached" + assert recorder_cfg.command_name == "deformable_pose" + assert recorder_cfg.minimal_height == 0.075 + assert recorder_cfg.position_tolerance == 0.05 + assert metric_cfg.recorder_term_name == "deformable_goal_reached" + + assert compute_deformable_goal_reached_rate([]) == 0.0 + assert compute_deformable_goal_reached_rate([np.array([], dtype=bool)]) == 0.0 + assert compute_deformable_goal_reached_rate([np.array([True, False]), np.array([[True]])]) == pytest.approx(2 / 3) diff --git a/isaaclab_arena_environments/franka_soft_lift_environment.py b/isaaclab_arena_environments/franka_soft_lift_environment.py new file mode 100644 index 0000000000..8b32120286 --- /dev/null +++ b/isaaclab_arena_environments/franka_soft_lift_environment.py @@ -0,0 +1,87 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import isaaclab.sim as sim_utils +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + +from isaaclab_arena.assets.register import register_environment +from isaaclab_arena.environments.arena_environment_factory import ArenaEnvironmentCfg, ArenaEnvironmentFactory +from isaaclab_arena.utils.pose import Pose + +if TYPE_CHECKING: + from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment + + +@dataclass +class FrankaSoftLiftEnvironmentCfg(ArenaEnvironmentCfg): + """Configure the Franka soft-lift evaluation scene.""" + + enable_cameras: bool = False + pose_variation_enabled: bool = True + + +@register_environment +class FrankaSoftLiftEnvironment(ArenaEnvironmentFactory[FrankaSoftLiftEnvironmentCfg]): + """Registered provider for the Isaac-Lift-Soft-Franka evaluation scene.""" + + name: str = "franka_soft_lift" + _legacy_argparse_cfg_type = FrankaSoftLiftEnvironmentCfg + + def build(self, cfg: FrankaSoftLiftEnvironmentCfg) -> IsaacLabArenaEnvironment: + """Build the environment from its typed configuration.""" + from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment + from isaaclab_arena.scene.scene import Scene + from isaaclab_arena.tasks.franka_soft_lift_task import FrankaSoftLiftTask + + deformable = self.asset_registry.get_asset_by_name("franka_soft_lift_block")(instance_name="deformable") + deformable.disable_reset_pose() + initial_pose_variation = deformable.get_variation("initial_pose") + if cfg.pose_variation_enabled: + initial_pose_variation.enable() + else: + initial_pose_variation.disable() + + table = self.asset_registry.get_asset_by_name("franka_soft_lift_table")(instance_name="table") + ground = self.asset_registry.get_asset_by_name("ground_plane")( + instance_name="ground", + prim_path="/World/GroundPlane", + initial_pose=Pose(position_xyz=(0.0, 0.0, -1.05)), + ) + sky_light = self.asset_registry.get_asset_by_name("light")( + instance_name="sky_light", + prim_path="/World/skyLight", + spawner_cfg=sim_utils.DomeLightCfg( + intensity=750.0, + texture_file=( + f"{ISAAC_NUCLEUS_DIR}/Materials/Textures/Skies/PolyHaven/kloofendal_43d_clear_puresky_4k.hdr" + ), + ), + ) + embodiment = self.asset_registry.get_asset_by_name("franka_soft_lift_panda")(enable_cameras=cfg.enable_cameras) + + scene = Scene(assets=[table, deformable, ground, sky_light]) + task = FrankaSoftLiftTask(deformable=deformable, table=table) + + def _set_sim_cfg(env_cfg): + env_cfg.decimation = 1 + env_cfg.sim.dt = 1.0 / 60.0 + env_cfg.sim.render_interval = env_cfg.decimation + env_cfg.sim.gravity = (0.0, 0.0, -9.81) + env_cfg.sync_deformable_visual_meshes_from_sim = True + return env_cfg + + return IsaacLabArenaEnvironment( + name=self.name, + embodiment=embodiment, + scene=scene, + task=task, + env_cfg_callback=_set_sim_cfg, + default_physics_preset="newton_mjwarp_vbd_proxy", + ) From 1c3cc520e490aa9465cb7b1943dfd53d80aa214e Mon Sep 17 00:00:00 2001 From: tstuyck Date: Thu, 30 Jul 2026 14:58:37 -0700 Subject: [PATCH 2/2] Add Franka soft lift smoke coverage Signed-off-by: tstuyck --- .../tests/test_franka_soft_lift_smoke.py | 78 +++++++++++++++++++ .../utils/isaaclab_utils/simulation_app.py | 21 ++++- 2 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 isaaclab_arena/tests/test_franka_soft_lift_smoke.py diff --git a/isaaclab_arena/tests/test_franka_soft_lift_smoke.py b/isaaclab_arena/tests/test_franka_soft_lift_smoke.py new file mode 100644 index 0000000000..913b8da63b --- /dev/null +++ b/isaaclab_arena/tests/test_franka_soft_lift_smoke.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Runtime smoke tests for the registered Franka soft-lift scene.""" + +from __future__ import annotations + +import pytest + +from isaaclab_arena.tests.utils.subprocess import run_simulation_app_function + +HEADLESS = True + + +def _test_franka_soft_lift_backends_step(simulation_app) -> bool: + import torch + + from isaaclab_arena.environments.arena_env_builder import ArenaEnvBuilder + from isaaclab_arena.environments.arena_env_builder_cfg import ArenaEnvBuilderCfg + from isaaclab_arena_environments.franka_soft_lift_environment import ( + FrankaSoftLiftEnvironment, + FrankaSoftLiftEnvironmentCfg, + ) + + backend_expectations = { + "physx": False, + "newton_mjwarp_vbd_proxy": True, + "newton_mjwarp_vbd": True, + } + + for preset, expected_replicate_physics in backend_expectations.items(): + arena_env = FrankaSoftLiftEnvironment().build(FrankaSoftLiftEnvironmentCfg(enable_cameras=False)) + builder = ArenaEnvBuilder( + arena_env, + ArenaEnvBuilderCfg(num_envs=1, solve_relations=False, presets=preset), + ) + env = builder.make_registered() + base_env = env.unwrapped + try: + obs, _ = env.reset() + assert base_env.cfg.scene.replicate_physics is expected_replicate_physics + assert base_env.cfg.sim.gravity == (0.0, 0.0, -9.81) + assert env.action_space.shape[-1] == 8 + assert obs["policy"].shape == (1, 93) + assert torch.isfinite(obs["policy"]).all() + + deformable = base_env.scene["deformable"] + nodal_before = deformable.data.nodal_pos_w.torch.clone() + assert nodal_before.shape[1] > 0, f"{preset}: deformable has no simulation nodes" + assert torch.isfinite(nodal_before).all(), f"{preset}: nodal positions are not finite after reset" + + actions = torch.zeros((base_env.num_envs, env.action_space.shape[-1]), device=base_env.device) + finite_rewards = True + finite_observations = True + for _ in range(15): + obs, rewards, _terminated, _truncated, _info = env.step(actions) + finite_rewards &= bool(torch.isfinite(rewards).all()) + finite_observations &= bool(torch.isfinite(obs["policy"]).all()) + + nodal_after = deformable.data.nodal_pos_w.torch + assert finite_rewards, f"{preset}: rewards became non-finite" + assert finite_observations, f"{preset}: observations became non-finite" + assert torch.isfinite(nodal_after).all(), f"{preset}: nodal positions became non-finite" + + max_delta = (nodal_after - nodal_before).abs().max().item() + assert max_delta > 1.0e-7, f"{preset}: deformable did not advance under simulation" + finally: + env.close() + + return True + + +@pytest.mark.with_subprocess +@pytest.mark.with_newton +def test_franka_soft_lift_backends_step() -> None: + assert run_simulation_app_function(_test_franka_soft_lift_backends_step, headless=HEADLESS) diff --git a/isaaclab_arena/utils/isaaclab_utils/simulation_app.py b/isaaclab_arena/utils/isaaclab_utils/simulation_app.py index a351824f59..0ae76f45c2 100644 --- a/isaaclab_arena/utils/isaaclab_utils/simulation_app.py +++ b/isaaclab_arena/utils/isaaclab_utils/simulation_app.py @@ -96,13 +96,26 @@ def reapply_viewer_cfg(env) -> None: ViewportCameraController calls sim.set_camera_view() during __init__, but visualizers (e.g. KitVisualizer) are not yet initialized at that point and silently ignore the call. - After gym.make() returns the visualizers are ready, so we call update_view_location() - again to apply the configured eye/lookat position. + After gym.make() returns the visualizers are ready, so we update through the controller's + configured origin mode to apply the configured eye/lookat position. """ unwrapped = env.unwrapped vcc = getattr(unwrapped, "viewport_camera_controller", None) - if vcc is not None: - vcc.update_view_location() + if vcc is None: + return + + origin_type = vcc.cfg.origin_type + if origin_type == "env": + vcc.update_view_to_env() + elif origin_type == "asset_root": + assert vcc.cfg.asset_name is not None, "Asset-root viewer config requires asset_name." + vcc.update_view_to_asset_root(vcc.cfg.asset_name) + elif origin_type == "asset_body": + assert vcc.cfg.asset_name is not None, "Asset-body viewer config requires asset_name." + assert vcc.cfg.body_name is not None, "Asset-body viewer config requires body_name." + vcc.update_view_to_asset_body(vcc.cfg.asset_name, vcc.cfg.body_name) + else: + vcc.update_view_to_world() def _kill_child_processes() -> None: