Mesh-based Non-collision Constraints #771
Conversation
There was a problem hiding this comment.
🤖 Isaac Lab Review Bot — PR #771
Mesh-based Non-collision Constraints
Summary
This PR adds sphere-to-SDF mesh collision support as an alternative to AABB overlap detection. The architecture is clean — CollisionMode.MESH integrates well into the existing NoCollisionLossStrategy dispatch, and the greedy sphere decomposition + Warp SDF kernel approach is sound. The test suite is thorough (542 lines!) with good coverage of dispatch routing, gradient flow, and integration.
Findings
| # | Severity | Finding |
|---|---|---|
| 1 | 🟡 Warning | Validator creates fresh WarpMeshManager per call — cache never reused |
| 2 | 🟡 Warning | Scale applied post-transform in extract_trimesh_from_usd may be incorrect for nested prims |
| 3 | 🔵 Suggestion | object_base.py abstract method has no explicit return None |
| 4 | 🔵 Suggestion | Sentinel warning pattern on function object is not thread-safe |
| 5 | 🔵 Suggestion | Consider documenting the rotated-anchor limitation more prominently |
See inline comments for details.
Update (5e86ed0a): Reviewed incremental changes since 655ac73.
Addressed Findings
- ✅ Finding #1 resolved —
_get_cpu_mesh_manager()now lazily creates and caches theWarpMeshManageron the instance, eliminating redundant allocations per validation call. Good fix. - ✅ Finding #2 resolved — Removed erroneous
.Ttranspose onComputeLocalToWorldTransforminusd_helpers.py. USD returns row-major matrices; the transpose was producing incorrect vertex transforms for nested prims.
Other Changes
- Validation logic refactored (
_validate_placement): Mesh mode now skips AABB validation entirely (elsebranch). Previously both checks ran in mesh mode — the AABB check was redundant and could produce false negatives for non-convex shapes. Clean improvement. - Test suite trimmed: Removed
test_sphere_count_respects_budget,test_cache_key_differs_for_different_meshes,test_dispatch_falls_back_when_obj_is_none, andtest_mesh_zero_loss_separated_cylinders. These removals look intentional (simplified scope / covered elsewhere), though removing cache-key differentiation test reduces regression coverage on the caching layer.
Remaining Observations
- Findings #3–#5 from original review remain unaddressed (low priority, suggestions only).
- The new
_get_cpu_mesh_manageruseshasattrcheck — works fine butOptionalattribute initialized in__init__would be more explicit.
Overall: Good incremental improvement. The two main warnings from the initial review are resolved. No new concerns.
Update (729d892c): Reviewed incremental changes since 5e86ed0a.
Changes in this push (2 files)
-
relation_loss_strategies.py— Addedparent_pos_resolved.expand(batch_size, -1)before the per-batch loop. This fixes a shape mismatch whenparent_pos_resolvedis not already batch-expanded (e.g., single parent broadcast to multiple children). Correct fix. -
warp_mesh_manager.py— Wrappedgetattr(obj, "scale", ...)intuple()for cache key computation. This prevents unhashable types (e.g., numpy arrays or torch tensors returned by.scale) from breaking the dict lookup. Necessary bugfix.
Assessment
Both changes are small, targeted bugfixes. No new concerns introduced. All previous suggestions (#3–#5) remain low-priority and unaddressed.
Greptile SummaryThis PR adds
Confidence Score: 4/5The MESH collision path is largely correct but the USD mesh extraction applies spawn scale in world space rather than local space, which can silently produce wrong collision geometry for complex multi-prim USD assemblies. The vectorized solver path and validation logic are well-tested and handle multi-env batching, yaw-aware transforms, and sentinel detection correctly. The USD mesh extractor is the weakest link: post-transform scaling is incorrect when per-prim world transforms are non-identity and scale is non-uniform. isaaclab_arena/utils/usd_helpers.py (scale ordering in extract_trimesh_from_usd) and isaaclab_arena/tests/test_mesh_collision.py (missing @requires_warp on one test) Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[ObjectPlacer.place] --> B[_generate_initial_orientations]
B --> C[_rotate_candidate_bboxes]
C --> D[RelationSolver.solve]
D --> E{CollisionMode?}
E -->|BBOX| F[_compute_no_overlap_loss_aabb]
E -->|MESH| G[_prepare_mesh_collision_cache]
G --> H[_compute_no_overlap_loss_mesh]
G --> I[_compute_no_overlap_loss_aabb skip_mesh_pairs=True]
H --> J[Loss + gradient]
I --> J
F --> J
D --> K[solved positions]
K --> L[_validate_placement]
L --> M[_validate_no_overlap AABB]
M -->|pass + MESH mode| O[_validate_no_overlap_mesh]
M -->|pass + BBOX mode| P[accept]
O --> P
M -->|fail| Q[reject]
O -->|fail| Q
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[ObjectPlacer.place] --> B[_generate_initial_orientations]
B --> C[_rotate_candidate_bboxes]
C --> D[RelationSolver.solve]
D --> E{CollisionMode?}
E -->|BBOX| F[_compute_no_overlap_loss_aabb]
E -->|MESH| G[_prepare_mesh_collision_cache]
G --> H[_compute_no_overlap_loss_mesh]
G --> I[_compute_no_overlap_loss_aabb skip_mesh_pairs=True]
H --> J[Loss + gradient]
I --> J
F --> J
D --> K[solved positions]
K --> L[_validate_placement]
L --> M[_validate_no_overlap AABB]
M -->|pass + MESH mode| O[_validate_no_overlap_mesh]
M -->|pass + BBOX mode| P[accept]
O --> P
M -->|fail| Q[reject]
O -->|fail| Q
Reviews (12): Last reviewed commit: "add is_anchor" | Re-trigger Greptile |
729d892 to
db06239
Compare
ef73a02 to
7c46283
Compare
alexmillane
left a comment
There was a problem hiding this comment.
First partial review.
Looks good. I haven't got to the warp mesh based stuff.
| def get_collision_mesh(self) -> trimesh.Trimesh | None: | ||
| """Lazily extract collision mesh from USD. Cached after first call.""" | ||
| if not hasattr(self, "_collision_mesh"): | ||
| self._collision_mesh = None | ||
| if self.usd_path is not None: | ||
| try: | ||
| self._collision_mesh = extract_trimesh_from_usd(self.usd_path, self.scale) | ||
| except (ValueError, RuntimeError, OSError) as e: | ||
| print(f" [MeshManager] Could not extract mesh for '{self.name}': {e}") | ||
| return self._collision_mesh | ||
|
|
||
| def __deepcopy__(self, memo): | ||
| """Exclude _collision_mesh from deepcopy (trimesh has unpicklable C pointers).""" | ||
| import copy | ||
|
|
||
| cls = self.__class__ | ||
| result = cls.__new__(cls) | ||
| memo[id(self)] = result | ||
| for k, v in self.__dict__.items(): | ||
| if k == "_collision_mesh": | ||
| setattr(result, k, None) | ||
| else: | ||
| setattr(result, k, copy.deepcopy(v, memo)) | ||
| return result |
There was a problem hiding this comment.
What do you think about moving the mesh stuff out of Object and into the solver?
I guess we have it here because we want caching behaviour. We could instead have something like CollisionShapeManager in the solver which manages the caching.
We should also probably move the bounding boxes there too.
| arena_group.add_argument( | ||
| "--collision_mode", | ||
| type=str, | ||
| choices=["bbox", "mesh"], | ||
| default="bbox", | ||
| help="Collision detection mode: 'bbox' (AABB, default) or 'mesh' (sphere-to-SDF, requires Warp).", | ||
| ) |
There was a problem hiding this comment.
I feel like the mesh vs. bounding-box decision should be made on the environment level. For example, an environment with a shelf we'd like to place something on qill require mesh-mode. Here we're leaving it up to the user a run time.
That seems like an odd choice to me.
Can you see a way we could configure this in each environment?
There was a problem hiding this comment.
To me the obvious way would be to make the ObjectPlacerParams/RelationSolverParams an (optional) part of IsaacLabArenaEnvironment. If specified in the environment they're passed along to the solver.
| placement_seed: int | None = None, | ||
| resolve_on_reset: bool | None = None, | ||
| random_yaw_init: bool = False, | ||
| collision_mode: str = "bbox", |
There was a problem hiding this comment.
Related to the comment above.
We're seeing a reflection of the members of ObjectPlacerParams on the interface to this function.
I think the correct way to get out of this issue (that we have to copy members of ObjectPlacerParams onto the interface), is to make this function accept a full ObjectPlacerParams instance, which is specified per-environment in IsaacLabArenaEnvironment.
We should probably make this change in an MR that preceeds this one.
| def __deepcopy__(self, memo): | ||
| """Deep copy, dropping lazy Warp caches (unpicklable C pointers); rebuilt on demand.""" | ||
| import copy | ||
|
|
||
| cls = self.__class__ | ||
| result = cls.__new__(cls) | ||
| memo[id(self)] = result | ||
| for k, v in self.__dict__.items(): | ||
| if k in ("_cpu_mesh_manager", "_warned_no_mesh"): | ||
| continue | ||
| setattr(result, k, copy.deepcopy(v, memo)) | ||
| return result |
There was a problem hiding this comment.
This is a bit ugly and we'd like to avoid it. Or at worst, we could isolate it.
What the motivation here?
Could we solve this in the WarpMeshManager. Potentially by overriding its deep-copy operation?
Aside: why do we need special treatment of _warned_no_mesh? That appears just to be a set[str]?
| def _get_cpu_mesh_manager(self): | ||
| """Lazily create a CPU WarpMeshManager, cached across validation calls.""" | ||
| if not hasattr(self, "_cpu_mesh_manager"): | ||
| from isaaclab_arena.relations.warp_mesh_manager import WarpMeshManager | ||
|
|
||
| self._cpu_mesh_manager = WarpMeshManager( | ||
| num_spheres=self.params.solver_params.num_spheres, | ||
| device="cpu", | ||
| ) | ||
| return self._cpu_mesh_manager |
There was a problem hiding this comment.
Out of interest, why do we do this lazily?
It seems to me that there's no cost to creating one of these guys, as they just create empty dicts. What's the motivation here?
There was a problem hiding this comment.
This is to avoid loading warp when we only use bbox
| # Rotate child sphere centers by net_yaw = child_yaw - parent_yaw. | ||
| net_yaw = child_yaw - parent_yaw | ||
| if net_yaw != 0.0: | ||
| cos_n = math.cos(net_yaw) | ||
| sin_n = math.sin(net_yaw) | ||
| rx = centers_local[:, 0] * cos_n - centers_local[:, 1] * sin_n | ||
| ry = centers_local[:, 0] * sin_n + centers_local[:, 1] * cos_n | ||
| centers_local = torch.stack([rx, ry, centers_local[:, 2]], dim=-1) | ||
|
|
||
| batch_size = child_pos.shape[0] | ||
| parent_pos_resolved = parent_pos_resolved.expand(batch_size, -1) | ||
| total_loss = torch.zeros(batch_size, device=device, dtype=child_pos.dtype) | ||
|
|
||
| for b in range(batch_size): | ||
| offset = child_pos[b] - parent_pos_resolved[b] | ||
| # Rotate offset into the parent's local frame. | ||
| if parent_yaw != 0.0: | ||
| cos_p = math.cos(-parent_yaw) | ||
| sin_p = math.sin(-parent_yaw) | ||
| ox = offset[0] * cos_p - offset[1] * sin_p | ||
| oy = offset[0] * sin_p + offset[1] * cos_p | ||
| offset = torch.stack([ox, oy, offset[2]]) | ||
| centers_in_parent = centers_local + offset |
There was a problem hiding this comment.
Two suggestions:
- Part of this code represents transforming vectors in one frame to another. Under a transformation (that happens to only have a yaw component). I'm wondering if we can use more general tools for this. For example, can we use:
isaaclab.utils.mathfor examplequat_applyfor applying rotations, rather than recoding the code for rotations here. - It is useful if you use structured notation. For example
q_B_Arepresents the rotation from the frameAto the frameB.
I just wrote a good example of doing this type of this here
| n_candidates = max(num_spheres, n_candidates) | ||
| n_surface = max(n_candidates, n_surface) | ||
|
|
||
| rng = np.random.default_rng(seed) |
There was a problem hiding this comment.
This might destroy the seeding we do elsewhere.
I'm wondering if we should avoid setting the seed anywhere in our codebase, except for the one place we set it in Isaac Lab.
@xyao-nv @peterd-NV What do you think? Should we outlaw setting seeds in our code?
ee9757e to
ca4a09c
Compare
Signed-off-by: zhx06 <zihaox@nvidia.com>
Signed-off-by: zhx06 <zihaox@nvidia.com>
Signed-off-by: zhx06 <zihaox@nvidia.com>
Signed-off-by: zhx06 <zihaox@nvidia.com>
Signed-off-by: zhx06 <zihaox@nvidia.com>
Signed-off-by: zhx06 <zihaox@nvidia.com>
af4e742 to
bc78db6
Compare
Summary
Add mesh-based non-collision constraints via sphere-to-SDF
Detailed description
CollisionMode.MESHas an alternative to AABB for no-overlap constraints, using greedy sphere decomposition + differentiable Warp SDF queries against actual collision geometry.--collision_mode meshenables the new path.Core files
relations/warp_sdf_kernels.py— differentiable SDF queries on Warp meshesrelations/warp_mesh_manager.py— sphere decomposition and mesh cachingrelations/relation_solver.py— vectorized mesh collision loss during optimizationrelations/object_placer.py— mesh collision validation at placement timerelations/relation_loss_strategies.py— per-pair mesh loss for the strategy API