55
66from __future__ import annotations
77
8+ import copy
9+ import numpy as np
810import torch
911from typing import TYPE_CHECKING , cast
1012
13+ import warp as wp
1114from isaaclab .utils .math import quat_apply , quat_apply_inverse
1215
1316from isaaclab_arena .relations .collision_mode import CollisionMode
17+ from isaaclab_arena .relations .mesh_pair_cache import MeshPairCache
1418from isaaclab_arena .relations .relation_loss_strategies import (
1519 NoCollisionLossStrategy ,
1620 RelationLossStrategy ,
1923from isaaclab_arena .relations .relation_solver_params import RelationSolverParams
2024from isaaclab_arena .relations .relation_solver_state import RelationSolverState
2125from isaaclab_arena .relations .relations import On , Relation , RelationBase , UnaryRelation
26+ from isaaclab_arena .relations .warp_mesh_manager import WarpMeshAndSphereCache
27+ from isaaclab_arena .relations .warp_sdf_kernels import clamp_sdf_sentinel , multi_mesh_sdf
2228from isaaclab_arena .utils .bounding_box import AxisAlignedBoundingBox
29+ from isaaclab_arena .utils .pose import Pose , yaw_from_quat_xyzw
2330
2431if TYPE_CHECKING :
2532 from isaaclab_arena .assets .object_base import ObjectBase
26- from isaaclab_arena .relations .mesh_pair_cache import MeshPairCache
27- from isaaclab_arena .relations .warp_mesh_manager import WarpMeshAndSphereCache
2833
2934
3035class RelationSolver :
31- """Differentiable solver for 3D spatial relations of IsaacLab Arena Objects
36+ """Differentiable solver for 3D spatial relations of IsaacLab Arena Objects.
3237
3338 Uses the Strategy pattern for loss computation: each Relation type has a
3439 corresponding RelationLossStrategy that handles the actual loss calculation.
@@ -41,7 +46,8 @@ def __init__(
4146 self ,
4247 params : RelationSolverParams | None = None ,
4348 ):
44- """
49+ """Initialize the solver with the given parameters.
50+
4551 Args:
4652 params: Solver configuration parameters. If None, uses defaults.
4753 """
@@ -62,8 +68,7 @@ def __init__(
6268 self ._mesh_cache_rev : MeshPairCache | None = None
6369
6470 def __deepcopy__ (self , memo ):
65- """Deep-copy resets ephemeral Warp caches (rebuilt on next solve)."""
66- import copy
71+ """Reset Warp GPU caches on copy; wp.Mesh handles are not copy-safe."""
6772
6873 cls = self .__class__
6974 result = cls .__new__ (cls )
@@ -76,16 +81,10 @@ def __deepcopy__(self, memo):
7681 return result
7782
7883 def _get_strategy (self , relation : RelationBase ) -> RelationLossStrategy | UnaryRelationLossStrategy :
79- """Look up the appropriate strategy for a relation type.
84+ """Look up the loss strategy for a relation type; raises ValueError if none registered .
8085
8186 Args:
8287 relation: The relation to find a strategy for.
83-
84- Returns:
85- The RelationLossStrategy or UnaryRelationLossStrategy for this relation type.
86-
87- Raises:
88- ValueError: If no strategy is registered for this relation type.
8988 """
9089 strategy = self .params .strategies .get (type (relation ))
9190 if strategy is None :
@@ -114,14 +113,12 @@ def _compute_total_loss(
114113 device = state .device
115114 total_loss = torch .zeros (batch_size , device = device , dtype = torch .float32 )
116115
117- # Compute loss from all spatial relations using strategies
118116 for obj in state .optimizable_objects :
119117 for relation in obj .get_spatial_relations ():
120118 child_pos = state .get_position (obj )
121119 strategy = self ._get_strategy (relation )
122120 child_bbox = state .get_bbox (obj )
123121
124- # Handle unary relations (no parent)
125122 if isinstance (relation , UnaryRelation ):
126123 unary_strategy = cast (UnaryRelationLossStrategy , strategy )
127124 loss = unary_strategy .compute_loss (
@@ -131,7 +128,7 @@ def _compute_total_loss(
131128 )
132129 if debug :
133130 _print_unary_relation_debug (obj , relation , child_pos [0 ], loss .mean ())
134- # Handle binary relations (with parent) like On, NextTo
131+ # Binary relation ( On, NextTo, etc.)
135132 elif isinstance (relation , Relation ):
136133 relation_strategy = cast (RelationLossStrategy , strategy )
137134 parent = relation .parent
@@ -166,24 +163,7 @@ def _compute_no_overlap_loss(
166163 state : RelationSolverState ,
167164 debug : bool = False ,
168165 ) -> torch .Tensor :
169- """Compute pairwise no-overlap loss, skipping On-linked pairs.
170-
171- Each unique non-On pair is evaluated twice (once per direction):
172- - Non-anchor vs anchor: gradient flows to the non-anchor only.
173- - Non-anchor vs non-anchor: both objects receive gradient by computing
174- the loss in both directions with the other's position detached.
175-
176- In MESH mode, a precomputed cache feeds a single multi-mesh SDF kernel
177- per batch element per direction (fwd/rev).
178-
179- Args:
180- state: Current optimization state with object positions and
181- optional per-env bounding boxes.
182- debug: If True, print detailed loss breakdown.
183-
184- Returns:
185- Per-environment loss tensor of shape (batch_size,).
186- """
166+ """Compute pairwise no-overlap loss, skipping On-linked pairs."""
187167 if self .params .collision_mode == CollisionMode .MESH :
188168 mesh_loss = self ._compute_no_overlap_loss_mesh (state , debug )
189169 aabb_loss = self ._compute_no_overlap_loss_aabb (state , debug , skip_mesh_pairs = True )
@@ -292,8 +272,6 @@ def _prepare_mesh_collision_cache(
292272 on_pairs : set [tuple [int , int ]],
293273 ) -> None :
294274 """Precompute static per-pair mesh collision data (called once per solve)."""
295- from isaaclab_arena .relations .warp_mesh_manager import WarpMeshAndSphereCache
296-
297275 device = state .device
298276 device_str = str (device )
299277 if self ._mesh_manager is None or self ._mesh_manager .device != device_str :
@@ -317,12 +295,6 @@ def _build_vectorized_cache(
317295
318296 Returns None if no valid pairs exist for this direction.
319297 """
320- import numpy as np
321-
322- import warp as wp
323-
324- from isaaclab_arena .utils .pose import Pose , yaw_from_quat_xyzw
325-
326298 centers_list : list [torch .Tensor ] = []
327299 radii_list : list [torch .Tensor ] = []
328300 pair_child_objs : list = []
@@ -346,7 +318,7 @@ def _build_vectorized_cache(
346318 if child_mesh is None :
347319 if child .name not in self ._warned_no_mesh :
348320 self ._warned_no_mesh .add (child .name )
349- print (f"[NoCollision] MESH mode: '{ child .name } ' has no collision mesh, skipping ." )
321+ print (f"[NoCollision] '{ child .name } ' has no collision mesh; pair will use AABB fallback ." )
350322 continue
351323 child_spheres = manager .get_query_spheres (child_mesh , obj = child ).to (device )
352324 child_centers_local = child_spheres [:, :3 ]
@@ -363,7 +335,7 @@ def _build_vectorized_cache(
363335 if parent_mesh is None :
364336 if anchor .name not in self ._warned_no_mesh :
365337 self ._warned_no_mesh .add (anchor .name )
366- print (f"[NoCollision] MESH mode: '{ anchor .name } ' has no collision mesh, skipping ." )
338+ print (f"[NoCollision] '{ anchor .name } ' has no collision mesh; pair will use AABB fallback ." )
367339 continue
368340 warp_mesh = manager .get_warp_mesh (parent_mesh , obj = anchor )
369341 parent_bbox = state .get_bbox (anchor )
@@ -412,7 +384,7 @@ def _build_vectorized_cache(
412384 if other_mesh is None :
413385 if other .name not in self ._warned_no_mesh :
414386 self ._warned_no_mesh .add (other .name )
415- print (f"[NoCollision] MESH mode: '{ other .name } ' has no collision mesh, skipping ." )
387+ print (f"[NoCollision] '{ other .name } ' has no collision mesh; pair will use AABB fallback ." )
416388 continue
417389 warp_mesh = manager .get_warp_mesh (other_mesh , obj = other )
418390 other_bbox = state .get_bbox (other )
@@ -451,7 +423,7 @@ def _build_vectorized_cache(
451423 if other_mesh is None :
452424 if other .name not in self ._warned_no_mesh :
453425 self ._warned_no_mesh .add (other .name )
454- print (f"[NoCollision] MESH mode: '{ other .name } ' has no collision mesh, skipping ." )
426+ print (f"[NoCollision] '{ other .name } ' has no collision mesh; pair will use AABB fallback ." )
455427 continue
456428 other_spheres = manager .get_query_spheres (other_mesh , obj = other ).to (device )
457429 other_centers_local = other_spheres [:, :3 ]
@@ -487,8 +459,6 @@ def _build_vectorized_cache(
487459 if not centers_list :
488460 return None
489461
490- from isaaclab_arena .relations .mesh_pair_cache import MeshPairCache
491-
492462 wp_device = str (device )
493463 pair_sphere_count = torch .tensor ([e - s for s , e in pair_slices ], dtype = torch .float32 , device = device )
494464 sphere_pair_id = torch .repeat_interleave (
@@ -526,10 +496,6 @@ def _compute_no_overlap_loss_mesh(
526496 Uses precomputed pair cache (centers, radii, mesh indices) to batch all
527497 sphere queries into a single Warp kernel call per iteration.
528498 """
529- import warp as wp
530-
531- from isaaclab_arena .relations .warp_sdf_kernels import clamp_sdf_sentinel , multi_mesh_sdf
532-
533499 device = state .device
534500 total_loss = torch .zeros (state .batch_size , device = device , dtype = torch .float32 )
535501 clearance_m = self .params .clearance_m
@@ -714,7 +680,7 @@ def solve(
714680 if self .params .collision_mode == CollisionMode .MESH :
715681 self ._mesh_manager .reset_sentinel_warning ()
716682
717- # Setup optimizer (only for optimizable positions)
683+ # Only optimizable_positions participates in Adam — anchors are fixed.
718684 optimizer = torch .optim .Adam ([state .optimizable_positions ], lr = self .params .lr )
719685
720686 # Compute initial loss so _last_loss_per_env is always populated
@@ -732,12 +698,10 @@ def solve(
732698 if self .params .save_position_history and iter % self .POSITION_HISTORY_SAVE_INTERVAL == 0 :
733699 position_history .append (state .get_all_positions_snapshot ())
734700
735- # Compute total loss
736701 loss = self ._compute_total_loss (state )
737702 loss_history .append (loss .item ())
738703
739- # Backprop and update (only optimizable positions will update).
740- # Constant-zero loss (all pairs broadphase-culled, no relations) has no grad_fn; nothing to step.
704+ # Constant-zero loss has no grad_fn — skip backward when broadphase culls all pairs.
741705 if loss .grad_fn is not None :
742706 loss .backward ()
743707 optimizer .step ()
@@ -758,7 +722,6 @@ def solve(
758722 print (f"\n Final loss: { loss_history [- 1 ]:.6f} " )
759723 print (f"Total iterations: { len (loss_history )} " )
760724
761- # Store metadata for optional access
762725 self ._last_loss_history = loss_history
763726 self ._last_position_history = position_history
764727
@@ -796,7 +759,6 @@ def debug_losses(self, objects: list[ObjectBase]) -> None:
796759 print ("No position history available. Run solve() first." )
797760 return
798761
799- # Build positions dict from final position history
800762 final_positions = {obj : (pos [0 ], pos [1 ], pos [2 ]) for obj , pos in zip (objects , final_positions_list )}
801763
802764 state = RelationSolverState (objects , [final_positions ])
0 commit comments