Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 13 additions & 5 deletions isaaclab_arena/assets/object_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ def __init__(self, parent_asset: Object, **kwargs):
super().__init__(**kwargs)
self.parent_asset = parent_asset
self._parent_scale = parent_asset.scale
# 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)
# Resolve the path and pose together to avoid opening the parent USD stage multiple times.
(
self._prim_path_in_parent_usd,
self.initial_pose_relative_to_parent,
) = self._get_referenced_prim_path_and_pose_relative_to_parent(parent_asset)
self.object_cfg = self._init_object_cfg()
self._bounding_box: AxisAlignedBoundingBox | None = None
self._collision_mesh: trimesh.Trimesh | None = None
Expand All @@ -48,6 +51,11 @@ def get_initial_pose(self) -> Pose:
T_W_O = T_W_P.multiply(T_P_O)
return T_W_O

@property
def prim_path_in_parent_usd(self) -> str:
"""Return the referenced prim's absolute path in its parent USD stage."""
return self._prim_path_in_parent_usd

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

Expand Down Expand Up @@ -169,8 +177,8 @@ def _generate_base_cfg(self) -> AssetBaseCfg:
)
return object_cfg

def _get_referenced_prim_pose_relative_to_parent(self, parent_asset: Object) -> Pose:
"""Get the prim's transform pose relative to the parent's default prim.
def _get_referenced_prim_path_and_pose_relative_to_parent(self, parent_asset: Object) -> tuple[str, Pose]:
"""Get the prim path and transform pose relative to the parent's default prim.

The position is scaled by the parent's scale factor.
"""
Expand All @@ -186,7 +194,7 @@ def _get_referenced_prim_pose_relative_to_parent(self, parent_asset: Object) ->
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_xyzw=prim_pose.rotation_xyzw)
return prim_path_in_usd, Pose(position_xyz=scaled_pos, rotation_xyzw=prim_pose.rotation_xyzw)

@staticmethod
def isaaclab_prim_path_to_original_prim_path(
Expand Down
20 changes: 9 additions & 11 deletions isaaclab_arena/environments/relation_solver_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,6 @@
from isaaclab_arena.relations.placement_result import PlacementResult


def _get_passive_collision_objects(
assets: Iterable[Asset | RigidObjectSet],
include_background: bool = False,
) -> list[CollisionObject]:
"""Load passive collision discovery only when relation placement needs it."""
from isaaclab_arena.relations.passive_collision_objects import get_passive_collision_objects

return get_passive_collision_objects(assets, include_background=include_background)


def solve_and_apply_relation_placement(
assets: list[PlaceableAsset],
num_envs: int,
Expand Down Expand Up @@ -77,12 +67,20 @@ def solve_and_apply_relation_placement(
# mutating the caller.
placer_params.reachability_config = copy.copy(placer_params.reachability_config)
if collision_objects is None and scene_assets is not None:
# Lazy import to avoid pxr import before SimulationApp is ready.
from isaaclab_arena.assets.object_reference import ObjectReference
from isaaclab_arena.relations.passive_collision_objects import get_passive_collision_objects

scene_assets = list(scene_assets)
collision_objects = _get_passive_collision_objects(
background_mesh_exclusions = [
asset for asset in get_anchor_objects(assets) if isinstance(asset, ObjectReference)
]
collision_objects = get_passive_collision_objects(
scene_assets,
include_background=_should_include_background_mesh(
assets, scene_assets, placer_params.solver_params.collision_mode
),
background_mesh_exclusions=background_mesh_exclusions,
)
placement_pool = PooledObjectPlacer(
objects=assets,
Expand Down
36 changes: 29 additions & 7 deletions isaaclab_arena/relations/background_collision_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from __future__ import annotations

import trimesh
from collections.abc import Sequence
from collections.abc import Collection, Mapping, Sequence
from typing import TYPE_CHECKING

from isaaclab_arena.relations.collision_mode import CollisionMode
Expand Down Expand Up @@ -67,15 +67,23 @@ def get_collision_mesh(self) -> trimesh.Trimesh:
return self._mesh


def make_fixed_collision_objects(objects: Sequence[CollisionObject]) -> list[CollisionObject]:
def make_fixed_collision_objects(
objects: Sequence[CollisionObject],
excluded_prim_paths_by_object: Mapping[CollisionObject, Collection[str]] | None = None,
) -> list[CollisionObject]:
"""Combine the objects' collision meshes into one FixedCollisionObject.

Objects in BBOX mode or without an extractable mesh are returned unchanged;
a whole-scene Background that cannot aggregate is an error.
Objects in BBOX mode or without an extractable mesh are returned unchanged.
Background mesh extraction failures are errors.

Args:
objects: Fixed collision objects to aggregate.
excluded_prim_paths_by_object: USD prim subtrees omitted from individual objects'
extracted meshes, keyed by source object.
"""
from isaaclab_arena.assets.background import Background

mesh, skipped_objects = _combine_fixed_meshes(objects)
mesh, skipped_objects = _combine_fixed_meshes(objects, excluded_prim_paths_by_object)
collision_objects: list[CollisionObject] = []
if mesh is not None:
collision_objects.append(FixedCollisionObject(mesh))
Expand Down Expand Up @@ -104,18 +112,32 @@ def make_fixed_collision_objects(objects: Sequence[CollisionObject]) -> list[Col
return collision_objects


def _combine_fixed_meshes(objects: Sequence[CollisionObject]) -> tuple[trimesh.Trimesh | None, list[CollisionObject]]:
def _combine_fixed_meshes(
objects: Sequence[CollisionObject],
excluded_prim_paths_by_object: Mapping[CollisionObject, Collection[str]] | None = None,
) -> tuple[trimesh.Trimesh | None, list[CollisionObject]]:
from isaaclab_arena.assets.background import Background
from isaaclab_arena.relations.warp_mesh_manager import WarpMeshAndSphereCache

manager = WarpMeshAndSphereCache(device="cpu")
excluded_prim_paths_by_object = excluded_prim_paths_by_object or {}
meshes = []
skipped_objects = []
for obj in objects:
if obj.collision_mode == CollisionMode.BBOX:
skipped_objects.append(obj)
continue
mesh = manager.get_collision_mesh(obj)
excluded_prim_paths = excluded_prim_paths_by_object.get(obj, ())
if isinstance(obj, Background):
try:
mesh = manager.get_collision_mesh_or_raise(obj, excluded_prim_paths=excluded_prim_paths)
except (OSError, ValueError):
skipped_objects.append(obj)
continue
if mesh is None:
continue
else:
mesh = manager.get_collision_mesh(obj, excluded_prim_paths=excluded_prim_paths)
if mesh is None:
skipped_objects.append(obj)
continue
Expand Down
18 changes: 15 additions & 3 deletions isaaclab_arena/relations/passive_collision_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

from collections import defaultdict
from collections.abc import Iterable
from typing import TYPE_CHECKING

Expand All @@ -23,7 +24,9 @@


def get_passive_collision_objects(
assets: Iterable[Asset | RigidObjectSet], include_background: bool = False
assets: Iterable[Asset | RigidObjectSet],
include_background: bool = False,
background_mesh_exclusions: Iterable[ObjectReference] = (),
) -> list[CollisionObject]:
"""Return relation-free scene assets that qualify as passive collision obstacles.

Expand All @@ -34,6 +37,8 @@ def get_passive_collision_objects(
assets: Scene assets to scan for relation-free fixed objects.
include_background: If True, include Background assets and aggregate all
mesh-capable objects into a single FixedCollisionObject.
background_mesh_exclusions: Object references whose USD subtrees are omitted
from an aggregated parent Background mesh.
"""
collision_objects: list[CollisionObject] = []
for asset in assets:
Expand Down Expand Up @@ -76,5 +81,12 @@ def get_passive_collision_objects(
]

if include_background:
return make_fixed_collision_objects(collision_objects)
return list(collision_objects)
excluded_prim_paths_by_object: defaultdict[CollisionObject, set[str]] = defaultdict(set)
for reference in background_mesh_exclusions:
if reference.parent_asset in collision_object_set:
excluded_prim_paths_by_object[reference.parent_asset].add(reference.prim_path_in_parent_usd)
return make_fixed_collision_objects(
collision_objects,
excluded_prim_paths_by_object=excluded_prim_paths_by_object,
)
return collision_objects
51 changes: 40 additions & 11 deletions isaaclab_arena/relations/warp_mesh_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import torch
import trimesh
from collections import defaultdict
from collections.abc import Collection
from heapq import heappop, heappush
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -123,7 +124,7 @@ def __init__(
self._device = device
self._warp_mesh_cache: dict[tuple, wp.Mesh] = {}
self._sphere_cache: dict[tuple, torch.Tensor] = {}
self._trimesh_cache: dict[tuple, trimesh.Trimesh | None] = {}
self._trimesh_cache: dict[tuple, trimesh.Trimesh | ValueError | None] = {}
self._sentinel_warned: bool = False
self._raw_open_mesh_warned: set[tuple] = set()

Expand All @@ -143,29 +144,57 @@ def warn_sdf_sentinel(self, sdf_values: torch.Tensor) -> None:
"(no mesh face found). Collision detection may be incomplete for these points."
)

def get_collision_mesh(self, obj: CollisionObject) -> trimesh.Trimesh | None:
"""Return the cached collision mesh, extracting from USD on first access."""
def get_collision_mesh(
self,
obj: CollisionObject,
excluded_prim_paths: Collection[str] = (),
) -> trimesh.Trimesh | None:
"""Return the collision mesh, or ``None`` when USD extraction fails."""
from isaaclab_arena.assets.object import Object

if not isinstance(obj, Object) or obj.usd_path is None:
assert not excluded_prim_paths, "USD prim exclusions require an Object with a usd_path."
return obj.get_collision_mesh()

try:
return self.get_collision_mesh_or_raise(obj, excluded_prim_paths)
except (OSError, ValueError):
return None

def get_collision_mesh_or_raise(
self,
obj: CollisionObject,
excluded_prim_paths: Collection[str] = (),
) -> trimesh.Trimesh | None:
"""Return the collision mesh while preserving USD extraction errors."""
from isaaclab_arena.assets.object import Object

if not isinstance(obj, Object) or obj.usd_path is None:
assert not excluded_prim_paths, "USD prim exclusions require an Object with a usd_path."
return obj.get_collision_mesh()
usd_path = obj.usd_path
scale = tuple(obj.scale)
key = (usd_path, scale)

exclusions = tuple(sorted(excluded_prim_paths))
key = (obj.usd_path, tuple(obj.scale), exclusions)
if key not in self._trimesh_cache:
from isaaclab_arena.utils.usd_helpers import extract_trimesh_from_usd # deferred: pxr import

try:
self._trimesh_cache[key] = extract_trimesh_from_usd(usd_path, scale)
self._trimesh_cache[key] = extract_trimesh_from_usd(
obj.usd_path,
obj.scale,
excluded_prim_paths=exclusions,
)
except ValueError as e:
# Permanent: bad USD content, cache None to avoid re-parsing.
print(f" [WarpMeshAndSphereCache] Could not extract mesh for '{obj.name}': {e}")
self._trimesh_cache[key] = None
self._trimesh_cache[key] = e
except OSError as e:
# Transient: file I/O failure, don't cache so next call retries.
print(f" [WarpMeshAndSphereCache] Could not extract mesh for '{obj.name}': {e}")
return None
return self._trimesh_cache[key]
raise
result = self._trimesh_cache[key]
if isinstance(result, ValueError):
raise result
return result

@property
def device(self) -> str:
Expand Down
42 changes: 42 additions & 0 deletions isaaclab_arena/tests/test_reference_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,48 @@ def test_object_reference_world_bbox_applies_parent_yaw():
assert torch.allclose(world_bbox.max_point, torch.tensor([[8.0, 1.2, 0.05]]), atol=1e-6)


def test_object_reference_caches_parent_usd_prim_path(monkeypatch):
"""Resolving the initial pose also caches the parent-USD path for later use."""
from isaaclab_arena.assets.object_reference import ObjectReference

calls = {"open_count": 0}
obj_ref = ObjectReference.__new__(ObjectReference)
obj_ref.prim_path = "{ENV_REGEX_NS}/kitchen/counter"
obj_ref._parent_scale = (1.0, 1.0, 1.0)
parent = SimpleNamespace(usd_path="/tmp/kitchen.usd", name="kitchen")

class OpenStage:
def __init__(self, path):
assert path == parent.usd_path

def __enter__(self):
calls["open_count"] += 1
return SimpleNamespace(GetPrimAtPath=lambda path: object())

def __exit__(self, exc_type, exc, tb):
return False

monkeypatch.setattr("isaaclab_arena.assets.object_reference.open_stage", OpenStage)
monkeypatch.setattr(
ObjectReference,
"isaaclab_prim_path_to_original_prim_path",
staticmethod(lambda prim_path, parent_asset, stage: "/World/counter"),
)
monkeypatch.setattr(
"isaaclab_arena.assets.object_reference.get_prim_pose_in_default_prim_frame",
lambda prim, stage: Pose(),
)

(
obj_ref._prim_path_in_parent_usd,
pose,
) = obj_ref._get_referenced_prim_path_and_pose_relative_to_parent(parent)

assert obj_ref.prim_path_in_parent_usd == "/World/counter"
assert pose == Pose()
assert calls["open_count"] == 1


def test_object_reference_get_collision_mesh_extracts_referenced_prim(monkeypatch):
"""ObjectReference collision meshes are extracted from the referenced sub-prim."""
import trimesh
Expand Down
Loading
Loading