diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index d8d279b18..2515a6e30 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -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 ( @@ -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 @@ -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 @@ -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, @@ -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) diff --git a/isaaclab_arena/relations/placement_result.py b/isaaclab_arena/relations/placement_result.py index 6f79b9e80..13cd9b252 100644 --- a/isaaclab_arena/relations/placement_result.py +++ b/isaaclab_arena/relations/placement_result.py @@ -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.""" @@ -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 diff --git a/isaaclab_arena/relations/pooled_object_placer.py b/isaaclab_arena/relations/pooled_object_placer.py index d13211b55..640195743 100644 --- a/isaaclab_arena/relations/pooled_object_placer.py +++ b/isaaclab_arena/relations/pooled_object_placer.py @@ -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: @@ -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: diff --git a/isaaclab_arena/tests/test_heterogeneous_placement.py b/isaaclab_arena/tests/test_heterogeneous_placement.py index 5392b3e9a..49396c40c 100644 --- a/isaaclab_arena/tests/test_heterogeneous_placement.py +++ b/isaaclab_arena/tests/test_heterogeneous_placement.py @@ -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 @@ -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 @@ -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" @@ -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): @@ -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(): @@ -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) # --------------------------------------------------------------------------- diff --git a/isaaclab_arena/tests/test_object_placer_init.py b/isaaclab_arena/tests/test_object_placer_init.py index e26883e1e..3510a03c5 100644 --- a/isaaclab_arena/tests/test_object_placer_init.py +++ b/isaaclab_arena/tests/test_object_placer_init.py @@ -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() diff --git a/isaaclab_arena/tests/test_object_placer_reproducibility.py b/isaaclab_arena/tests/test_object_placer_reproducibility.py index 690018e12..10122fb0f 100644 --- a/isaaclab_arena/tests/test_object_placer_reproducibility.py +++ b/isaaclab_arena/tests/test_object_placer_reproducibility.py @@ -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 @@ -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): @@ -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 @@ -142,7 +142,7 @@ 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() @@ -150,9 +150,8 @@ def test_object_placer_multi_env_returns_multi_env_result(): 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 @@ -161,7 +160,7 @@ 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() @@ -169,9 +168,8 @@ def test_object_placer_multi_env_produces_different_positions(): 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" @@ -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 @@ -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 diff --git a/isaaclab_arena/tests/test_placement_events.py b/isaaclab_arena/tests/test_placement_events.py index 1440d8070..aefa013bc 100644 --- a/isaaclab_arena/tests/test_placement_events.py +++ b/isaaclab_arena/tests/test_placement_events.py @@ -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]): @@ -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 @@ -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" @@ -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 ( diff --git a/isaaclab_arena/tests/test_placement_result.py b/isaaclab_arena/tests/test_placement_result.py new file mode 100644 index 000000000..3b17f8354 --- /dev/null +++ b/isaaclab_arena/tests/test_placement_result.py @@ -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 diff --git a/isaaclab_arena_examples/relations/dummy_object_placer_notebook.py b/isaaclab_arena_examples/relations/dummy_object_placer_notebook.py index d67969157..66826f5a0 100644 --- a/isaaclab_arena_examples/relations/dummy_object_placer_notebook.py +++ b/isaaclab_arena_examples/relations/dummy_object_placer_notebook.py @@ -80,7 +80,7 @@ def run_dummy_object_placer_demo(): # Place objects using ObjectPlacer (anchor is auto-detected via IsAnchor relation) placer = ObjectPlacer(params=ObjectPlacerParams()) - result = placer.place(objects=all_objects) + (result,) = placer.place(objects=all_objects) # Visualization visualizer = RelationSolverVisualizer( @@ -142,7 +142,7 @@ def run_dummy_multi_anchor_demo(): # Place objects (verbose=True shows anchors and optimizable objects) placer = ObjectPlacer(params=ObjectPlacerParams(verbose=True)) - result = placer.place(objects=all_objects) + (result,) = placer.place(objects=all_objects) print("\nFinal positions:") for obj, pos in result.positions.items(): diff --git a/isaaclab_arena_examples/relations/isaac_sim_no_collision_notebook.py b/isaaclab_arena_examples/relations/isaac_sim_no_collision_notebook.py index 2fc6a11a7..74006d7cc 100644 --- a/isaaclab_arena_examples/relations/isaac_sim_no_collision_notebook.py +++ b/isaaclab_arena_examples/relations/isaac_sim_no_collision_notebook.py @@ -129,7 +129,7 @@ def apply_overlapping_pose_then_solve_and_display(): env.unwrapped.sim.render() # Run the solver and apply solved poses to the sim. placer = ObjectPlacer() - result = placer.place(objects=objects_with_relations) + (result,) = placer.place(objects=objects_with_relations) for obj in placeable_objects: if obj.name not in env.unwrapped.scene.rigid_objects: print(