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
11 changes: 11 additions & 0 deletions isaaclab_arena/agentic_environment_generation/default_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Copyright (c) 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

# =============================================================================
# Intent compiler default parameters
# =============================================================================

# The ID of the initial state spec in the ArenaEnvInitialGraphSpec.
INITIAL_STATE_SPEC_ID = "state_initial"
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
IntentResolutionTraceEvent,
match_asset,
)
from isaaclab_arena.agentic_environment_generation.default_params import INITIAL_STATE_SPEC_ID
from isaaclab_arena.agentic_environment_generation.environment_intent_spec import EnvironmentIntentSpec, Item
from isaaclab_arena.assets.registries import AssetRegistry
from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec
Expand All @@ -22,8 +23,6 @@
TaskSpec,
)

_INITIAL_STATE_SPEC_ID = "state_initial"


class IntentCompiler:
"""Compiles an agent intent spec into a validated :class:`ArenaEnvInitialGraphSpec`."""
Expand Down Expand Up @@ -53,7 +52,11 @@ def has_resolution_errors(self) -> bool:
"""``True`` if the last :meth:`compile` call produced any error-stage trace events."""
return bool(self.resolution_errors)

def compile(self, spec: EnvironmentIntentSpec, env_name: str | None = None) -> ArenaEnvInitialGraphSpec:
def compile(
self,
spec: EnvironmentIntentSpec,
env_name: str | None = None,
) -> ArenaEnvInitialGraphSpec:
"""Compile an :class:`EnvironmentIntentSpec` into an :class:`ArenaEnvInitialGraphSpec`.

Args:
Expand Down Expand Up @@ -150,7 +153,7 @@ def _build_initial_state_spec(
if constraint is not None:
constraints.append(constraint)
return ArenaEnvGraphStateSpec(
id=_INITIAL_STATE_SPEC_ID,
id=INITIAL_STATE_SPEC_ID,
is_delta=False,
spatial_constraints=constraints,
task_constraints=[],
Expand All @@ -173,7 +176,7 @@ def _build_spatial_constraint(
return None

reference_part = f"_{rel.reference}" if rel.reference is not None else ""
constraint_id = f"{_INITIAL_STATE_SPEC_ID}_{index}_{rel.kind}{reference_part}_{rel.subject}"
constraint_id = f"{INITIAL_STATE_SPEC_ID}_{index}_{rel.kind}{reference_part}_{rel.subject}"
self.trace.append(IntentResolutionTraceEvent(f"{stage_prefix}.ok", rel.subject, rel.reference, note=rel.kind))
return ArenaEnvGraphSpatialRelationSpec(
id=constraint_id,
Expand Down
36 changes: 29 additions & 7 deletions isaaclab_arena/relations/object_placer.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,8 @@ def _validate_on_relations(
) -> bool:
"""Validate each On relation; keep in sync with OnLossStrategy in relation_loss_strategies.py.

1. X: child's footprint entirely within parent's X extent.
2. Y: child's footprint entirely within parent's Y extent.
1. X: child's footprint within parent's X extent, inset by the relation's edge_margin_m.
2. Y: child's footprint within parent's Y extent, inset by the relation's edge_margin_m.
3. Z: child_bottom in (parent_top, parent_top+clearance_m], within on_relation_z_tolerance_m.

Args:
Expand All @@ -547,15 +547,37 @@ def _validate_on_relations(
parent_bbox = env_bboxes[parent]
child_world = child_bbox.translated(positions[obj])
parent_world = parent_bbox.translated(positions[parent])
parent_size = parent_world.max_point - parent_world.min_point
child_size = child_world.max_point - child_world.min_point

m = rel.edge_margin_m
# 1) Checking that with the specified margin, the parent is wide enough to place the child on top
if m > 0.0:
freespace = parent_size - child_size
# A margin too large for the surface inverts the inset band so containment can never pass.
if torch.any(freespace[0, :2] < 2 * m):
# The maximum feasible margin is the minimum of the freespace on the xy axes.
max_feasible_margin = max(0.0, min(freespace[0, :2]) / 2.0)
# When parent < child, freespace[0, :2] is negative and max_feasible_margin is 0.0.
if max_feasible_margin > 0.0:
if self.params.verbose:
print(
f"On relation: edge_margin_m={m} m is too large for parent '{parent.name}'. Max"
f" feasible margin here is {max_feasible_margin:.3f} m. Use a smaller"
" edge_margin_m."
)
return False
# 2) Checking that the child lies within the parent's xy
if (
child_world.min_point[0, 0] < parent_world.min_point[0, 0]
or child_world.max_point[0, 0] > parent_world.max_point[0, 0]
or child_world.min_point[0, 1] < parent_world.min_point[0, 1]
or child_world.max_point[0, 1] > parent_world.max_point[0, 1]
child_world.min_point[0, 0] < parent_world.min_point[0, 0] + m
or child_world.max_point[0, 0] > parent_world.max_point[0, 0] - m
or child_world.min_point[0, 1] < parent_world.min_point[0, 1] + m
or child_world.max_point[0, 1] > parent_world.max_point[0, 1] - m
Comment thread
xyao-nv marked this conversation as resolved.
):
if self.params.verbose:
print(f" On relation: '{obj.name}' XY outside parent (retrying)")
print(f"On relation: '{obj.name}' XY outside parent (retrying)")
return False
# 3) Checking that the child lies within an acceptable z-range.
parent_local_top_z: float = parent_bbox.max_point[0, 2].item()
child_local_bottom_z: float = child_bbox.min_point[0, 2].item()
parent_top_z = parent_local_top_z + positions[parent][2]
Expand Down
15 changes: 10 additions & 5 deletions isaaclab_arena/relations/relation_loss_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,16 @@ def compute_loss(
parent_y_max = parent_world_bbox.max_point[:, 1]
parent_z_max = parent_world_bbox.max_point[:, 2] # Top surface

# Compute valid position ranges such that child's entire footprint is within parent
valid_x_min = parent_x_min - child_bbox.min_point[:, 0] # child's left at parent's left
valid_x_max = parent_x_max - child_bbox.max_point[:, 0] # child's right at parent's right
valid_y_min = parent_y_min - child_bbox.min_point[:, 1]
valid_y_max = parent_y_max - child_bbox.max_point[:, 1]
# Compute valid position ranges such that child's entire footprint is within parent,
# with the parent's extent inset by edge_margin_m so the footprint stays off the rim.
m = relation.edge_margin_m
Comment thread
xyao-nv marked this conversation as resolved.
valid_x_min = parent_x_min + m - child_bbox.min_point[:, 0] # child's left at parent's left + margin
valid_x_max = parent_x_max - m - child_bbox.max_point[:, 0] # child's right at parent's right - margin
valid_y_min = parent_y_min + m - child_bbox.min_point[:, 1]
valid_y_max = parent_y_max - m - child_bbox.max_point[:, 1]

# The bounds invert (lower > upper) when the margin is too large for the surface or the
# child is oversized. The loss becomes a non-zero constant with gradient zero.

# 1. X band loss: child's footprint entirely within parent's X extent
x_band_loss = linear_band_loss(
Expand Down
17 changes: 15 additions & 2 deletions isaaclab_arena/relations/relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
if TYPE_CHECKING:
from isaaclab_arena.assets.object_base import ObjectBase

# Default inward inset (meters) from each X/Y edge of an ``On`` support surface, keeping
# the placed object's footprint off the rim.
DEFAULT_ON_EDGE_MARGIN_M = 0.05


class Side(Enum):
"""Axis direction for spatial relationships."""
Expand Down Expand Up @@ -116,8 +120,9 @@ class On(Relation):
"""Represents an 'on top of' relationship between objects.

This relation specifies that a child object should be placed on top of
the parent object, with X/Y bounded within the parent's extent and Z
positioned on the parent's top surface.
the parent object, with X/Y bounded within the parent's extent (optionally
inset by ``edge_margin_m`` so the child stays off the rim) and Z positioned
on the parent's top surface.

Note: Loss computation is handled by OnLossStrategy in relation_loss_strategies.py.
"""
Expand All @@ -129,16 +134,24 @@ def __init__(
parent: ObjectBase,
relation_loss_weight: float = 1.0,
clearance_m: float = 0.01,
edge_margin_m: float = DEFAULT_ON_EDGE_MARGIN_M,
):
"""
Args:
parent: The parent asset that this object should be placed on top of.
relation_loss_weight: Weight for the relationship loss function.
clearance_m: Safety clearance above parent's surface in meters (default: 1cm).
edge_margin_m: Inward inset from each X/Y edge of the parent's surface in
meters (default: 5cm). The child's whole footprint is kept at least this
far from the rim. The solver rejects a margin too large for the surface
to honor (``2 * edge_margin_m`` wider than ``parent_extent - child_extent``
on either axis).
"""
super().__init__(parent, relation_loss_weight)
assert clearance_m >= 0.0, f"Clearance must be non-negative, got {clearance_m}"
assert edge_margin_m >= 0.0, f"edge_margin_m must be non-negative, got {edge_margin_m}"
self.clearance_m = clearance_m
self.edge_margin_m = edge_margin_m


@register_object_relation
Expand Down
2 changes: 2 additions & 0 deletions isaaclab_arena/tests/test_intent_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ def test_spatial_constraint_binary_relation_id_and_fields():
assert constraint.reference == "maple_table"
assert constraint.subject == "cracker_box"
assert constraint.id == "state_initial_0_on_maple_table_cracker_box"
# The compiler does not special-case ``on``; the edge margin is the On relation's own default.
assert constraint.params == {}


def test_spatial_constraint_unary_relation_id_and_fields():
Expand Down
59 changes: 59 additions & 0 deletions isaaclab_arena/tests/test_relation_loss_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,65 @@ def test_on_loss_strategy_constrains_entire_footprint():
assert loss > 0.0, "Loss should penalize child footprint extending beyond parent"


def test_on_loss_strategy_edge_margin_insets_band_by_margin():
"""The edge margin shifts the valid X band inward by exactly the margin."""

table = _create_table() # X extent [0, 1]
box = _create_box() # 0.2m wide, local min at 0
strategy = OnLossStrategy(slope=10.0)
# Without a margin the rim-aligned upper bound for the origin is 1 - 0.2 = 0.8, so x=0.8 -> loss 0.
# A 0.05 margin moves that bound to 0.75, leaving x=0.8 exactly 0.05 over -> loss = slope * 0.05 = 0.5.
child_pos = torch.tensor([0.8, 0.4, 0.11])

loss_no_margin = strategy.compute_loss(
On(table, clearance_m=0.01, edge_margin_m=0.0), child_pos, box.bounding_box, table.bounding_box
)
loss_margin = strategy.compute_loss(
On(table, clearance_m=0.01, edge_margin_m=0.05), child_pos, box.bounding_box, table.bounding_box
)

assert torch.isclose(loss_no_margin, torch.tensor(0.0), atol=1e-4)
assert torch.isclose(loss_margin, torch.tensor(0.5), atol=1e-4)


def test_on_loss_strategy_oversized_child_keeps_plateau_without_margin():
"""Without a margin, a child wider than the parent keeps a flat-plateau penalty (no centering pull)."""

table = _create_table() # X extent [0, 1]
wide_box = DummyObject(
name="wide_box",
bounding_box=AxisAlignedBoundingBox(min_point=(0.0, 0.0, 0.0), max_point=(1.4, 0.2, 0.15)),
)
strategy = OnLossStrategy(slope=10.0)
relation = On(table, clearance_m=0.01, edge_margin_m=0.0)

# The valid X band inverts to [-0.4, 0]; the loss is a constant slope * 0.4 = 4.0 anywhere inside it,
# so the centered origin (-0.2) and the rim-aligned origin (0.0) carry the *same* nonzero penalty.
loss_center = strategy.compute_loss(
relation, torch.tensor([-0.2, 0.4, 0.11]), wide_box.bounding_box, table.bounding_box
)
loss_edge = strategy.compute_loss(
relation, torch.tensor([0.0, 0.4, 0.11]), wide_box.bounding_box, table.bounding_box
)

assert torch.isclose(loss_center, torch.tensor(4.0), atol=1e-4)
assert torch.isclose(loss_edge, loss_center, atol=1e-4) # flat plateau: no preferred X position


def test_on_loss_strategy_margin_too_large_yields_high_loss():
"""A margin too wide to fit inverts the band into a high constant loss instead of asserting."""

table = _create_table() # X/Y extent [0, 1]
box = _create_box() # 0.2m wide -> max feasible margin = (1 - 0.2) / 2 = 0.4
strategy = OnLossStrategy(slope=10.0)
relation = On(table, clearance_m=0.01, edge_margin_m=0.5) # 0.5 > 0.4 -> infeasible

# Each axis band inverts to [0.5, 0.3]; anywhere inside floors at slope * (0.5 - 0.3) = 2.0.
# X and Y both contribute 2.0 and Z is on target -> total 4.0, with no assertion raised.
loss = strategy.compute_loss(relation, torch.tensor([0.4, 0.4, 0.11]), box.bounding_box, table.bounding_box)
assert torch.isclose(loss, torch.tensor(4.0), atol=1e-4)


# =============================================================================
# NextToLossStrategy tests
# =============================================================================
Expand Down
27 changes: 26 additions & 1 deletion isaaclab_arena/tests/test_validate_placement.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def test_on_relation_containment_uses_rotated_bbox():
placer = ObjectPlacer(params=ObjectPlacerParams())
desk = _make_desk() # XY in [-0.5, 0.5]
child = _make_long_box("child") # x in [-0.3, 0.3], y in [-0.05, 0.05]
child.add_relation(On(desk, clearance_m=0.01))
child.add_relation(On(desk, clearance_m=0.01, edge_margin_m=0.0))
# Near the +Y rim: axis-aligned half-Y 0.05 stays inside; rotated 90° half-Y 0.3 spills past +0.5.
positions = {desk: (0.0, 0.0, 0.0), child: (0.0, 0.44, 0.105)}

Expand Down Expand Up @@ -243,3 +243,28 @@ def test_on_relation_check_child_outside_xy_returns_false():
box.add_relation(On(desk))
positions = {desk: (0.0, 0.0, 0.0), box: (10.0, 10.0, 0.1)}
assert placer._validate_on_relations(positions, _env_bboxes(positions)) is False


def _validate_box_on_desk(edge_margin_m: float, box_x: float) -> bool:
"""Validate a 0.2m box at (box_x, 0) on a desk (XY in [-0.5, 0.5]) under the given edge margin."""
placer = ObjectPlacer(params=ObjectPlacerParams())
desk = _make_desk()
box = _make_box("box", size=0.2)
box.add_relation(On(desk, edge_margin_m=edge_margin_m))
positions = {desk: (0.0, 0.0, 0.0), box: (box_x, 0.0, 0.16)}
return placer._validate_on_relations(positions, _env_bboxes(positions))


def test_on_relation_edge_margin_within_inset_band_passes():
# Box right edge 0.3 + 0.1 == rim 0.5 - margin 0.1: on the inset bound, still inside.
assert _validate_box_on_desk(edge_margin_m=0.1, box_x=0.3) is True


def test_on_relation_edge_margin_inside_rim_but_in_margin_gap_fails():
# Box right edge 0.45 is inside the rim but past the inset bound 0.4 (in the margin gap).
assert _validate_box_on_desk(edge_margin_m=0.1, box_x=0.35) is False


def test_on_relation_edge_margin_too_large_for_surface_rejected():
# Desk free span 0.8 caps the margin at 0.4; 0.5 inverts the inset band so containment fails.
assert _validate_box_on_desk(edge_margin_m=0.5, box_x=0.0) is False
Loading