Skip to content
4 changes: 4 additions & 0 deletions isaaclab_arena/assets/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ def get_variation(self, name: str) -> VariationBase:
def get_variations(self) -> list[VariationBase]:
"""Return every variation attached to this asset, enabled or not."""
return list(self.variations.values())

def get_scene_name(self) -> str:
"""Return the Isaac Lab scene key for the asset."""
return self.name
51 changes: 51 additions & 0 deletions isaaclab_arena/assets/background_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
#
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

import re
from pathlib import Path
from typing import Any

import isaaclab.sim as sim_utils
Expand Down Expand Up @@ -195,6 +199,53 @@ def get_viewer_cfg(self) -> ViewerCfg:
return ViewerCfg(eye=(2.75, -5.5, 1.5), lookat=(2.75, -1.4, 0.9))


_REPLICATOR_ROOT = Path(__file__).resolve().parents[2] / "Replicator"


def _discover_replicator_kitchen_usda_paths() -> dict[str, Path]:
"""Map ``replicator_kitchen_<seed>`` registry names to USDA paths under ``Replicator/``."""
paths: dict[str, Path] = {}
if not _REPLICATOR_ROOT.is_dir():
return paths
for usda_path in sorted(_REPLICATOR_ROOT.glob("seed_*/*.usda")):
match = re.match(r"seed_(\d+)_", usda_path.parent.name)
if match is None:
continue
paths[f"replicator_kitchen_{match.group(1)}"] = usda_path.resolve()
return paths


class ReplicatorKitchenBackground(LibraryBackground):
"""Lightwheel Replicator-generated kitchen floorplan from ``Replicator/seed_*/``."""

tags = ["background", "replicator"]
initial_pose = Pose.identity()
object_min_z = -0.2

def get_viewer_cfg(self) -> ViewerCfg:
return ViewerCfg(eye=(0.0, -1.0, 1.35), lookat=(0.0, 0.0, 1.35))


def _register_replicator_kitchen_background(asset_name: str, usda_path: Path) -> None:
"""Register one ``replicator_kitchen_<seed>`` background asset class."""
seed = asset_name.removeprefix("replicator_kitchen_")

@register_asset
class _ReplicatorKitchenEntry(ReplicatorKitchenBackground):
name = asset_name
usd_path = str(usda_path)

def __init__(self):
super().__init__()

_ReplicatorKitchenEntry.__name__ = f"ReplicatorKitchen{seed}"
_ReplicatorKitchenEntry.__qualname__ = _ReplicatorKitchenEntry.__name__


for _asset_name, _usda_path in _discover_replicator_kitchen_usda_paths().items():
_register_replicator_kitchen_background(_asset_name, _usda_path)


@register_asset
class MapleTableRobolab(LibraryBackground):
"""
Expand Down
47 changes: 7 additions & 40 deletions isaaclab_arena/assets/dummy_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,74 +7,41 @@
import torch
import trimesh

from isaaclab_arena.relations.collision_mode import CollisionMode
from isaaclab_arena.relations.relations import IsAnchor, Relation, RelationBase, UnaryRelation
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox, quaternion_to_90_deg_z_quarters
from isaaclab_arena.relations.placement_asset import PlacementAsset
from isaaclab_arena.relations.relations import RelationBase
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox
from isaaclab_arena.utils.pose import Pose


class DummyObject:
class DummyObject(PlacementAsset):
"""Dummy object for testing purposes without Isaac Sim dependencies."""

def __init__(
self,
name: str,
bounding_box: AxisAlignedBoundingBox,
initial_pose: Pose | None = None,
relations: list[RelationBase] = [],
relations: list[RelationBase] | None = None,
collision_mesh: trimesh.Trimesh | None = None,
**kwargs,
):
self.name = name
super().__init__(name=name)
self.initial_pose = initial_pose
self.bounding_box = bounding_box
assert self.bounding_box is not None
self.relations = list(relations)
self.relations = list(relations or [])
self._collision_mesh = collision_mesh
self.collision_mode: CollisionMode | None = None
# If True, mesh collision replaces non-watertight meshes with their convex hull.
self.repair_collision_mesh_non_watertight = True

def add_relation(self, relation: RelationBase) -> None:
self.relations.append(relation)

def get_relations(self) -> list[RelationBase]:
return self.relations

def get_spatial_relations(self) -> list[RelationBase]:
"""Get only spatial relations (On, NextTo, AtPosition, etc.), excluding markers like IsAnchor."""
return [r for r in self.relations if isinstance(r, (Relation, UnaryRelation))]

def get_bounding_box(self) -> AxisAlignedBoundingBox:
"""Get local bounding box (relative to object origin)."""
return self.bounding_box

def get_world_bounding_box(self) -> AxisAlignedBoundingBox:
"""Get bounding box in world coordinates (local bbox rotated and translated).

Only 90° rotations around Z axis are supported.
"""
if self.initial_pose is None:
return self.bounding_box
quarters = quaternion_to_90_deg_z_quarters(self.initial_pose.rotation_xyzw)
return self.bounding_box.rotated_90_around_z(quarters).translated(self.initial_pose.position_xyz)

def get_corners_aabb(self, pos: torch.Tensor) -> torch.Tensor:
return self.bounding_box.get_corners_at(pos)

def set_initial_pose(self, pose: Pose) -> None:
self.initial_pose = pose

def get_initial_pose(self) -> Pose | None:
return self.initial_pose

def is_initial_pose_set(self) -> bool:
return self.initial_pose is not None

@property
def is_anchor(self) -> bool:
return any(isinstance(r, IsAnchor) for r in self.relations)

def get_collision_mesh(self) -> trimesh.Trimesh | None:
"""Return the collision mesh, or None to fall back to AABB."""
return self._collision_mesh
27 changes: 2 additions & 25 deletions isaaclab_arena/assets/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@
from __future__ import annotations

import torch
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
import trimesh
from typing import Any

from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg
from isaaclab.sensors.contact_sensor.contact_sensor_cfg import ContactSensorCfg
Expand All @@ -18,7 +15,7 @@
from isaaclab_arena.assets.object_base import ObjectBase, ObjectType
from isaaclab_arena.assets.object_utils import detect_object_type
from isaaclab_arena.relations.relations import RelationBase
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox, quaternion_to_90_deg_z_quarters
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox
from isaaclab_arena.utils.pose import Pose
from isaaclab_arena.utils.usd.rigid_bodies import find_shallowest_rigid_body
from isaaclab_arena.utils.usd_helpers import compute_local_bounding_box_from_usd, has_light, open_stage
Expand Down Expand Up @@ -66,33 +63,13 @@ def __init__(
self.object_cfg = self._init_object_cfg()
self.event_cfg = self._init_event_cfg()

def add_relation(self, relation: RelationBase) -> None:
"""Add a relation to this object."""
self.relations.append(relation)

def get_bounding_box(self) -> AxisAlignedBoundingBox:
"""Get local bounding box (relative to object origin)."""
assert self.usd_path is not None
if self.bounding_box is None:
self.bounding_box = compute_local_bounding_box_from_usd(self.usd_path, self.scale)
return self.bounding_box

def get_collision_mesh(self) -> trimesh.Trimesh | None:
"""Return None: USD-backed objects expose no preloaded collision mesh."""

def get_world_bounding_box(self) -> AxisAlignedBoundingBox:
"""Get bounding box in world coordinates (local bbox rotated and translated).

Only 90° rotations around Z axis are supported. An assertion error is raised
for any other rotation. If initial_pose is a PoseRange (not a fixed Pose),
returns the local bounding box without transformation.
"""
local_bbox = self.get_bounding_box()
if self.initial_pose is None or not isinstance(self.initial_pose, Pose):
return local_bbox
quarters = quaternion_to_90_deg_z_quarters(self.initial_pose.rotation_xyzw)
return local_bbox.rotated_90_around_z(quarters).translated(self.initial_pose.position_xyz)

def get_corners(self, pos: torch.Tensor) -> torch.Tensor:
assert self.usd_path is not None
if self.bounding_box is None:
Expand Down
67 changes: 16 additions & 51 deletions isaaclab_arena/assets/object_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,21 @@

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

import warp as wp
from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg

if TYPE_CHECKING:
import trimesh
from isaaclab.envs import ManagerBasedEnv
from isaaclab.managers import EventTermCfg, SceneEntityCfg
from isaaclab.sensors.contact_sensor.contact_sensor_cfg import ContactSensorCfg
from isaaclab_tasks.manager_based.manipulation.stack.mdp.franka_stack_events import randomize_object_pose

from isaaclab_arena.assets.asset import Asset

# Re-export ObjectType from the lightweight module so existing
# `from isaaclab_arena.assets.object_base import ObjectType` consumers keep working,
# while pure-Python spec modules can import from `object_type` directly without
# pulling in isaaclab/omni/pxr at module-load time.
from isaaclab_arena.assets.object_type import ObjectType
from isaaclab_arena.relations.collision_mode import CollisionMode
from isaaclab_arena.relations.relations import IsAnchor, Relation, RelationBase, UnaryRelation
from isaaclab_arena.relations.placement_asset import PlacementAsset
from isaaclab_arena.terms.events import set_object_pose, set_object_pose_per_env
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox
from isaaclab_arena.utils.pose import Pose, PosePerEnv, PoseRange
from isaaclab_arena.utils.velocity import Velocity
from isaaclab_arena.variations.object_mass_variation import ObjectMassVariation
Expand All @@ -40,7 +32,7 @@
]


class ObjectBase(Asset, ABC):
class ObjectBase(PlacementAsset, ABC):
"""Parent class for (spawnable) Object and ObjectReference."""

def __init__(
Expand All @@ -57,36 +49,9 @@ def __init__(
self.object_type = object_type
if self.object_type == ObjectType.RIGID:
self.add_variation(ObjectMassVariation(self.name))
self.initial_pose: Pose | PoseRange | PosePerEnv | None = None
self.initial_velocity: Velocity | None = None
self.object_cfg = None
self.event_cfg = None
self.relations: list[RelationBase] = []
# None means use the solver's default collision mode for this object.
self.collision_mode: CollisionMode | None = None
# If True, mesh collision replaces non-watertight meshes with their convex hull.
self.repair_collision_mesh_non_watertight = True

def get_initial_pose(self) -> Pose | PoseRange | PosePerEnv | None:
"""Return the current initial pose of this object.

Subclasses may override to derive the pose from other sources
(e.g. a parent asset), falling back to ``self.initial_pose``.
"""
return self.initial_pose

@abstractmethod
def get_bounding_box(self) -> AxisAlignedBoundingBox:
"""Get local bounding box (relative to object origin)."""
...

@abstractmethod
def get_world_bounding_box(self) -> AxisAlignedBoundingBox:
"""Get bounding box in world coordinates (local bbox rotated and translated)."""
...

def get_collision_mesh(self) -> trimesh.Trimesh | None:
"""Return collision mesh, or None to fall back to AABB overlap."""

def _get_initial_pose_as_pose(self) -> Pose | None:
"""Return a single ``Pose`` suitable for *init_state* and bounding-box calculations.
Expand All @@ -111,12 +76,25 @@ def set_initial_pose(self, pose: Pose | PoseRange | PosePerEnv) -> None:
pose: A fixed ``Pose``, a ``PoseRange`` (randomised on reset),
or a ``PosePerEnv`` (distinct pose per environment).
"""
self._set_pose_state(pose)
self.event_cfg = self._init_event_cfg()

def _set_pose_state(self, pose: Pose | PoseRange | PosePerEnv) -> None:
"""Update the stored pose and materialized object configuration."""
self.initial_pose = pose
initial_pose = self._get_initial_pose_as_pose()
if initial_pose is not None and self.object_cfg is not None:
self.object_cfg.init_state.pos = initial_pose.position_xyz
self.object_cfg.init_state.rot = initial_pose.rotation_xyzw
self.event_cfg = self._init_event_cfg()

def set_spawn_pose(self, pose: Pose) -> None:
"""Set the scene-construction pose without rebuilding the reset event."""
assert self.object_cfg is not None, "object_cfg must be initialized before setting the spawn pose"
self._set_pose_state(pose)

def has_pose_reset_event(self) -> bool:
"""Return whether the asset owns a root-pose reset event."""
return self.event_cfg is not None

def set_initial_velocity(self, velocity: Velocity) -> None:
"""Set / override the initial velocity and rebuild derived configs.
Expand Down Expand Up @@ -181,19 +159,6 @@ def _init_event_cfg(self) -> EventTermCfg | None:
},
)

def get_relations(self) -> list[RelationBase]:
"""Get all relations for this object."""
return self.relations

@property
def is_anchor(self) -> bool:
"""True if this object has an IsAnchor relation."""
return any(isinstance(r, IsAnchor) for r in self.relations)

def get_spatial_relations(self) -> list[RelationBase]:
"""Get only spatial relations (On, NextTo, AtPosition, etc.), excluding markers like IsAnchor."""
return [r for r in self.relations if isinstance(r, (Relation, UnaryRelation))]

def set_prim_path(self, prim_path: str) -> None:
self.prim_path = prim_path

Expand Down
11 changes: 11 additions & 0 deletions isaaclab_arena/cli/isaaclab_arena_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ def add_isaaclab_arena_cli_args(parser: argparse.ArgumentParser) -> None:
" layout."
),
)
arena_group.add_argument(
"--relation_collision_mode",
type=str,
choices=["bbox", "mesh"],
default="bbox",
help=(
"Collision mode for relation placement. 'mesh' checks actual geometry and adds the scene background as a"
" collision obstacle (slower, avoids clutter intersections); 'bbox' (default) uses fast box overlap and"
" ignores the background mesh."
),
)
arena_group.add_argument(
"--list_variations",
action="store_true",
Expand Down
26 changes: 26 additions & 0 deletions isaaclab_arena/embodiments/droid/droid.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from isaaclab_arena.embodiments.embodiment_base import EmbodimentBase
from isaaclab_arena.embodiments.franka.franka import franka_stack_events
from isaaclab_arena.utils.cameras import ArenaCameraCfg
from isaaclab_arena.utils.embodiment_placement import PlacementUsdSource
from isaaclab_arena.utils.pose import Pose
from isaaclab_arena.variations.camera_extrinsics_variation import CameraExtrinsicsVariation

Expand Down Expand Up @@ -106,6 +107,31 @@ def _update_scene_cfg_with_robot_initial_pose(self, scene_config: Any, pose: Pos

return scene_config

def get_placement_usd_sources(self) -> list[PlacementUsdSource]:
"""Union the robot and stand USD footprints for relation solving."""
sources = super().get_placement_usd_sources()
scene_config = self.scene_config
assert scene_config is not None and hasattr(scene_config, "stand")
stand = scene_config.stand
stand_spawn = stand.spawn
stand_scale = getattr(stand_spawn, "scale", None) or (1.0, 1.0, 1.0)
if not isinstance(stand_scale, tuple):
stand_scale = tuple(stand_scale)
stand_pos = stand.init_state.pos
stand_rot = stand.init_state.rot
if not isinstance(stand_pos, tuple):
stand_pos = tuple(stand_pos)
if not isinstance(stand_rot, tuple):
stand_rot = tuple(stand_rot)
sources.append(
PlacementUsdSource(
usd_path=stand_spawn.usd_path,
scale=stand_scale,
offset_pose=Pose(position_xyz=stand_pos, rotation_xyzw=stand_rot),
)
)
return sources

def set_initial_joint_pose(self, initial_joint_pose: list[float]) -> None:
self.event_config.init_franka_arm_pose.params["default_pose"] = initial_joint_pose

Expand Down
Loading
Loading