Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2f3b5f7
Refactor NoCollisionLossStrategy to accept clearance_m directly
cvolkcvolk Apr 10, 2026
29ca957
Add clearance_m to RelationSolverParams, remove NoCollision from stra…
cvolkcvolk Apr 10, 2026
20b60fc
Add built-in pairwise no-overlap loss to RelationSolver
cvolkcvolk Apr 10, 2026
908d1e4
Delete NoCollision class from public API
cvolkcvolk Apr 10, 2026
a5956ee
Remove NoCollision auto-injection from ArenaEnvBuilder
cvolkcvolk Apr 10, 2026
ad73c6d
Unify validation to use solver_params.clearance_m
cvolkcvolk Apr 10, 2026
11bb480
Remove NoCollision usage from example notebooks
cvolkcvolk Apr 10, 2026
abadd09
Fix validation to skip anchor-to-non-anchor pairs
cvolkcvolk Apr 10, 2026
20dae96
Add clearance_m validation to RelationSolverParams
cvolkcvolk Apr 10, 2026
5f0ecdc
Fix validation to only skip On-parent anchor pairs
cvolkcvolk Apr 10, 2026
762f937
Update stale docs and comments referencing removed NoCollision
cvolkcvolk Apr 10, 2026
4afa420
Add tests for clearance_m validation and overlap edge cases
cvolkcvolk Apr 10, 2026
2d0fb0c
Check all pairs in validation, use margin=0 for anchor pairs
cvolkcvolk Apr 10, 2026
bb62b35
Simplify validation docstring
cvolkcvolk Apr 10, 2026
511a417
Apply clearance_m uniformly to all pairs in validation
cvolkcvolk Apr 10, 2026
00102fa
Simplify _compute_no_overlap_loss to a single flat loop
cvolkcvolk Apr 10, 2026
bfe2274
Fix remaining stale NoCollision references and docs
cvolkcvolk Apr 10, 2026
620cf73
Document gradient coupling in no-overlap loss loop
cvolkcvolk Apr 10, 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
6 changes: 2 additions & 4 deletions docs/pages/concepts/concept_object_placement.rst
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ and add them to the scene:
The builder automatically:

- Collects all objects with at least one relation from the scene
- Adds pairwise ``NoCollision`` constraints between all non-anchor objects (if not already present)
- Automatically prevents overlap between placed objects (controlled by ``clearance_m`` in ``RelationSolverParams``)
- Creates an ``ObjectPlacer`` and runs placement
- Sets the solved poses on the objects before handing them to Isaac Lab

Expand Down Expand Up @@ -405,15 +405,13 @@ Configuration

placer = ObjectPlacer(params=ObjectPlacerParams(
max_placement_attempts=10,
min_separation_m=0.01,
placement_seed=42,
verbose=True,
))

Key parameters:

- ``max_placement_attempts`` (default ``5``): Number of solver restarts before giving up
- ``min_separation_m`` (default ``0.0``): Extra separation enforced during overlap validation
- ``max_placement_attempts`` (default ``10``): Number of solver restarts before giving up
- ``placement_seed`` (default ``None``): Random seed for reproducible placements
- ``on_relation_z_tolerance_m`` (default ``5e-3``): Tolerance for Z validation of ``On`` relations

Expand Down
19 changes: 2 additions & 17 deletions isaaclab_arena/environments/arena_env_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
from isaaclab_arena.metrics.recorder_manager_utils import metrics_to_recorder_manager_cfg
from isaaclab_arena.relations.object_placer import ObjectPlacer
from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams
from isaaclab_arena.relations.relations import IsAnchor, NoCollision
from isaaclab_arena.tasks.no_task import NoTask
from isaaclab_arena.utils.configclass import combine_configclass_instances
from isaaclab_arena.utils.isaaclab_utils.simulation_app import reapply_viewer_cfg
Expand Down Expand Up @@ -72,25 +71,13 @@ def _get_objects_with_relations(self) -> list[Object | ObjectReference]:
objects_with_relations.append(asset)
return objects_with_relations

def _add_pairwise_no_collision(self, objects_with_relations: list[Object | ObjectReference]) -> None:
"""Add NoCollision between every pair of non-anchor objects (if not already present)."""
non_anchors = [
obj for obj in objects_with_relations if not any(isinstance(r, IsAnchor) for r in obj.get_relations())
]
for i, obj_a in enumerate(non_anchors):
for obj_b in non_anchors[i + 1 :]:
has_no_collision = any(isinstance(r, NoCollision) and r.parent is obj_b for r in obj_a.get_relations())
if not has_no_collision:
obj_a.add_relation(NoCollision(obj_b))
Comment on lines -75 to -84
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea good move to remove this from the EnvBuilder.


def _solve_relations(self) -> None:
"""Solve spatial relations for objects in the scene.

This method:
1. Collects all objects from the scene that have relations
2. Adds NoCollision between every pair of non-anchor objects (if not already present)
3. Runs the ObjectPlacer to solve spatial constraints
4. Applies solved positions to objects
2. Runs the ObjectPlacer to solve spatial constraints
3. Applies solved positions to objects
"""
# All objects with relations are subjects of the relation solving.
objects_with_relations = self._get_objects_with_relations()
Expand All @@ -99,8 +86,6 @@ def _solve_relations(self) -> None:
print("No objects with relations found in scene. Skipping relation solving.")
return

self._add_pairwise_no_collision(objects_with_relations)

# Run the ObjectPlacer (default on_relation_z_tolerance_m accommodates solver residual).
placement_seed = getattr(self.args, "placement_seed", None)
placer = ObjectPlacer(params=ObjectPlacerParams(placement_seed=placement_seed))
Expand Down
17 changes: 15 additions & 2 deletions isaaclab_arena/relations/object_placer.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,16 +298,29 @@ def _validate_no_overlap(
self,
positions: dict[Object | ObjectReference, tuple[float, float, float]],
) -> bool:
"""Check that no two objects overlap in 3D (axis-aligned bbox with margin)."""
"""Check that no two objects overlap in 3D (axis-aligned bbox with clearance margin).

All pairs must be separated by at least ``clearance_m``.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A minor question: Do we plan to have the same clearance_m for all objects? Will we expect that different object sizes sometimes benefit from different clearance values in the future?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's a good point, we might want to add this in the future. For now I think we should go with simplicity.

Anchor-to-anchor pairs are skipped (both fixed, user's responsibility).
"""
anchor_objects = get_anchor_objects(list(positions.keys()))
anchor_set = set(anchor_objects)
objects = list(positions.keys())
# Small tolerance to avoid false positives at floating-point boundaries
# (e.g., On relation places child exactly at clearance_m above parent).
margin = max(0.0, self.params.solver_params.clearance_m - 1e-6)

for i in range(len(objects)):
for j in range(i + 1, len(objects)):
a, b = objects[i], objects[j]

if a in anchor_set and b in anchor_set:
continue

a_world = a.get_bounding_box().translated(positions[a])
b_world = b.get_bounding_box().translated(positions[b])

if a_world.overlaps(b_world, margin=self.params.min_separation_m):
if a_world.overlaps(b_world, margin=margin):
if self.params.verbose:
print(f" Overlap between '{a.name}' and '{b.name}'")
return False
Expand Down
5 changes: 0 additions & 5 deletions isaaclab_arena/relations/object_placer_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,6 @@ class ObjectPlacerParams:
placement_seed: int | None = None
"""Random seed for reproducible placement. If None, uses current RNG state."""

min_separation_m: float = 0.0
"""Minimum separation (meters) required between object bounding boxes.
Set to 0.0 to only reject actual overlaps. A small positive value (e.g. 0.005)
adds a safety margin between objects."""

on_relation_z_tolerance_m: float = 5e-3
"""Tolerance (meters) for On-relation Z validation. Valid Z band is extended to
(parent_top - tolerance, parent_top + clearance_m + tolerance]. Default 5e-3 accommodates solver residual."""
20 changes: 11 additions & 9 deletions isaaclab_arena/relations/relation_loss_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox

if TYPE_CHECKING:
from isaaclab_arena.relations.relations import AtPosition, NextTo, On, Relation, NoCollision
from isaaclab_arena.relations.relations import AtPosition, NextTo, On, Relation

from isaaclab_arena.relations.relations import Side

Expand Down Expand Up @@ -306,8 +306,10 @@ def compute_loss(
return relation.relation_loss_weight * total_loss


class NoCollisionLossStrategy(RelationLossStrategy):
"""Loss strategy for NoCollision relations.
class NoCollisionLossStrategy:
"""Computes no-overlap loss between two bounding boxes.

Internal utility used by RelationSolver. Not part of the public API.

Computes loss based on:
1. X overlap: zero when child and parent are separated along X; else overlap length
Expand All @@ -328,24 +330,24 @@ def __init__(self, slope: float = 10.0, debug: bool = False):

def compute_loss(
self,
relation: "NoCollision",
clearance_m: float,
child_pos: torch.Tensor,
child_bbox: AxisAlignedBoundingBox,
parent_world_bbox: AxisAlignedBoundingBox,
) -> torch.Tensor:
"""Compute loss for NoCollision relation.
"""Compute no-overlap loss between child and parent bounding boxes.

Args:
relation: NoCollision relation with relation_loss_weight.
clearance_m: Minimum required gap between child and parent bounding boxes.
child_pos: Child object position tensor (x, y, z) in world coords.
child_bbox: Child object local bounding box.
parent_world_bbox: Parent bounding box in world coordinates.

Returns:
Weighted loss tensor.
Loss tensor (scalar); positive when boxes overlap within clearance, zero otherwise.
"""
# Parent world extents from the world bounding box, expanded by clearance_m
c = relation.clearance_m
c = clearance_m
parent_x_min = parent_world_bbox.min_point[0] - c
parent_x_max = parent_world_bbox.max_point[0] + c
parent_y_min = parent_world_bbox.min_point[1] - c
Expand Down Expand Up @@ -381,7 +383,7 @@ def compute_loss(
)
print(f" [NoCollision] volume={overlap_volume.item():.6f}, loss={total_loss.item():.6f}")

return relation.relation_loss_weight * total_loss
return total_loss


class AtPositionLossStrategy(UnaryRelationLossStrategy):
Expand Down
61 changes: 60 additions & 1 deletion isaaclab_arena/relations/relation_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
import torch
from typing import TYPE_CHECKING

from isaaclab_arena.relations.relation_loss_strategies import RelationLossStrategy, UnaryRelationLossStrategy
from isaaclab_arena.relations.relation_loss_strategies import (
NoCollisionLossStrategy,
RelationLossStrategy,
UnaryRelationLossStrategy,
)
from isaaclab_arena.relations.relation_solver_params import RelationSolverParams
from isaaclab_arena.relations.relation_solver_state import RelationSolverState
from isaaclab_arena.relations.relations import AtPosition, Relation, RelationBase
Expand Down Expand Up @@ -37,6 +41,7 @@ def __init__(
params: Solver configuration parameters. If None, uses defaults.
"""
self.params = params or RelationSolverParams()
self._no_collision_strategy = NoCollisionLossStrategy(slope=10000.0)
self._last_loss_history: list[float] = []
self._last_position_history: list = []

Expand All @@ -60,6 +65,57 @@ def _get_strategy(self, relation: RelationBase) -> RelationLossStrategy | UnaryR
)
return strategy

def _compute_no_overlap_loss(self, state: RelationSolverState, debug: bool = False) -> torch.Tensor:
"""Compute pairwise no-overlap loss for all object pairs.

For each non-anchor object, computes overlap loss against every other object.
Anchor-to-anchor pairs are skipped (neither moves). Gradient flows to the
non-anchor object in each pair.

Args:
state: Current optimization state with object positions.
debug: If True, print detailed loss breakdown.

Returns:
Total no-overlap loss tensor.
"""
total = torch.tensor(0.0)
clearance = self.params.clearance_m
all_objects = state.optimizable_objects + list(state.anchor_objects)

# Each non-anchor appears as "child" (receives gradient). For two non-anchors A and B,
# both (A-as-child, B-as-other) and (B-as-child, A-as-other) are computed so both get
# gradient. The .item() calls below detach the "other" position from the graph -- this
# is intentional. Do not switch to single-pair (i < j) iteration without also making
# the loss differentiable w.r.t. both positions.
for child in state.optimizable_objects:
child_pos = state.get_position(child)
child_bbox = child.get_bounding_box()

for other in all_objects:
if other is child:
continue

if other in state.anchor_objects:
other_world_bbox = other.get_world_bounding_box()
else:
other_pos = state.get_position(other)
other_world_bbox = other.get_bounding_box().translated(
(other_pos[0].item(), other_pos[1].item(), other_pos[2].item())
)

loss = self._no_collision_strategy.compute_loss(
clearance_m=clearance,
child_pos=child_pos,
child_bbox=child_bbox,
parent_world_bbox=other_world_bbox,
)
if debug and loss.item() > 0:
print(f" NoOverlap({child.name}, {other.name}): {loss.item():.6f}")
total = total + loss

return total

def _compute_total_loss(self, state: RelationSolverState, debug: bool = False) -> torch.Tensor:
"""Compute total loss from all relations using registered strategies.

Expand Down Expand Up @@ -113,6 +169,9 @@ def _compute_total_loss(self, state: RelationSolverState, debug: bool = False) -

total_loss = total_loss + loss

# Built-in pairwise no-overlap loss
total_loss = total_loss + self._compute_no_overlap_loss(state, debug=debug)

return total_loss

def solve(
Expand Down
12 changes: 9 additions & 3 deletions isaaclab_arena/relations/relation_solver_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,18 @@
from isaaclab_arena.relations.relation_loss_strategies import (
AtPositionLossStrategy,
NextToLossStrategy,
NoCollisionLossStrategy,
OnLossStrategy,
RelationLossStrategy,
UnaryRelationLossStrategy,
)
from isaaclab_arena.relations.relations import AtPosition, NextTo, NoCollision, On, RelationBase
from isaaclab_arena.relations.relations import AtPosition, NextTo, On, RelationBase


def _default_strategies() -> dict[type[RelationBase], RelationLossStrategy | UnaryRelationLossStrategy]:
"""Factory for default loss strategies."""
return {
NextTo: NextToLossStrategy(slope=10.0),
On: OnLossStrategy(slope=100.0),
NoCollision: NoCollisionLossStrategy(slope=10000.0),
AtPosition: AtPositionLossStrategy(slope=100.0),
}

Expand All @@ -45,8 +43,16 @@ class RelationSolverParams:
save_position_history: bool = True
"""Save position snapshots during optimization for visualization/debugging. Disable to reduce memory."""

clearance_m: float = 0.01
"""Minimum clearance (meters) between all object bounding boxes. The solver adds
no-overlap loss for every non-anchor pair and every non-anchor-to-anchor pair.
Set to 0.0 to only prevent actual overlaps (no safety margin)."""

# default_factory ensures each instance gets its own dict (mutable defaults are shared across instances)
strategies: dict[type[RelationBase], RelationLossStrategy | UnaryRelationLossStrategy] = field(
default_factory=_default_strategies
)
"""Loss strategies for each relation type. Override to customize loss computation."""

def __post_init__(self):
assert self.clearance_m >= 0.0, f"clearance_m must be non-negative, got {self.clearance_m}"
27 changes: 0 additions & 27 deletions isaaclab_arena/relations/relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,33 +117,6 @@ def __init__(
self.clearance_m = clearance_m


class NoCollision(Relation):
"""Represents a 'no collision' relationship between two objects.

This relation specifies that the child and parent bounding boxes must not
overlap. Adding NoCollision on one side is enough; the solver counts each
unordered pair once.

Note: Loss computation is handled by NoCollisionLossStrategy in relation_loss_strategies.py.
"""

def __init__(
self,
parent: Object | ObjectReference,
relation_loss_weight: float = 1.0,
clearance_m: float = 0.01,
):
"""
Args:
parent: The other object that this object must not collide with.
relation_loss_weight: Weight for the relationship loss function.
clearance_m: Minimum clearance between bounding boxes in meters (default: 1cm).
"""
super().__init__(parent, relation_loss_weight)
assert clearance_m >= 0.0, f"clearance_m must be non-negative, got {clearance_m}"
self.clearance_m = clearance_m


class IsAnchor(RelationBase):
"""Marker indicating this object is an anchor for relation solving.

Expand Down
Loading
Loading