Skip to content

Commit 91dc6e1

Browse files
committed
Relation solver places embodiments
1 parent 1afe366 commit 91dc6e1

32 files changed

Lines changed: 871 additions & 186 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
from __future__ import annotations
7+
8+
import trimesh
9+
10+
from isaaclab_arena.relations.relations import IsAnchor, Relation, RelationBase, UnaryRelation
11+
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox, quaternion_to_90_deg_z_quarters
12+
from isaaclab_arena.utils.pose import Pose
13+
14+
15+
class DummyEmbodiment:
16+
"""Embodiment stand-in for relation-solver tests without Isaac Sim dependencies."""
17+
18+
def __init__(
19+
self,
20+
name: str,
21+
bounding_box: AxisAlignedBoundingBox,
22+
initial_pose: Pose | None = None,
23+
relations: list[RelationBase] | None = None,
24+
collision_mesh: trimesh.Trimesh | None = None,
25+
placement_scene_entity_name: str = "robot",
26+
):
27+
self.name = name
28+
self.initial_pose = initial_pose
29+
self.bounding_box = bounding_box
30+
self.relations = list(relations or [])
31+
self._collision_mesh = collision_mesh
32+
self.placement_scene_entity_name = placement_scene_entity_name
33+
34+
@property
35+
def placement_kind(self) -> str:
36+
return "embodiment"
37+
38+
def add_relation(self, relation: RelationBase) -> None:
39+
assert not isinstance(relation, IsAnchor), "Embodiment cannot be marked as an anchor"
40+
self.relations.append(relation)
41+
42+
def get_relations(self) -> list[RelationBase]:
43+
return self.relations
44+
45+
def get_spatial_relations(self) -> list[RelationBase]:
46+
return [relation for relation in self.relations if isinstance(relation, (Relation, UnaryRelation))]
47+
48+
def get_bounding_box(self) -> AxisAlignedBoundingBox:
49+
return self.bounding_box
50+
51+
def get_world_bounding_box(self) -> AxisAlignedBoundingBox:
52+
if self.initial_pose is None:
53+
return self.bounding_box
54+
quarters = quaternion_to_90_deg_z_quarters(self.initial_pose.rotation_xyzw)
55+
return self.bounding_box.rotated_90_around_z(quarters).translated(self.initial_pose.position_xyz)
56+
57+
def get_collision_mesh(self) -> trimesh.Trimesh | None:
58+
return self._collision_mesh
59+
60+
def get_initial_pose(self) -> Pose | None:
61+
return self.initial_pose
62+
63+
def set_initial_pose(self, pose: Pose) -> None:
64+
self.initial_pose = pose
65+
66+
@property
67+
def is_anchor(self) -> bool:
68+
return False

isaaclab_arena/assets/dummy_object.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ def is_initial_pose_set(self) -> bool:
7575
def is_anchor(self) -> bool:
7676
return any(isinstance(r, IsAnchor) for r in self.relations)
7777

78+
@property
79+
def placement_kind(self) -> str:
80+
return "object"
81+
82+
@property
83+
def placement_scene_entity_name(self) -> str:
84+
return self.name
85+
7886
def get_collision_mesh(self) -> trimesh.Trimesh | None:
7987
"""Return the collision mesh, or None to fall back to AABB."""
8088
return self._collision_mesh

isaaclab_arena/assets/object.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,6 @@ def __init__(
6666
self.object_cfg = self._init_object_cfg()
6767
self.event_cfg = self._init_event_cfg()
6868

69-
def add_relation(self, relation: RelationBase) -> None:
70-
"""Add a relation to this object."""
71-
self.relations.append(relation)
72-
7369
def get_bounding_box(self) -> AxisAlignedBoundingBox:
7470
"""Get local bounding box (relative to object origin)."""
7571
assert self.usd_path is not None

isaaclab_arena/assets/object_base.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,20 @@ def get_relations(self) -> list[RelationBase]:
182182
"""Get all relations for this object."""
183183
return self.relations
184184

185+
def add_relation(self, relation: RelationBase) -> None:
186+
"""Add a relation to this object."""
187+
self.relations.append(relation)
188+
189+
@property
190+
def placement_kind(self) -> str:
191+
"""Scene objects use the object placement apply path."""
192+
return "object"
193+
194+
@property
195+
def placement_scene_entity_name(self) -> str:
196+
"""Isaac Lab scene entity name used when writing solved poses to sim."""
197+
return self.name
198+
185199
@property
186200
def is_anchor(self) -> bool:
187201
"""True if this object has an IsAnchor relation."""

isaaclab_arena/embodiments/droid/droid.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from isaaclab_arena.embodiments.droid.observations import arm_joint_pos, ee_pos, ee_quat, gripper_pos
4141
from isaaclab_arena.embodiments.embodiment_base import EmbodimentBase
4242
from isaaclab_arena.embodiments.franka.franka import franka_stack_events
43+
from isaaclab_arena.utils.embodiment_placement import PlacementUsdSource
4344
from isaaclab_arena.utils.pose import Pose
4445
from isaaclab_arena.variations.camera_extrinsics_variation import CameraExtrinsicsVariation
4546

@@ -85,6 +86,31 @@ def _update_scene_cfg_with_robot_initial_pose(self, scene_config: Any, pose: Pos
8586

8687
return scene_config
8788

89+
def get_placement_usd_sources(self) -> list[PlacementUsdSource]:
90+
"""Union the robot and stand USD footprints for relation solving."""
91+
sources = super().get_placement_usd_sources()
92+
scene_config = self.scene_config
93+
assert scene_config is not None and hasattr(scene_config, "stand")
94+
stand = scene_config.stand
95+
stand_spawn = stand.spawn
96+
stand_scale = getattr(stand_spawn, "scale", None) or (1.0, 1.0, 1.0)
97+
if not isinstance(stand_scale, tuple):
98+
stand_scale = tuple(stand_scale)
99+
stand_pos = stand.init_state.pos
100+
stand_rot = stand.init_state.rot
101+
if not isinstance(stand_pos, tuple):
102+
stand_pos = tuple(stand_pos)
103+
if not isinstance(stand_rot, tuple):
104+
stand_rot = tuple(stand_rot)
105+
sources.append(
106+
PlacementUsdSource(
107+
usd_path=stand_spawn.usd_path,
108+
scale=stand_scale,
109+
offset_pose=Pose(position_xyz=stand_pos, rotation_xyzw=stand_rot),
110+
)
111+
)
112+
return sources
113+
88114
def set_initial_joint_pose(self, initial_joint_pose: list[float]) -> None:
89115
self.event_config.init_franka_arm_pose.params["default_pose"] = initial_joint_pose
90116

@@ -162,7 +188,7 @@ class DroidSceneCfg:
162188
prim_path="{ENV_REGEX_NS}/Robot",
163189
spawn=sim_utils.UsdFileCfg(
164190
usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Arena/assets/robot_library/droid/franka_robotiq_2f_85_flattened.usd",
165-
activate_contact_sensors=True,
191+
activate_contact_sensors=False,
166192
rigid_props=sim_utils.RigidBodyPropertiesCfg(
167193
disable_gravity=True,
168194
max_depenetration_velocity=5.0,

isaaclab_arena/embodiments/embodiment_base.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,27 @@
33
#
44
# SPDX-License-Identifier: Apache-2.0
55

6+
from __future__ import annotations
7+
68
from collections.abc import Mapping
7-
from typing import Any
9+
from typing import TYPE_CHECKING, Any
810

911
from isaaclab.envs import ManagerBasedRLMimicEnv
1012
from isaaclab.managers.recorder_manager import RecorderManagerBaseCfg
1113

1214
from isaaclab_arena.assets.asset import Asset
1315
from isaaclab_arena.embodiments.common.arm_mode import ArmMode
16+
from isaaclab_arena.relations.collision_mode import CollisionMode
17+
from isaaclab_arena.relations.relations import IsAnchor, Relation, RelationBase, UnaryRelation
18+
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox, quaternion_to_90_deg_z_quarters
1419
from isaaclab_arena.utils.cameras import make_camera_observation_cfg
1520
from isaaclab_arena.utils.configclass import combine_configclass_instances
21+
from isaaclab_arena.utils.embodiment_placement import PlacementUsdSource, compute_embodiment_placement_bbox
1622
from isaaclab_arena.utils.pose import Pose
1723

24+
if TYPE_CHECKING:
25+
import trimesh
26+
1827

1928
class EmbodimentBase(Asset):
2029

@@ -37,6 +46,12 @@ def __init__(
3746
self.initial_pose = initial_pose
3847
self.concatenate_observation_terms = concatenate_observation_terms
3948
self.arm_mode = arm_mode or self.default_arm_mode
49+
self.relations: list[RelationBase] = []
50+
self._placement_bounding_box: AxisAlignedBoundingBox | None = None
51+
# None means use the solver's default collision mode for this embodiment.
52+
self.collision_mode: CollisionMode | None = None
53+
# If True, mesh collision replaces non-watertight meshes with their convex hull.
54+
self.repair_collision_mesh_non_watertight = True
4055
# These should be filled by the subclass
4156
self.scene_config: Any | None = None
4257
self.camera_config: Any | None = None
@@ -50,6 +65,70 @@ def __init__(
5065
self.xr: Any | None = None
5166
self.termination_cfg: Any | None = None
5267

68+
@property
69+
def placement_kind(self) -> str:
70+
"""Embodiments use the embodiment placement apply path."""
71+
return "embodiment"
72+
73+
@property
74+
def placement_scene_entity_name(self) -> str:
75+
"""Isaac Lab scene entity name used when writing solved poses to sim."""
76+
return self.get_embodiment_name_in_scene()
77+
78+
def add_relation(self, relation: RelationBase) -> None:
79+
"""Attach a spatial relation to this embodiment."""
80+
assert not isinstance(relation, IsAnchor), "Embodiment cannot be marked as an anchor"
81+
self.relations.append(relation)
82+
83+
def get_relations(self) -> list[RelationBase]:
84+
"""Return all relations attached to this embodiment."""
85+
return self.relations
86+
87+
def get_spatial_relations(self) -> list[RelationBase]:
88+
"""Return spatial constraints, excluding placement markers."""
89+
return [relation for relation in self.relations if isinstance(relation, (Relation, UnaryRelation))]
90+
91+
def get_initial_pose(self) -> Pose | None:
92+
"""Return the embodiment's initial pose."""
93+
return self.initial_pose
94+
95+
@property
96+
def is_anchor(self) -> bool:
97+
"""Embodiments are never relation anchors."""
98+
return False
99+
100+
def get_placement_usd_sources(self) -> list[PlacementUsdSource]:
101+
"""Return USD assets that define this embodiment's placement footprint."""
102+
scene_config = self.scene_config
103+
assert scene_config is not None and hasattr(
104+
scene_config, "robot"
105+
), f"{self.__class__.__name__} must populate scene_config.robot before placement bbox lookup"
106+
robot = scene_config.robot
107+
spawn = robot.spawn
108+
usd_path = spawn.usd_path
109+
scale = getattr(spawn, "scale", None) or (1.0, 1.0, 1.0)
110+
if not isinstance(scale, tuple):
111+
scale = tuple(scale)
112+
return [PlacementUsdSource(usd_path=usd_path, scale=scale)]
113+
114+
def get_bounding_box(self) -> AxisAlignedBoundingBox:
115+
"""Return the USD-derived local placement footprint relative to the robot origin."""
116+
if self._placement_bounding_box is None:
117+
self._placement_bounding_box = compute_embodiment_placement_bbox(self.get_placement_usd_sources())
118+
return self._placement_bounding_box
119+
120+
def get_world_bounding_box(self) -> AxisAlignedBoundingBox:
121+
"""Return the placement footprint in world coordinates."""
122+
local_bbox = self.get_bounding_box()
123+
pose = self.get_initial_pose()
124+
if pose is None:
125+
return local_bbox
126+
quarters = quaternion_to_90_deg_z_quarters(pose.rotation_xyzw)
127+
return local_bbox.rotated_90_around_z(quarters).translated(pose.position_xyz)
128+
129+
def get_collision_mesh(self) -> trimesh.Trimesh | None:
130+
"""Embodiments fall back to AABB overlap checks in the relation solver."""
131+
53132
def set_initial_pose(self, pose: Pose) -> None:
54133
self.initial_pose = pose
55134

isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
if TYPE_CHECKING:
2424
from isaaclab_arena.environment_spec.arena_env_graph_spec import ArenaEnvGraphSpec
2525

26+
_EMBODIMENT_SPATIAL_RELATION_KINDS = frozenset(
27+
{"on", "next_to", "not_next_to", "at_position", "position_limits", "face_to"}
28+
)
29+
2630

2731
def build_arena_env_from_graph_spec(graph_spec: ArenaEnvGraphSpec, enable_cameras: bool = False) -> Any:
2832
"""Build an IsaacLabArenaEnvironment from a validated ArenaEnvGraphSpec.
@@ -33,6 +37,7 @@ def build_arena_env_from_graph_spec(graph_spec: ArenaEnvGraphSpec, enable_camera
3337
"""
3438
assets_by_node_id = _instantiate_assets_from_spec(graph_spec, AssetRegistry(), enable_cameras=enable_cameras)
3539
_ensure_scene_lighting(graph_spec, assets_by_node_id)
40+
_validate_embodiment_relations(graph_spec, graph_spec.relations)
3641
_attach_spatial_relations_to_assets(graph_spec.relations, assets_by_node_id)
3742
scene_assets = [asset for node_id, asset in assets_by_node_id.items() if node_id != graph_spec.embodiment.id]
3843
return IsaacLabArenaEnvironment(
@@ -129,6 +134,18 @@ def _instantiate_assets_from_spec(
129134
return assets_by_node_id
130135

131136

137+
def _validate_embodiment_relations(graph_spec: ArenaEnvGraphSpec, relations: list[SpatialRelationSpec]) -> None:
138+
"""Reject unsupported or anchor-style relations on the embodiment node."""
139+
embodiment_id = graph_spec.embodiment.id
140+
for relation in relations:
141+
if relation.subject != embodiment_id:
142+
continue
143+
assert relation.kind != "is_anchor", "Embodiment cannot be marked is_anchor"
144+
assert (
145+
relation.kind in _EMBODIMENT_SPATIAL_RELATION_KINDS
146+
), f"Embodiment relation kind {relation.kind!r} is not supported for placement solving"
147+
148+
132149
def _attach_spatial_relations_to_assets(
133150
relations: list[SpatialRelationSpec], assets_by_node_id: dict[str, type[Asset]]
134151
) -> None:

isaaclab_arena/environments/arena_env_builder.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,12 @@ def __init__(
6767
self._placement_event_cfg: EventTermCfg | None = None
6868

6969
def _solve_relations(self) -> None:
70-
"""Solve spatial relations for objects in the scene.
70+
"""Solve spatial relations for scene objects and optional embodiment placement.
7171
7272
This method:
73-
1. Collects all objects from the scene that have relations
74-
2. Builds an object-placement pool
75-
3. Reuses the object-only relation placer
76-
4. Applies solved positions either by writing fixed per-object initial poses
77-
or by registering a pooled reset placement event
73+
1. Collects scene objects and the embodiment when it carries spatial relations
74+
2. Builds a placement pool and runs the relation solver jointly
75+
3. Applies solved positions to scene objects and/or the embodiment spawn pose
7876
7977
Behaviour on reset depends on ``ObjectPlacerParams.resolve_on_reset``.
8078
When the environment does not provide placer parameters, the builder creates
@@ -85,7 +83,10 @@ def _solve_relations(self) -> None:
8583
* **False** — applies one layout per environment so per-object reset
8684
events restore the same layout every time.
8785
"""
88-
objects_with_relations = self.arena_env.scene.get_objects_with_relations()
86+
placement_entities = list(self.arena_env.scene.get_objects_with_relations())
87+
embodiment = self.arena_env.embodiment
88+
if embodiment is not None and embodiment.get_spatial_relations():
89+
placement_entities.append(embodiment)
8990

9091
placer_params = self.arena_env.placer_params
9192
if placer_params is None:
@@ -96,7 +97,7 @@ def _solve_relations(self) -> None:
9697
if self.cfg.resolve_on_reset is not None:
9798
placer_params.resolve_on_reset = self.cfg.resolve_on_reset
9899
self._placement_event_cfg = solve_and_apply_relation_placement(
99-
objects_with_relations,
100+
placement_entities,
100101
num_envs=self.cfg.num_envs,
101102
placer_params=placer_params,
102103
scene_assets=self.arena_env.scene.assets.values(),

0 commit comments

Comments
 (0)