Skip to content

Commit 64aca98

Browse files
authored
Add X/Y margin to ON relation & access by intent compiler (#795)
## Summary Short description of the change (max 50 chars) ## Detailed description - Why: An object placed On a surface could solve to a pose on the rim, causing it topple off the edge. There was no way to keep its footprint a safe distance inside the parent surface. - What: 1. On has an `edge_margin_m` parameter (default 0.0) that insets the valid X/Y footprint band by the margin, so the whole footprint stays at least that far from every rim. 2. A margin too large for the parent surface is now rejected in validation 3. `IntentCompiler.compile()` injects a default edge_margin_m (0.05 m) on every on relation built from an intent spec - Impact: Agent-generated scenes keep objects clear of surface edges by default. ## Results <img width="700" height="600" alt="optimization_margin_0p30" src="https://github.com/user-attachments/assets/979e4bd9-1c95-486a-90e9-fbfa448c6baf" /> <img width="633" height="603" alt="placement_margin_0p30" src="https://github.com/user-attachments/assets/467d211e-027f-441b-a0d9-9f4d28e5c0b4" /> ### With agentic env gen pipeline Before <img width="812" height="512" alt="Screenshot from 2026-06-15 12-17-15" src="https://github.com/user-attachments/assets/a787d63d-923c-41f6-bdc9-b5c4f7ed1356" /> After <img width="956" height="545" alt="Screenshot from 2026-06-15 11-02-57" src="https://github.com/user-attachments/assets/982990dd-05f7-41f2-af53-a871a68f6ef6" />
1 parent 3571005 commit 64aca98

8 files changed

Lines changed: 160 additions & 20 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
# =============================================================================
7+
# Intent compiler default parameters
8+
# =============================================================================
9+
10+
# The ID of the initial state spec in the ArenaEnvInitialGraphSpec.
11+
INITIAL_STATE_SPEC_ID = "state_initial"

isaaclab_arena/agentic_environment_generation/intent_compiler.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
IntentResolutionTraceEvent,
1111
match_asset,
1212
)
13+
from isaaclab_arena.agentic_environment_generation.default_params import INITIAL_STATE_SPEC_ID
1314
from isaaclab_arena.agentic_environment_generation.environment_intent_spec import EnvironmentIntentSpec, Item
1415
from isaaclab_arena.assets.registries import AssetRegistry
1516
from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec
@@ -22,8 +23,6 @@
2223
TaskSpec,
2324
)
2425

25-
_INITIAL_STATE_SPEC_ID = "state_initial"
26-
2726

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

56-
def compile(self, spec: EnvironmentIntentSpec, env_name: str | None = None) -> ArenaEnvInitialGraphSpec:
55+
def compile(
56+
self,
57+
spec: EnvironmentIntentSpec,
58+
env_name: str | None = None,
59+
) -> ArenaEnvInitialGraphSpec:
5760
"""Compile an :class:`EnvironmentIntentSpec` into an :class:`ArenaEnvInitialGraphSpec`.
5861
5962
Args:
@@ -150,7 +153,7 @@ def _build_initial_state_spec(
150153
if constraint is not None:
151154
constraints.append(constraint)
152155
return ArenaEnvGraphStateSpec(
153-
id=_INITIAL_STATE_SPEC_ID,
156+
id=INITIAL_STATE_SPEC_ID,
154157
is_delta=False,
155158
spatial_constraints=constraints,
156159
task_constraints=[],
@@ -173,7 +176,7 @@ def _build_spatial_constraint(
173176
return None
174177

175178
reference_part = f"_{rel.reference}" if rel.reference is not None else ""
176-
constraint_id = f"{_INITIAL_STATE_SPEC_ID}_{index}_{rel.kind}{reference_part}_{rel.subject}"
179+
constraint_id = f"{INITIAL_STATE_SPEC_ID}_{index}_{rel.kind}{reference_part}_{rel.subject}"
177180
self.trace.append(IntentResolutionTraceEvent(f"{stage_prefix}.ok", rel.subject, rel.reference, note=rel.kind))
178181
return ArenaEnvGraphSpatialRelationSpec(
179182
id=constraint_id,

isaaclab_arena/relations/object_placer.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -528,8 +528,8 @@ def _validate_on_relations(
528528
) -> bool:
529529
"""Validate each On relation; keep in sync with OnLossStrategy in relation_loss_strategies.py.
530530
531-
1. X: child's footprint entirely within parent's X extent.
532-
2. Y: child's footprint entirely within parent's Y extent.
531+
1. X: child's footprint within parent's X extent, inset by the relation's edge_margin_m.
532+
2. Y: child's footprint within parent's Y extent, inset by the relation's edge_margin_m.
533533
3. Z: child_bottom in (parent_top, parent_top+clearance_m], within on_relation_z_tolerance_m.
534534
535535
Args:
@@ -547,15 +547,37 @@ def _validate_on_relations(
547547
parent_bbox = env_bboxes[parent]
548548
child_world = child_bbox.translated(positions[obj])
549549
parent_world = parent_bbox.translated(positions[parent])
550+
parent_size = parent_world.max_point - parent_world.min_point
551+
child_size = child_world.max_point - child_world.min_point
552+
553+
m = rel.edge_margin_m
554+
# 1) Checking that with the specified margin, the parent is wide enough to place the child on top
555+
if m > 0.0:
556+
freespace = parent_size - child_size
557+
# A margin too large for the surface inverts the inset band so containment can never pass.
558+
if torch.any(freespace[0, :2] < 2 * m):
559+
# The maximum feasible margin is the minimum of the freespace on the xy axes.
560+
max_feasible_margin = max(0.0, min(freespace[0, :2]) / 2.0)
561+
# When parent < child, freespace[0, :2] is negative and max_feasible_margin is 0.0.
562+
if max_feasible_margin > 0.0:
563+
if self.params.verbose:
564+
print(
565+
f"On relation: edge_margin_m={m} m is too large for parent '{parent.name}'. Max"
566+
f" feasible margin here is {max_feasible_margin:.3f} m. Use a smaller"
567+
" edge_margin_m."
568+
)
569+
return False
570+
# 2) Checking that the child lies within the parent's xy
550571
if (
551-
child_world.min_point[0, 0] < parent_world.min_point[0, 0]
552-
or child_world.max_point[0, 0] > parent_world.max_point[0, 0]
553-
or child_world.min_point[0, 1] < parent_world.min_point[0, 1]
554-
or child_world.max_point[0, 1] > parent_world.max_point[0, 1]
572+
child_world.min_point[0, 0] < parent_world.min_point[0, 0] + m
573+
or child_world.max_point[0, 0] > parent_world.max_point[0, 0] - m
574+
or child_world.min_point[0, 1] < parent_world.min_point[0, 1] + m
575+
or child_world.max_point[0, 1] > parent_world.max_point[0, 1] - m
555576
):
556577
if self.params.verbose:
557-
print(f" On relation: '{obj.name}' XY outside parent (retrying)")
578+
print(f"On relation: '{obj.name}' XY outside parent (retrying)")
558579
return False
580+
# 3) Checking that the child lies within an acceptable z-range.
559581
parent_local_top_z: float = parent_bbox.max_point[0, 2].item()
560582
child_local_bottom_z: float = child_bbox.min_point[0, 2].item()
561583
parent_top_z = parent_local_top_z + positions[parent][2]

isaaclab_arena/relations/relation_loss_strategies.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,16 @@ def compute_loss(
274274
parent_y_max = parent_world_bbox.max_point[:, 1]
275275
parent_z_max = parent_world_bbox.max_point[:, 2] # Top surface
276276

277-
# Compute valid position ranges such that child's entire footprint is within parent
278-
valid_x_min = parent_x_min - child_bbox.min_point[:, 0] # child's left at parent's left
279-
valid_x_max = parent_x_max - child_bbox.max_point[:, 0] # child's right at parent's right
280-
valid_y_min = parent_y_min - child_bbox.min_point[:, 1]
281-
valid_y_max = parent_y_max - child_bbox.max_point[:, 1]
277+
# Compute valid position ranges such that child's entire footprint is within parent,
278+
# with the parent's extent inset by edge_margin_m so the footprint stays off the rim.
279+
m = relation.edge_margin_m
280+
valid_x_min = parent_x_min + m - child_bbox.min_point[:, 0] # child's left at parent's left + margin
281+
valid_x_max = parent_x_max - m - child_bbox.max_point[:, 0] # child's right at parent's right - margin
282+
valid_y_min = parent_y_min + m - child_bbox.min_point[:, 1]
283+
valid_y_max = parent_y_max - m - child_bbox.max_point[:, 1]
284+
285+
# The bounds invert (lower > upper) when the margin is too large for the surface or the
286+
# child is oversized. The loss becomes a non-zero constant with gradient zero.
282287

283288
# 1. X band loss: child's footprint entirely within parent's X extent
284289
x_band_loss = linear_band_loss(

isaaclab_arena/relations/relations.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
if TYPE_CHECKING:
1818
from isaaclab_arena.assets.object_base import ObjectBase
1919

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

2125
class Side(Enum):
2226
"""Axis direction for spatial relationships."""
@@ -116,8 +120,9 @@ class On(Relation):
116120
"""Represents an 'on top of' relationship between objects.
117121
118122
This relation specifies that a child object should be placed on top of
119-
the parent object, with X/Y bounded within the parent's extent and Z
120-
positioned on the parent's top surface.
123+
the parent object, with X/Y bounded within the parent's extent (optionally
124+
inset by ``edge_margin_m`` so the child stays off the rim) and Z positioned
125+
on the parent's top surface.
121126
122127
Note: Loss computation is handled by OnLossStrategy in relation_loss_strategies.py.
123128
"""
@@ -129,16 +134,24 @@ def __init__(
129134
parent: ObjectBase,
130135
relation_loss_weight: float = 1.0,
131136
clearance_m: float = 0.01,
137+
edge_margin_m: float = DEFAULT_ON_EDGE_MARGIN_M,
132138
):
133139
"""
134140
Args:
135141
parent: The parent asset that this object should be placed on top of.
136142
relation_loss_weight: Weight for the relationship loss function.
137143
clearance_m: Safety clearance above parent's surface in meters (default: 1cm).
144+
edge_margin_m: Inward inset from each X/Y edge of the parent's surface in
145+
meters (default: 5cm). The child's whole footprint is kept at least this
146+
far from the rim. The solver rejects a margin too large for the surface
147+
to honor (``2 * edge_margin_m`` wider than ``parent_extent - child_extent``
148+
on either axis).
138149
"""
139150
super().__init__(parent, relation_loss_weight)
140151
assert clearance_m >= 0.0, f"Clearance must be non-negative, got {clearance_m}"
152+
assert edge_margin_m >= 0.0, f"edge_margin_m must be non-negative, got {edge_margin_m}"
141153
self.clearance_m = clearance_m
154+
self.edge_margin_m = edge_margin_m
142155

143156

144157
@register_object_relation

isaaclab_arena/tests/test_intent_compiler.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ def test_spatial_constraint_binary_relation_id_and_fields():
255255
assert constraint.reference == "maple_table"
256256
assert constraint.subject == "cracker_box"
257257
assert constraint.id == "state_initial_0_on_maple_table_cracker_box"
258+
# The compiler does not special-case ``on``; the edge margin is the On relation's own default.
259+
assert constraint.params == {}
258260

259261

260262
def test_spatial_constraint_unary_relation_id_and_fields():

isaaclab_arena/tests/test_relation_loss_strategies.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,65 @@ def test_on_loss_strategy_constrains_entire_footprint():
143143
assert loss > 0.0, "Loss should penalize child footprint extending beyond parent"
144144

145145

146+
def test_on_loss_strategy_edge_margin_insets_band_by_margin():
147+
"""The edge margin shifts the valid X band inward by exactly the margin."""
148+
149+
table = _create_table() # X extent [0, 1]
150+
box = _create_box() # 0.2m wide, local min at 0
151+
strategy = OnLossStrategy(slope=10.0)
152+
# Without a margin the rim-aligned upper bound for the origin is 1 - 0.2 = 0.8, so x=0.8 -> loss 0.
153+
# A 0.05 margin moves that bound to 0.75, leaving x=0.8 exactly 0.05 over -> loss = slope * 0.05 = 0.5.
154+
child_pos = torch.tensor([0.8, 0.4, 0.11])
155+
156+
loss_no_margin = strategy.compute_loss(
157+
On(table, clearance_m=0.01, edge_margin_m=0.0), child_pos, box.bounding_box, table.bounding_box
158+
)
159+
loss_margin = strategy.compute_loss(
160+
On(table, clearance_m=0.01, edge_margin_m=0.05), child_pos, box.bounding_box, table.bounding_box
161+
)
162+
163+
assert torch.isclose(loss_no_margin, torch.tensor(0.0), atol=1e-4)
164+
assert torch.isclose(loss_margin, torch.tensor(0.5), atol=1e-4)
165+
166+
167+
def test_on_loss_strategy_oversized_child_keeps_plateau_without_margin():
168+
"""Without a margin, a child wider than the parent keeps a flat-plateau penalty (no centering pull)."""
169+
170+
table = _create_table() # X extent [0, 1]
171+
wide_box = DummyObject(
172+
name="wide_box",
173+
bounding_box=AxisAlignedBoundingBox(min_point=(0.0, 0.0, 0.0), max_point=(1.4, 0.2, 0.15)),
174+
)
175+
strategy = OnLossStrategy(slope=10.0)
176+
relation = On(table, clearance_m=0.01, edge_margin_m=0.0)
177+
178+
# The valid X band inverts to [-0.4, 0]; the loss is a constant slope * 0.4 = 4.0 anywhere inside it,
179+
# so the centered origin (-0.2) and the rim-aligned origin (0.0) carry the *same* nonzero penalty.
180+
loss_center = strategy.compute_loss(
181+
relation, torch.tensor([-0.2, 0.4, 0.11]), wide_box.bounding_box, table.bounding_box
182+
)
183+
loss_edge = strategy.compute_loss(
184+
relation, torch.tensor([0.0, 0.4, 0.11]), wide_box.bounding_box, table.bounding_box
185+
)
186+
187+
assert torch.isclose(loss_center, torch.tensor(4.0), atol=1e-4)
188+
assert torch.isclose(loss_edge, loss_center, atol=1e-4) # flat plateau: no preferred X position
189+
190+
191+
def test_on_loss_strategy_margin_too_large_yields_high_loss():
192+
"""A margin too wide to fit inverts the band into a high constant loss instead of asserting."""
193+
194+
table = _create_table() # X/Y extent [0, 1]
195+
box = _create_box() # 0.2m wide -> max feasible margin = (1 - 0.2) / 2 = 0.4
196+
strategy = OnLossStrategy(slope=10.0)
197+
relation = On(table, clearance_m=0.01, edge_margin_m=0.5) # 0.5 > 0.4 -> infeasible
198+
199+
# Each axis band inverts to [0.5, 0.3]; anywhere inside floors at slope * (0.5 - 0.3) = 2.0.
200+
# X and Y both contribute 2.0 and Z is on target -> total 4.0, with no assertion raised.
201+
loss = strategy.compute_loss(relation, torch.tensor([0.4, 0.4, 0.11]), box.bounding_box, table.bounding_box)
202+
assert torch.isclose(loss, torch.tensor(4.0), atol=1e-4)
203+
204+
146205
# =============================================================================
147206
# NextToLossStrategy tests
148207
# =============================================================================

isaaclab_arena/tests/test_validate_placement.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def test_on_relation_containment_uses_rotated_bbox():
169169
placer = ObjectPlacer(params=ObjectPlacerParams())
170170
desk = _make_desk() # XY in [-0.5, 0.5]
171171
child = _make_long_box("child") # x in [-0.3, 0.3], y in [-0.05, 0.05]
172-
child.add_relation(On(desk, clearance_m=0.01))
172+
child.add_relation(On(desk, clearance_m=0.01, edge_margin_m=0.0))
173173
# Near the +Y rim: axis-aligned half-Y 0.05 stays inside; rotated 90° half-Y 0.3 spills past +0.5.
174174
positions = {desk: (0.0, 0.0, 0.0), child: (0.0, 0.44, 0.105)}
175175

@@ -243,3 +243,28 @@ def test_on_relation_check_child_outside_xy_returns_false():
243243
box.add_relation(On(desk))
244244
positions = {desk: (0.0, 0.0, 0.0), box: (10.0, 10.0, 0.1)}
245245
assert placer._validate_on_relations(positions, _env_bboxes(positions)) is False
246+
247+
248+
def _validate_box_on_desk(edge_margin_m: float, box_x: float) -> bool:
249+
"""Validate a 0.2m box at (box_x, 0) on a desk (XY in [-0.5, 0.5]) under the given edge margin."""
250+
placer = ObjectPlacer(params=ObjectPlacerParams())
251+
desk = _make_desk()
252+
box = _make_box("box", size=0.2)
253+
box.add_relation(On(desk, edge_margin_m=edge_margin_m))
254+
positions = {desk: (0.0, 0.0, 0.0), box: (box_x, 0.0, 0.16)}
255+
return placer._validate_on_relations(positions, _env_bboxes(positions))
256+
257+
258+
def test_on_relation_edge_margin_within_inset_band_passes():
259+
# Box right edge 0.3 + 0.1 == rim 0.5 - margin 0.1: on the inset bound, still inside.
260+
assert _validate_box_on_desk(edge_margin_m=0.1, box_x=0.3) is True
261+
262+
263+
def test_on_relation_edge_margin_inside_rim_but_in_margin_gap_fails():
264+
# Box right edge 0.45 is inside the rim but past the inset bound 0.4 (in the margin gap).
265+
assert _validate_box_on_desk(edge_margin_m=0.1, box_x=0.35) is False
266+
267+
268+
def test_on_relation_edge_margin_too_large_for_surface_rejected():
269+
# Desk free span 0.8 caps the margin at 0.4; 0.5 inverts the inset band so containment fails.
270+
assert _validate_box_on_desk(edge_margin_m=0.5, box_x=0.0) is False

0 commit comments

Comments
 (0)