Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
900ded4
ObjectReferences work with ObjectPlacer
cvolkcvolk Jan 27, 2026
b3f0388
Merge branch 'main' into cvolk/object_references_work_object_placer
cvolkcvolk Jan 27, 2026
adfbab6
Introduce unary AtPosition Relation
cvolkcvolk Jan 27, 2026
aef2164
Clean up
cvolkcvolk Jan 27, 2026
f83dcc1
Update strategy weight
cvolkcvolk Jan 27, 2026
bb0e244
Merge branch 'main' into cvolk/at_position_strategy
cvolkcvolk Jan 28, 2026
4b33517
Merge branch 'main' into cvolk/at_position_strategy
cvolkcvolk Jan 28, 2026
ae2f436
Resolve conflicts and fix isaacSim notebook
cvolkcvolk Jan 28, 2026
4d71d61
Merge branch 'main' into cvolk/object_references_work_object_placer
cvolkcvolk Jan 28, 2026
b25fbd2
Merge branch 'cvolk/at_position_strategy' into cvolk/object_reference…
cvolkcvolk Jan 28, 2026
8879774
ObjectReferences work in IsaacSim notebook
cvolkcvolk Jan 28, 2026
c84e2ab
Reduce number of iteration steps
cvolkcvolk Jan 28, 2026
a9a1511
Merge branch 'main' into cvolk/object_references_work_object_placer
cvolkcvolk Jan 28, 2026
1c0f07f
Remove lightwheel kitchen background
cvolkcvolk Jan 28, 2026
6cab508
revert changes in compile_env_notebook.py
cvolkcvolk Jan 28, 2026
2138d3b
Remove unused variable
cvolkcvolk Jan 28, 2026
20e288a
don't import IsaacSim dependecies
cvolkcvolk Jan 28, 2026
bb25b3d
Update comment
cvolkcvolk Jan 28, 2026
5d211b3
Merge branch 'main' into cvolk/object_references_work_object_placer
cvolkcvolk Jan 29, 2026
7d39795
Merge branch 'main' into cvolk/object_references_work_object_placer
cvolkcvolk Jan 29, 2026
0400aa1
Scale ObjectReference bounding box correctly
cvolkcvolk Jan 29, 2026
8b0b3c1
Initial_poses uses prim transform instead of geometry center
cvolkcvolk Jan 29, 2026
8102e26
Move the IsAnchor check for ObjectReference out of object_placer.py
cvolkcvolk Jan 29, 2026
6f932c8
Trigger CI
cvolkcvolk Jan 29, 2026
897f9b8
Merge branch 'main' into cvolk/object_references_work_object_placer
cvolkcvolk Jan 30, 2026
d248a54
Remove initial_pose from ObjectReference and use get_initial_pose
cvolkcvolk Jan 30, 2026
a1b3d74
update docstring about ObjectReferences being Anchors
cvolkcvolk Jan 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 6 additions & 24 deletions isaaclab_arena/assets/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@

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 AtPosition, Relation, RelationBase
from isaaclab_arena.relations.relations import RelationBase
from isaaclab_arena.terms.events import set_object_pose
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox
from isaaclab_arena.utils.pose import Pose, PoseRange
from isaaclab_arena.utils.usd.rigid_bodies import find_shallowest_rigid_body
from isaaclab_arena.utils.usd_helpers import compute_bounding_box_from_usd, has_light, open_stage
from isaaclab_arena.utils.usd_helpers import compute_local_bounding_box_from_usd, has_light, open_stage


class Object(ObjectBase):
Expand Down Expand Up @@ -55,46 +55,28 @@ def __init__(
self.bounding_box = None
self.object_cfg = self._init_object_cfg()
self.event_cfg = self._init_event_cfg()
self.relations = []

def add_relation(self, relation: RelationBase) -> None:
"""Add a relation to this object."""
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, AtPosition))]

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_bounding_box_from_usd(self.usd_path, self.scale)
self.bounding_box = compute_local_bounding_box_from_usd(self.usd_path, self.scale)
return self.bounding_box

def get_world_bounding_box(self) -> AxisAlignedBoundingBox:
"""Get bounding box in world coordinates (local bbox + position offset)."""
local_bbox = self.get_bounding_box()
pos = self.initial_pose.position_xyz if self.initial_pose else (0, 0, 0)
return AxisAlignedBoundingBox(
min_point=(
local_bbox.min_point[0] + pos[0],
local_bbox.min_point[1] + pos[1],
local_bbox.min_point[2] + pos[2],
),
max_point=(
local_bbox.max_point[0] + pos[0],
local_bbox.max_point[1] + pos[1],
local_bbox.max_point[2] + pos[2],
),
)
return local_bbox.translated(pos)

def get_corners(self, pos: torch.Tensor) -> torch.Tensor:
assert self.usd_path is not None
if self.bounding_box is None:
self.bounding_box = compute_bounding_box_from_usd(self.usd_path, self.scale)
self.bounding_box = compute_local_bounding_box_from_usd(self.usd_path, self.scale)
return self.bounding_box.get_corners_at(pos)

def set_initial_pose(self, pose: Pose | PoseRange) -> None:
Expand Down
10 changes: 10 additions & 0 deletions isaaclab_arena/assets/object_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from isaaclab.sensors.contact_sensor.contact_sensor_cfg import ContactSensorCfg

from isaaclab_arena.assets.asset import Asset
from isaaclab_arena.relations.relations import AtPosition, Relation, RelationBase
from isaaclab_arena.utils.pose import Pose


Expand Down Expand Up @@ -40,6 +41,15 @@ def __init__(
self.object_type = object_type
self.object_cfg = None
self.event_cfg = None
self.relations: list[RelationBase] = []

def get_relations(self) -> list[RelationBase]:
"""Get all relations for this object."""
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, AtPosition))]

def set_prim_path(self, prim_path: str) -> None:
self.prim_path = prim_path
Expand Down
65 changes: 57 additions & 8 deletions isaaclab_arena/assets/object_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,26 @@
from isaaclab_arena.affordances.openable import Openable
from isaaclab_arena.assets.asset import Asset
from isaaclab_arena.assets.object_base import ObjectBase, ObjectType
from isaaclab_arena.relations.relations import IsAnchor, RelationBase
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox
from isaaclab_arena.utils.pose import Pose
from isaaclab_arena.utils.usd_helpers import open_stage
from isaaclab_arena.utils.usd_helpers import compute_local_bounding_box_from_prim, open_stage
from isaaclab_arena.utils.usd_pose_helpers import get_prim_pose_in_default_prim_frame


class ObjectReference(ObjectBase):
"""An object which *refers* to an existing element in the scene"""

def __init__(self, parent_asset: Asset, **kwargs):
# Call the parent class constructor first as we need the parent asset and initial pose relative to the parent to be set.
super().__init__(**kwargs)
self.initial_pose_relative_to_parent = self._get_referenced_prim_pose_relative_to_parent(parent_asset)
self.parent_asset = parent_asset
# Check that the object reference is not a spawner.
# Store parent's scale for bounding box calculations
self._parent_scale = getattr(parent_asset, "scale", (1.0, 1.0, 1.0))
# Get the prim's transform pose (not geometry center - solver is origin-agnostic)
self.initial_pose_relative_to_parent = self._get_referenced_prim_pose_relative_to_parent(parent_asset)
assert self.object_type != ObjectType.SPAWNER, "Object reference cannot be a spawner"
self.object_cfg = self._init_object_cfg()
self._bounding_box: AxisAlignedBoundingBox | None = None

def get_initial_pose(self) -> Pose:
if self.parent_asset.initial_pose is None:
Expand All @@ -36,6 +40,43 @@ def get_initial_pose(self) -> Pose:
T_W_O = T_W_P.multiply(T_P_O)
return T_W_O

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

ObjectReference only supports IsAnchor relations because the placement
solver treats references as fixed points.

Args:
relation: Must be an IsAnchor relation.
"""
assert isinstance(relation, IsAnchor), (
f"ObjectReference only supports IsAnchor relations, got {type(relation).__name__}. "
"The placement solver does not optimize ObjectReference positions."
)
self.relations.append(relation)

def get_bounding_box(self) -> AxisAlignedBoundingBox:
"""Get local bounding box of the referenced prim (relative to prim transform).

The bounding box is relative to the prim's transform origin, consistent with
how Object.get_bounding_box() returns bbox relative to USD origin.

The bounding box is computed lazily and cached for subsequent calls.
"""
if self._bounding_box is None:
with open_stage(self.parent_asset.usd_path) as parent_stage:
prim_path_in_usd = self.isaaclab_prim_path_to_original_prim_path(
self.prim_path, self.parent_asset, parent_stage
)
raw_bbox = compute_local_bounding_box_from_prim(parent_stage, prim_path_in_usd)
# Apply parent's scale (no centering - solver is origin-agnostic)
self._bounding_box = raw_bbox.scaled(self._parent_scale)
return self._bounding_box

def get_world_bounding_box(self) -> AxisAlignedBoundingBox:
"""Get bounding box in world coordinates (local bbox + world position)."""
return self.get_bounding_box().translated(self.get_initial_pose().position_xyz)

def get_contact_sensor_cfg(self, contact_against_prim_paths: list[str] | None = None) -> ContactSensorCfg:
# NOTE(alexmillane): Right now this requires that the object
# has the contact sensor enabled prior to using this reference.
Expand Down Expand Up @@ -86,15 +127,23 @@ def _generate_base_cfg(self) -> AssetBaseCfg:
return object_cfg

def _get_referenced_prim_pose_relative_to_parent(self, parent_asset: Asset) -> Pose:
"""Get the prim's transform pose relative to the parent's default prim.

The position is scaled by the parent's scale factor.
"""
with open_stage(parent_asset.usd_path) as parent_stage:
# Remove the ENV_REGEX_NS prefix from the prim path (because this
# is added later by IsaacLab). In the original USD file, the prim path
# is the path after the ENV_REGEX_NS prefix.
prim_path_in_usd = self.isaaclab_prim_path_to_original_prim_path(self.prim_path, parent_asset, parent_stage)
prim = parent_stage.GetPrimAtPath(prim_path_in_usd)
if not prim:
raise ValueError(f"No prim found with path {prim_path_in_usd} in {parent_asset.usd_path}")
return get_prim_pose_in_default_prim_frame(prim, parent_stage)
prim_pose = get_prim_pose_in_default_prim_frame(prim, parent_stage)
# Apply parent's scale to the position
scaled_pos = (
prim_pose.position_xyz[0] * self._parent_scale[0],
prim_pose.position_xyz[1] * self._parent_scale[1],
prim_pose.position_xyz[2] * self._parent_scale[2],
)
return Pose(position_xyz=scaled_pos, rotation_wxyz=prim_pose.rotation_wxyz)

def isaaclab_prim_path_to_original_prim_path(
self, isaaclab_prim_path: str, parent_asset: Asset, stage: Usd.Stage
Expand Down
15 changes: 8 additions & 7 deletions isaaclab_arena/environments/arena_env_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from isaaclab_arena.assets.asset_registry import DeviceRegistry
from isaaclab_arena.assets.object import Object
from isaaclab_arena.assets.object_reference import ObjectReference
from isaaclab_arena.embodiments.no_embodiment import NoEmbodiment
from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment
from isaaclab_arena.environments.isaaclab_arena_manager_based_env import (
Expand Down Expand Up @@ -45,20 +46,20 @@ def orchestrate(self) -> None:
self.arena_env.embodiment, self.arena_env.scene, self.arena_env.task
)

def _get_objects_with_relations(self) -> list[Object]:
def _get_objects_with_relations(self) -> list[Object | ObjectReference]:
"""Get all objects from the scene that have relations.

Returns:
List of Object instances that have at least one relation.
List of Object or ObjectReference instances that have at least one relation.
"""
objects_with_relations: list[Object] = []
objects_with_relations: list[Object | ObjectReference] = []
for asset in self.arena_env.scene.assets.values():
if not isinstance(asset, Object):
# Fail early if a non-Object asset has relations - they won't be solved
if not isinstance(asset, (Object, ObjectReference)):
# Fail early if a non-Object/ObjectReference asset has relations - they won't be solved
# TODO(cvolk, 2026-01-26): Support ObjectSets.
assert not (hasattr(asset, "get_relations") and asset.get_relations()), (
f"Asset '{asset.name}' has relations but is not an Object "
f"(type: {type(asset).__name__}). Only Object instances support relations."
f"Asset '{asset.name}' has relations but is not an Object or ObjectReference "
f"(type: {type(asset).__name__}). Only Object and ObjectReference instances support relations."
)
continue
if asset.get_relations():
Expand Down
25 changes: 13 additions & 12 deletions isaaclab_arena/relations/object_placer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

if TYPE_CHECKING:
from isaaclab_arena.assets.object import Object
from isaaclab_arena.assets.object_reference import ObjectReference


class ObjectPlacer:
Expand All @@ -41,7 +42,7 @@ def __init__(self, params: ObjectPlacerParams | None = None):

def place(
self,
objects: list[Object],
objects: list[Object | ObjectReference],
) -> PlacementResult:
"""Place objects according to their spatial relations.

Expand All @@ -68,7 +69,7 @@ def place(

# Validate all anchors have initial_pose set
for anchor in anchor_objects:
assert anchor.initial_pose is not None, (
assert anchor.get_initial_pose() is not None, (
f"Anchor object '{anchor.name}' must have an initial_pose set. "
"Call anchor_object.set_initial_pose(...) before placing."
)
Expand All @@ -87,7 +88,7 @@ def place(
init_bounds = self._get_init_bounds(anchor_objects[0])

# Placement loop with retries
best_positions: dict[Object, tuple[float, float, float]] = {}
best_positions: dict[Object | ObjectReference, tuple[float, float, float]] = {}
best_loss = float("inf")
success = False

Expand Down Expand Up @@ -129,7 +130,7 @@ def place(
attempts=attempt + 1,
)

def _get_init_bounds(self, anchor_object: Object) -> AxisAlignedBoundingBox:
def _get_init_bounds(self, anchor_object: Object | ObjectReference) -> AxisAlignedBoundingBox:
"""Get bounds for random position initialization.

If init_bounds is provided in params, use it.
Expand Down Expand Up @@ -162,29 +163,29 @@ def _get_init_bounds(self, anchor_object: Object) -> AxisAlignedBoundingBox:

def _generate_initial_positions(
self,
objects: list[Object],
anchor_objects: set[Object],
objects: list[Object | ObjectReference],
anchor_objects: Object | ObjectReference,
init_bounds: AxisAlignedBoundingBox,
) -> dict[Object, tuple[float, float, float]]:
) -> dict[Object | ObjectReference, tuple[float, float, float]]:
"""Generate initial positions for all objects.

Anchors keep their current initial_pose, others get random positions.

Returns:
Dictionary mapping all objects to their starting positions.
"""
positions: dict[Object, tuple[float, float, float]] = {}
positions: dict[Object | ObjectReference, tuple[float, float, float]] = {}
for obj in objects:
if obj in anchor_objects:
positions[obj] = obj.initial_pose.position_xyz
positions[obj] = obj.get_initial_pose().position_xyz
else:
random_pose = get_random_pose_within_bounding_box(init_bounds)
positions[obj] = random_pose.position_xyz
return positions

def _validate_placement(
self,
positions: dict[Object, tuple[float, float, float]],
positions: dict[Object | ObjectReference, tuple[float, float, float]],
) -> bool:
"""Validate that the placement is geometrically valid.

Expand All @@ -202,8 +203,8 @@ def _validate_placement(

def _apply_positions(
self,
positions: dict[Object, tuple[float, float, float]],
anchor_objects: set[Object],
positions: dict[Object | ObjectReference, tuple[float, float, float]],
anchor_objects: Object | ObjectReference,
) -> None:
"""Apply solved positions to objects (skipping anchors)."""
for obj, pos in positions.items():
Expand Down
13 changes: 7 additions & 6 deletions isaaclab_arena/relations/relation_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

if TYPE_CHECKING:
from isaaclab_arena.assets.object import Object
from isaaclab_arena.assets.object_reference import ObjectReference


class RelationSolver:
Expand Down Expand Up @@ -107,13 +108,13 @@ def _compute_total_loss(self, state: RelationSolverState, debug: bool = False) -

def solve(
self,
objects: list[Object],
initial_positions: dict[Object, tuple[float, float, float]],
) -> dict[Object, tuple[float, float, float]]:
objects: list[Object | ObjectReference],
initial_positions: dict[Object | ObjectReference, tuple[float, float, float]],
) -> dict[Object | ObjectReference, tuple[float, float, float]]:
"""Solve for optimal positions of all objects.

Args:
objects: List of Object instances. Must include at least one object
objects: List of Object or ObjectReference instances. Must include at least one object
marked with IsAnchor() which serves as a fixed reference.
initial_positions: Starting positions for all objects (including anchors).

Expand Down Expand Up @@ -190,7 +191,7 @@ def last_position_history(self) -> list:
"""Position snapshots from the most recent solve() call."""
return self._last_position_history

def debug_losses(self, objects: list[Object]) -> None:
def debug_losses(self, objects: list[Object | ObjectReference]) -> None:
"""Print detailed loss breakdown for all relations using final positions.

Call this after solve() to inspect why objects may not be correctly positioned.
Expand All @@ -216,7 +217,7 @@ def debug_losses(self, objects: list[Object]) -> None:


def _print_relation_debug(
obj: Object,
obj: Object | ObjectReference,
relation: Relation,
child_pos: torch.Tensor,
parent_pos: torch.Tensor,
Expand Down
2 changes: 1 addition & 1 deletion isaaclab_arena/relations/relation_solver_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def _default_strategies() -> dict[type[RelationBase], RelationLossStrategy | Una
class RelationSolverParams:
"""Configuration parameters for RelationSolver."""

max_iters: int = 1000
max_iters: int = 600
"""Maximum optimization iterations."""

lr: float = 0.01
Expand Down
Loading