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
13 changes: 5 additions & 8 deletions isaaclab_arena/relations/object_placer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from isaaclab_arena.relations.bounding_box_helpers import assign_variants_for_envs, build_per_env_bounding_boxes
from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams
from isaaclab_arena.relations.placement_result import MultiEnvPlacementResult, PlacementResult
from isaaclab_arena.relations.placement_result import PlacementResult
from isaaclab_arena.relations.placement_validation import PlacementCheck, PlacementValidationResults
from isaaclab_arena.relations.relation_solver import RelationSolver
from isaaclab_arena.relations.relations import (
Expand Down Expand Up @@ -78,7 +78,7 @@ def place(
self,
objects: list[ObjectBase],
num_envs: int = 1,
) -> PlacementResult | MultiEnvPlacementResult:
) -> list[PlacementResult]:
"""Place objects according to their spatial relations.

Every environment is solved against its own per-env bounding boxes and
Expand All @@ -93,8 +93,7 @@ def place(
placement (one layout per env).

Returns:
PlacementResult when num_envs == 1, otherwise a
MultiEnvPlacementResult with one layout per environment.
One PlacementResult per environment.
"""
anchor_objects_set, generator = self._prepare_placement(objects)
max_attempts = self.params.max_placement_attempts
Expand Down Expand Up @@ -123,9 +122,7 @@ def place(
orientations_per_env = [r.orientations for r in results_per_env]
self._apply_poses(positions_per_env, anchor_objects_set, orientations_per_env)

if num_envs == 1:
return results_per_env[0]
return MultiEnvPlacementResult(results=results_per_env)
return results_per_env

def place_ranked_per_env(
self,
Expand Down Expand Up @@ -654,7 +651,7 @@ def _validate_placement(
env_bboxes: Per-object bboxes for the current env, each with shape (1, 3).

Returns:
PlacementValidationResults containing the validation results.
PlacementValidationResults with the overlap and on-relation checks.
"""
no_overlap = self._validate_no_overlap(positions, env_bboxes)
on_relation = self._validate_on_relations(positions, env_bboxes)
Expand Down
26 changes: 3 additions & 23 deletions isaaclab_arena/relations/placement_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

@dataclass
class PlacementResult:
"""Result of an ObjectPlacer.place() call."""
"""Solved object layout for one environment."""

validation_results: PlacementValidationResults
"""Validation checklist for the placement."""
Expand All @@ -35,28 +35,8 @@ class PlacementResult:

@property
def success(self) -> bool:
"""True when this layout passed every validation check.
"""True when all required validation checks pass.

Soft selection: place() always returns the best-ranked layout per env, even when no
candidate validated. Callers check success to distinguish a validated layout from a
lowest-loss fallback; failed_items on the checklist says which checks failed.
place() returns a best-loss fallback even on failure; check this to tell validated from fallback.
"""
return self.validation_results.do_all_required_validation_checks_pass()


@dataclass
class MultiEnvPlacementResult:
"""Result of an ObjectPlacer.place() call for multiple environments."""

results: list[PlacementResult]
"""One PlacementResult per environment (same length as num_envs)."""

@property
def success(self) -> bool:
"""True if every environment's placement succeeded."""
return all(r.success for r in self.results)

@property
def attempts(self) -> int:
"""Number of attempts (same for all envs in the batched run)."""
return self.results[0].attempts if self.results else 0
7 changes: 2 additions & 5 deletions isaaclab_arena/relations/pooled_object_placer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from isaaclab_arena.relations.bounding_box_helpers import has_heterogeneous_objects
from isaaclab_arena.relations.object_placer import ObjectPlacer
from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams
from isaaclab_arena.relations.placement_result import MultiEnvPlacementResult, PlacementResult
from isaaclab_arena.relations.placement_result import PlacementResult
from isaaclab_arena.utils.random import get_rngs

if TYPE_CHECKING:
Expand Down Expand Up @@ -186,10 +186,7 @@ def _solve_reusable_layouts(self, num_layouts: int, allow_fallback: bool = False
"""
self._prepare_seeded_solve(num_layouts * self._placer.params.max_placement_attempts)
with torch.inference_mode(False):
result = self._placer.place(self._objects, num_envs=num_layouts)

# place() returns a single PlacementResult only when num_envs == 1.
all_layouts = result.results if isinstance(result, MultiEnvPlacementResult) else [result]
all_layouts = self._placer.place(self._objects, num_envs=num_layouts)
valid_layouts = [layout for layout in all_layouts if layout.success]

if len(valid_layouts) < num_layouts:
Expand Down
26 changes: 10 additions & 16 deletions isaaclab_arena/tests/test_heterogeneous_placement.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from isaaclab_arena.relations.bounding_box_helpers import build_per_env_bounding_boxes, get_bounding_box_per_env
from isaaclab_arena.relations.object_placer import ObjectPlacer
from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams
from isaaclab_arena.relations.placement_result import MultiEnvPlacementResult, PlacementResult
from isaaclab_arena.relations.placement_result import PlacementResult
from isaaclab_arena.relations.placement_validation import PlacementValidationResults
from isaaclab_arena.relations.pooled_object_placer import PooledObjectPlacer
from isaaclab_arena.relations.relation_solver import RelationSolver
Expand Down Expand Up @@ -252,9 +252,8 @@ def test_object_placer_heterogeneous_produces_per_env_results():
placer = ObjectPlacer(params=params)
result = placer.place(objects, num_envs=num_envs)

assert isinstance(result, MultiEnvPlacementResult)
assert len(result.results) == num_envs
for r in result.results:
assert len(result) == num_envs
for r in result:
assert hetero_box in r.positions


Expand Down Expand Up @@ -283,10 +282,8 @@ def test_object_placer_heterogeneous_z_height_matches_variant():
placer = ObjectPlacer(params=params)
result = placer.place(objects, num_envs=num_envs)

assert isinstance(result, MultiEnvPlacementResult)
# Both envs should have solved z near the desk top + clearance (0.11).
# The On loss targets: z = parent_top + clearance - child_min_z = 0.1 + 0.01 - 0.0 = 0.11
for env_idx, r in enumerate(result.results):
assert len(result) == num_envs
for env_idx, r in enumerate(result):
z = r.positions[hetero][2]
assert abs(z - 0.11) < 0.05, f"Env {env_idx}: z={z:.4f}, expected ~0.11"

Expand Down Expand Up @@ -329,10 +326,9 @@ def test_mixed_heterogeneous_and_homogeneous_placement():
placer = ObjectPlacer(params=params)
result = placer.place(objects, num_envs=num_envs)

assert isinstance(result, MultiEnvPlacementResult)
assert len(result.results) == num_envs
assert len(result) == num_envs

for env_idx, r in enumerate(result.results):
for env_idx, r in enumerate(result):
assert obj_a in r.positions and obj_x in r.positions
# Verify z-height is near desk top + clearance for both objects.
for obj in (obj_a, obj_x):
Expand Down Expand Up @@ -365,8 +361,7 @@ def test_heterogeneous_placement_always_returns_per_env_results():
placer = ObjectPlacer(params=params)
result = placer.place([desk, hetero], num_envs=4)

assert isinstance(result, MultiEnvPlacementResult)
assert len(result.results) == 4
assert len(result) == 4


def test_object_placer_place_ranked_per_env_returns_sorted_env_lists():
Expand Down Expand Up @@ -424,9 +419,8 @@ def test_object_placer_homogeneous_objects_return_multi_env_result():
placer = ObjectPlacer(params=params)
result = placer.place([desk, box], num_envs=2)

assert isinstance(result, MultiEnvPlacementResult)
assert len(result.results) == 2
assert all(r.success for r in result.results)
assert len(result) == 2
assert all(r.success for r in result)


# ---------------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion isaaclab_arena/tests/test_object_placer_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ def _run():
bounding_box=AxisAlignedBoundingBox(min_point=(0.0, 0.0, 0.0), max_point=(0.2, 0.2, 0.2)),
)
box.add_relation(On(desk, clearance_m=0.01))
return ObjectPlacer(params=params).place([desk, box])
(result,) = ObjectPlacer(params=params).place([desk, box])
return result

result1 = _run()
result2 = _run()
Expand Down
28 changes: 13 additions & 15 deletions isaaclab_arena/tests/test_object_placer_reproducibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from isaaclab_arena.assets.dummy_object import DummyObject
from isaaclab_arena.relations.object_placer import ObjectPlacer
from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams
from isaaclab_arena.relations.placement_result import MultiEnvPlacementResult, PlacementResult
from isaaclab_arena.relations.placement_result import PlacementResult
from isaaclab_arena.relations.pooled_object_placer import PooledObjectPlacer
from isaaclab_arena.relations.relation_solver import RelationSolver
from isaaclab_arena.relations.relation_solver_params import RelationSolverParams
Expand Down Expand Up @@ -97,13 +97,13 @@ def test_object_placer_same_seed_produces_identical_result():
desk1, box1_run1, box2_run1 = _create_test_objects()
objects1 = [desk1, box1_run1, box2_run1]
placer1 = ObjectPlacer(params=ObjectPlacerParams(placement_seed=seed, solver_params=solver_params))
result1 = placer1.place(objects=objects1)
(result1,) = placer1.place(objects=objects1)

# Run 2
desk2, box1_run2, box2_run2 = _create_test_objects()
objects2 = [desk2, box1_run2, box2_run2]
placer2 = ObjectPlacer(params=ObjectPlacerParams(placement_seed=seed, solver_params=solver_params))
result2 = placer2.place(objects=objects2)
(result2,) = placer2.place(objects=objects2)

# Compare by name
for obj1, obj2 in zip(objects1, objects2):
Expand All @@ -121,13 +121,13 @@ def test_object_placer_different_seeds_produce_different_results():
desk1, box1_run1, box2_run1 = _create_test_objects()
objects1 = [desk1, box1_run1, box2_run1]
placer1 = ObjectPlacer(params=ObjectPlacerParams(placement_seed=42, solver_params=solver_params))
result1 = placer1.place(objects=objects1)
(result1,) = placer1.place(objects=objects1)

# Run 2 with seed 123
desk2, box1_run2, box2_run2 = _create_test_objects()
objects2 = [desk2, box1_run2, box2_run2]
placer2 = ObjectPlacer(params=ObjectPlacerParams(placement_seed=123, solver_params=solver_params))
result2 = placer2.place(objects=objects2)
(result2,) = placer2.place(objects=objects2)

# Check that at least one non-anchor position differs
any_different = False
Expand All @@ -142,17 +142,16 @@ def test_object_placer_different_seeds_produce_different_results():


def test_object_placer_multi_env_returns_multi_env_result():
"""Test that ObjectPlacer.place with num_envs>1 returns MultiEnvPlacementResult."""
"""place() with num_envs>1 returns one PlacementResult per env."""
num_envs = 4
solver_params = RelationSolverParams(max_iters=200, convergence_threshold=1e-3)
desk, box1, box2 = _create_test_objects()
objects = [desk, box1, box2]
placer = ObjectPlacer(params=ObjectPlacerParams(placement_seed=42, solver_params=solver_params))
result = placer.place(objects, num_envs=num_envs)

assert isinstance(result, MultiEnvPlacementResult)
assert len(result.results) == num_envs
for r in result.results:
assert len(result) == num_envs
for r in result:
assert isinstance(r, PlacementResult)
assert box1 in r.positions
assert box2 in r.positions
Expand All @@ -161,17 +160,16 @@ def test_object_placer_multi_env_returns_multi_env_result():


def test_object_placer_multi_env_produces_different_positions():
"""Test that multi-env placement produces different positions across environments."""
"""Multi-env placement produces different positions across envs."""
num_envs = 4
solver_params = RelationSolverParams(max_iters=200, convergence_threshold=1e-3)
desk, box1, box2 = _create_test_objects()
objects = [desk, box1, box2]
placer = ObjectPlacer(params=ObjectPlacerParams(placement_seed=42, solver_params=solver_params))
result = placer.place(objects, num_envs=num_envs)

assert isinstance(result, MultiEnvPlacementResult)
# At least one pair of envs should have different positions for a non-anchor object.
positions_box1 = [result.results[e].positions[box1] for e in range(num_envs)]
assert len(result) == num_envs
positions_box1 = [result[e].positions[box1] for e in range(num_envs)]
any_different = any(positions_box1[i] != positions_box1[j] for i in range(num_envs) for j in range(i + 1, num_envs))
assert any_different, "Multi-env placement should produce different positions across environments"

Expand Down Expand Up @@ -301,7 +299,7 @@ def test_random_yaw_init_applied_yaw_matches_selected_candidate():
placer = ObjectPlacer(
params=ObjectPlacerParams(placement_seed=11, solver_params=solver_params, random_yaw_init=True)
)
result = placer.place([desk, box1, box2], num_envs=1)
(result,) = placer.place([desk, box1, box2], num_envs=1)
for box in (box1, box2):
applied = _yaw_rad_from_quat(box.get_initial_pose().rotation_xyzw)
assert abs(wrap_angle_to_pi(applied - result.orientations[box])) < 1e-5
Expand All @@ -316,7 +314,7 @@ def test_random_yaw_init_composes_marker_yaw():
placer = ObjectPlacer(
params=ObjectPlacerParams(placement_seed=3, solver_params=solver_params, random_yaw_init=True)
)
result = placer.place([desk, box1, box2], num_envs=1)
(result,) = placer.place([desk, box1, box2], num_envs=1)
applied = _yaw_rad_from_quat(box1.get_initial_pose().rotation_xyzw)
assert abs(wrap_angle_to_pi(applied - (marker_yaw + result.orientations[box1]))) < 1e-5

Expand Down
13 changes: 6 additions & 7 deletions isaaclab_arena/tests/test_placement_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ def test_successive_placements_without_seed_produce_different_layouts():

desk1, box1_a, box2_a = _create_test_objects()
placer_a = ObjectPlacer(params=params)
result_a = placer_a.place([desk1, box1_a, box2_a], num_envs=1)
(result_a,) = placer_a.place([desk1, box1_a, box2_a], num_envs=1)

desk2, box1_b, box2_b = _create_test_objects()
placer_b = ObjectPlacer(params=params)
result_b = placer_b.place([desk2, box1_b, box2_b], num_envs=1)
(result_b,) = placer_b.place([desk2, box1_b, box2_b], num_envs=1)

any_different = False
for obj_a, obj_b in zip([box1_a, box2_a], [box1_b, box2_b]):
Expand All @@ -84,7 +84,6 @@ def test_placement_without_seed_multi_env_gives_different_layouts():

from isaaclab_arena.relations.object_placer import ObjectPlacer
from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams
from isaaclab_arena.relations.placement_result import MultiEnvPlacementResult
from isaaclab_arena.relations.relation_solver_params import RelationSolverParams

num_envs = 4
Expand All @@ -99,8 +98,8 @@ def test_placement_without_seed_multi_env_gives_different_layouts():
placer = ObjectPlacer(params=params)
result = placer.place([desk, box1, box2], num_envs=num_envs)

assert isinstance(result, MultiEnvPlacementResult)
positions_box1 = [result.results[env_idx].positions[box1] for env_idx in range(num_envs)]
assert len(result) == num_envs
positions_box1 = [result[env_idx].positions[box1] for env_idx in range(num_envs)]
any_different = any(positions_box1[i] != positions_box1[j] for i in range(num_envs) for j in range(i + 1, num_envs))
assert any_different, "Unseeded multi-env placement should produce different positions across environments"

Expand All @@ -121,11 +120,11 @@ def test_successive_seeded_placements_produce_same_layout():

desk1, box1_a, box2_a = _create_test_objects()
placer_a = ObjectPlacer(params=params)
result_a = placer_a.place([desk1, box1_a, box2_a], num_envs=1)
(result_a,) = placer_a.place([desk1, box1_a, box2_a], num_envs=1)

desk2, box1_b, box2_b = _create_test_objects()
placer_b = ObjectPlacer(params=params)
result_b = placer_b.place([desk2, box1_b, box2_b], num_envs=1)
(result_b,) = placer_b.place([desk2, box1_b, box2_b], num_envs=1)

for obj_a, obj_b in zip([box1_a, box2_a], [box1_b, box2_b]):
assert (
Expand Down
48 changes: 48 additions & 0 deletions isaaclab_arena/tests/test_placement_result.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Copyright (c) 2025-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

"""Unit tests for PlacementResult."""

from isaaclab_arena.relations.placement_result import PlacementResult
from isaaclab_arena.relations.placement_validation import PlacementCheck, PlacementValidationResults


def _make_result(
*,
required: dict[PlacementCheck, bool] | None = None,
optional: dict[PlacementCheck, bool] | None = None,
final_loss: float = 0.0,
) -> PlacementResult:
required = required or {PlacementCheck.NO_OVERLAP: True}
all_checks = dict(required)
if optional:
all_checks.update(optional)
return PlacementResult(
validation_results=PlacementValidationResults(
validation_results=all_checks,
required_checks=set(required),
),
positions={},
final_loss=final_loss,
attempts=1,
)


def test_success_true_when_all_required_pass():
result = _make_result(required={PlacementCheck.NO_OVERLAP: True, PlacementCheck.ON_RELATION: True})
assert result.success is True


def test_success_false_when_required_fails():
result = _make_result(required={PlacementCheck.NO_OVERLAP: False})
assert result.success is False


def test_success_ignores_failed_optional_check():
result = _make_result(
required={PlacementCheck.NO_OVERLAP: True},
optional={PlacementCheck.PHYSICS_SETTLED: False},
)
assert result.success is True
Loading
Loading