1515 MassAPI from base_link to wrapper, removing inner RigidBodyAPI, and
1616 deleting the internal FixedJoint.
1717
18- 3. **Articulated objects** (wardrobes with doors, fridges): Move
19- ArticulationRootAPI from wrapper to E_body, set kinematic mode on E_body,
20- remove wrapper physics , and delete FixedJoints to root .
18+ 3. **Articulated objects** (wardrobes with doors, fridges): Reparent nested
19+ rigid bodies as siblings, add self-collision filters (mirroring MuJoCo's
20+ ``<contact><exclude>`` pairs) , and set up correct articulation structure .
2121
2222Usage:
2323 # Fix single scene USD directory.
3939console_logger = logging .getLogger (__name__ )
4040
4141
42- # --- Helper functions ---
43-
44-
4542def remove_rigid_body_api (prim : Usd .Prim ) -> bool :
4643 """Remove PhysicsRigidBodyAPI from a prim if present."""
4744 if prim .HasAPI (UsdPhysics .RigidBodyAPI ):
@@ -113,7 +110,136 @@ def delete_prims(stage: Usd.Stage, paths: list[Sdf.Path]) -> int:
113110 return count
114111
115112
116- # --- Classification ---
113+ def _reparent_nested_rigid_bodies (
114+ stage : Usd .Stage ,
115+ wrapper_prim : Usd .Prim ,
116+ all_layers : list [Sdf .Layer ],
117+ ) -> None :
118+ """Move nested rigid bodies to be direct children of wrapper.
119+
120+ The MuJoCo→USD converter creates a hierarchy like:
121+ wrapper/E_body/E_door (door is child of body)
122+ But PhysX requires articulation links to be siblings:
123+ wrapper/E_body
124+ wrapper/E_door
125+
126+ We reparent across ALL sublayers (Physics, Geometry, Materials) so
127+ that mesh data, materials, and physics all move together. Joint
128+ relationship targets are updated in the stage's edit target layer.
129+ """
130+ wrapper_path = wrapper_prim .GetPath ()
131+
132+ # Find the root body (first direct child with RigidBodyAPI).
133+ root_body = None
134+ for child in wrapper_prim .GetChildren ():
135+ if child .HasAPI (UsdPhysics .RigidBodyAPI ):
136+ root_body = child
137+ break
138+ if root_body is None :
139+ return
140+
141+ # Collect child rigid bodies that need reparenting.
142+ prims_to_move = []
143+ for child in root_body .GetChildren ():
144+ if child .HasAPI (UsdPhysics .RigidBodyAPI ):
145+ prims_to_move .append (child .GetPath ())
146+
147+ if not prims_to_move :
148+ return
149+
150+ # Build path mapping.
151+ path_mapping : dict [str , str ] = {}
152+ for old_path in prims_to_move :
153+ new_path = wrapper_path .AppendChild (old_path .name )
154+ path_mapping [str (old_path )] = str (new_path )
155+
156+ # Apply reparenting to EVERY layer that contains these prims.
157+ for layer in all_layers :
158+ edit = Sdf .BatchNamespaceEdit ()
159+ has_edits = False
160+ for old_path in prims_to_move :
161+ if layer .GetPrimAtPath (old_path ):
162+ new_path = wrapper_path .AppendChild (old_path .name )
163+ edit .Add (old_path , new_path )
164+ has_edits = True
165+ if has_edits :
166+ if not layer .Apply (edit ):
167+ console_logger .warning (
168+ f"Failed to reparent in layer { layer .identifier } "
169+ )
170+
171+ # Update all relationship targets that referenced the old paths.
172+ # This covers joint body0/body1 targets.
173+ for descendant in Usd .PrimRange (wrapper_prim ):
174+ for rel in descendant .GetRelationships ():
175+ targets = rel .GetTargets ()
176+ new_targets = []
177+ changed = False
178+ for target in targets :
179+ target_str = str (target )
180+ for old_str , new_str in path_mapping .items ():
181+ if target_str == old_str or target_str .startswith (old_str + "/" ):
182+ target_str = new_str + target_str [len (old_str ) :]
183+ changed = True
184+ break
185+ new_targets .append (Sdf .Path (target_str ))
186+ if changed :
187+ rel .SetTargets (new_targets )
188+
189+
190+ def _add_self_collision_filter (
191+ stage : Usd .Stage ,
192+ wrapper_prim : Usd .Prim ,
193+ ) -> None :
194+ """Add self-collision filtering for all rigid bodies in an articulated object.
195+
196+ The MuJoCo source has ``<contact><exclude>`` pairs that prevent adjacent
197+ articulated links from colliding (e.g. wardrobe body vs. its doors).
198+ The mujoco_usd_converter does not convert these (``Tf.Warn("excludes
199+ are not supported")``), so we recreate them using a PhysicsCollisionGroup
200+ that includes all rigid bodies within the object and filters against
201+ itself.
202+
203+ Without this, PhysX detects collisions between overlapping bodies at
204+ hinge points, which prevents joints from moving interactively.
205+ """
206+ # Collect all rigid body prims under the wrapper.
207+ rigid_bodies = []
208+ for descendant in Usd .PrimRange (wrapper_prim ):
209+ if descendant .HasAPI (UsdPhysics .RigidBodyAPI ):
210+ rigid_bodies .append (descendant .GetPath ())
211+
212+ if len (rigid_bodies ) < 2 :
213+ return # No self-collision possible with fewer than 2 bodies.
214+
215+ # Create a PhysicsCollisionGroup under the wrapper.
216+ group_path = wrapper_prim .GetPath ().AppendChild ("selfCollisionFilter" )
217+ group = UsdPhysics .CollisionGroup .Define (stage , group_path )
218+
219+ # Add all rigid bodies to the group via CollectionAPI.
220+ collection = group .GetCollidersCollectionAPI ()
221+ includes_rel = collection .CreateIncludesRel ()
222+ for body_path in rigid_bodies :
223+ includes_rel .AddTarget (body_path )
224+
225+ # Filter the group against itself → disables collision between members.
226+ filtered_rel = group .GetFilteredGroupsRel ()
227+ filtered_rel .AddTarget (group_path )
228+
229+ console_logger .debug (
230+ f" { wrapper_prim .GetPath ().name } : self-collision filter for "
231+ f"{ len (rigid_bodies )} bodies"
232+ )
233+
234+
235+ def _has_nested_rigid_bodies (wrapper_prim : Usd .Prim ) -> bool :
236+ """Check if any child rigid body has a child that is also a rigid body."""
237+ for child in wrapper_prim .GetChildren ():
238+ if child .HasAPI (UsdPhysics .RigidBodyAPI ):
239+ for grandchild in child .GetChildren ():
240+ if grandchild .HasAPI (UsdPhysics .RigidBodyAPI ):
241+ return True
242+ return False
117243
118244
119245def classify_object (
@@ -125,21 +251,23 @@ def classify_object(
125251 Classification logic:
126252 1. Check ArticulationRootAPI first — articulated objects may not have
127253 FixedJoints to root (e.g. when furniture uses freejoints in MuJoCo).
128- 2. Check if wrapper has a FixedJoint descendant with body0 targeting root.
129- 3. If welded and no ArticulationRootAPI -> 'static'.
130- 4. If not welded and no ArticulationRootAPI -> 'dynamic'.
254+ 2. Check for nested rigid bodies — this catches partially-fixed objects
255+ from prior runs where ArticulationRootAPI was already removed but
256+ bodies were not yet reparented as siblings.
257+ 3. Check if wrapper has a FixedJoint descendant with body0 targeting root.
258+ 4. If welded and no ArticulationRootAPI -> 'static'.
259+ 5. If not welded and no ArticulationRootAPI -> 'dynamic'.
131260 """
132261 if wrapper_prim .HasAPI (UsdPhysics .ArticulationRootAPI ):
133262 return "articulated"
263+ if _has_nested_rigid_bodies (wrapper_prim ):
264+ return "articulated"
134265 welded_joints = find_fixed_joints_with_body0 (wrapper_prim , root_path )
135266 if welded_joints :
136267 return "static"
137268 return "dynamic"
138269
139270
140- # --- Fix functions ---
141-
142-
143271def fix_static_object (
144272 stage : Usd .Stage ,
145273 wrapper_prim : Usd .Prim ,
@@ -210,91 +338,92 @@ def fix_articulated_object(
210338 stage : Usd .Stage ,
211339 wrapper_prim : Usd .Prim ,
212340 root_path : Sdf .Path ,
341+ all_layers : list [Sdf .Layer ],
213342) -> None :
214- """Fix an articulated object for Isaac Sim.
215-
216- Moves ArticulationRootAPI from wrapper to E_body (immediate child),
217- sets kinematic mode on E_body, ensures E_body has RigidBodyAPI (joints
218- reference it as body0), removes wrapper physics, and deletes FixedJoints
219- to root and from E_body to wrapper.
343+ """Fix an articulated object for Isaac Sim / PhysX compatibility.
344+
345+ The converter creates two types of articulated objects:
346+
347+ A) **Freejoint** (wardrobes, cabinets): No FixedJoint to scene root.
348+ These should be free-floating — the whole object can be pushed
349+ around. Fix: remove ArticulationRootAPI entirely and delete the
350+ internal FixedJoint. Bodies become regular rigid bodies connected
351+ by joints. This allows Isaac Sim's interactive force tools to
352+ work on each body individually.
353+
354+ B) **Welded** (wall-mounted sconces, built-in cabinets): Has a
355+ FixedJoint to scene root. These should be fixed-base articulations.
356+ Fix: clear body0 on the FixedJoint from wrapper→E_body so it
357+ anchors E_body to the world.
358+
359+ Common fixes for both:
360+ - Remove RigidBodyAPI from the wrapper.
361+ - Delete any FixedJoint from wrapper to the scene root (invalid
362+ because the root Xform has no RigidBodyAPI).
363+ - Move child rigid bodies (doors/drawers) from being nested under
364+ E_body to being direct children of the wrapper (PhysX requires
365+ sibling rigid bodies, not parent-child nesting).
220366 """
221367 wrapper_path = wrapper_prim .GetPath ()
222368
223- # Find E_body: immediate child that has RigidBodyAPI+MassAPI.
224- # Fallback: if APIs were stripped by a prior bad run, find child whose
225- # name ends with _E_body or has revolute/prismatic joint descendants.
226- e_body = None
227- for child in wrapper_prim .GetChildren ():
228- if child .HasAPI (UsdPhysics .RigidBodyAPI ) and child .HasAPI (UsdPhysics .MassAPI ):
229- e_body = child
230- break
231-
232- if e_body is None :
233- # Fallback: look for child with MassAPI only (RigidBodyAPI may have
234- # been stripped).
235- for child in wrapper_prim .GetChildren ():
236- if child .HasAPI (UsdPhysics .MassAPI ):
237- e_body = child
238- break
239-
240- if e_body is None :
241- # Last resort: find child that has joint descendants.
242- for child in wrapper_prim .GetChildren ():
243- for desc in Usd .PrimRange (child ):
244- type_name = desc .GetTypeName ()
245- if type_name in (
246- "PhysicsRevoluteJoint" ,
247- "PhysicsPrismaticJoint" ,
248- ):
249- e_body = child
250- break
251- if e_body is not None :
252- break
253-
254- if e_body is None :
255- console_logger .warning (
256- f"Articulated object { wrapper_path } has no identifiable "
257- "E_body child, skipping."
258- )
259- return
260-
261- # Move ArticulationRootAPI from wrapper to E_body.
262- if wrapper_prim .HasAPI (UsdPhysics .ArticulationRootAPI ):
263- wrapper_prim .RemoveAPI (UsdPhysics .ArticulationRootAPI )
264- UsdPhysics .ArticulationRootAPI .Apply (e_body )
265-
266- # Ensure E_body has RigidBodyAPI. Joints reference it as body0 and PhysX
267- # requires both endpoints to be rigid bodies.
268- if not e_body .HasAPI (UsdPhysics .RigidBodyAPI ):
269- UsdPhysics .RigidBodyAPI .Apply (e_body )
270-
271- # Set kinematic mode on E_body to anchor it in place.
272- kinematic_attr = e_body .GetAttribute ("physics:kinematicEnabled" )
273- if not kinematic_attr :
274- kinematic_attr = e_body .CreateAttribute (
275- "physics:kinematicEnabled" , Sdf .ValueTypeNames .Bool
276- )
277- kinematic_attr .Set (True )
369+ # Determine if this object is welded to world or free-floating.
370+ # Welded objects have a FixedJoint from wrapper to the scene root.
371+ root_joints = find_fixed_joints_with_body0 (wrapper_prim , root_path )
372+ is_welded = len (root_joints ) > 0
278373
279- # Remove RigidBodyAPI and MassAPI from wrapper.
374+ # 1. Remove RigidBodyAPI from the wrapper.
280375 remove_rigid_body_api (wrapper_prim )
281- remove_mass_api (wrapper_prim )
282376
283- # Delete FixedJoint from wrapper to root.
284- root_joints = find_fixed_joints_with_body0 (wrapper_prim , root_path )
377+ # 2. Find the FixedJoint from wrapper→E_body.
378+ wrapper_to_body_joints : list [Sdf .Path ] = []
379+ for descendant in Usd .PrimRange (wrapper_prim ):
380+ if descendant .GetTypeName () == "PhysicsFixedJoint" :
381+ body0_rel = descendant .GetRelationship ("physics:body0" )
382+ if body0_rel :
383+ targets = body0_rel .GetTargets ()
384+ if targets and targets [0 ] == wrapper_path :
385+ wrapper_to_body_joints .append (descendant .GetPath ())
386+
387+ if is_welded :
388+ # Fixed-base articulation: keep ArticulationRootAPI, clear body0
389+ # to world-anchor E_body.
390+ for jp in wrapper_to_body_joints :
391+ joint_prim = stage .GetPrimAtPath (jp )
392+ if joint_prim :
393+ body0_rel = joint_prim .GetRelationship ("physics:body0" )
394+ if body0_rel :
395+ body0_rel .ClearTargets (True )
396+ console_logger .debug (f" { wrapper_path .name } : fixed-base (welded to world)" )
397+ else :
398+ # Free-floating: remove ArticulationRootAPI so bodies are regular
399+ # rigid bodies connected by joints. This allows Isaac Sim's
400+ # interactive force tools to work on each body. Delete the
401+ # internal FixedJoint (not needed without an articulation).
402+ if wrapper_prim .HasAPI (UsdPhysics .ArticulationRootAPI ):
403+ wrapper_prim .RemoveAPI (UsdPhysics .ArticulationRootAPI )
404+ delete_prims (stage , wrapper_to_body_joints )
405+ console_logger .debug (f" { wrapper_path .name } : free-floating (no articulation)" )
406+
407+ # 3. Delete FixedJoint from wrapper to scene root (invalid target).
285408 delete_prims (stage , root_joints )
286409
287- # Delete FixedJoint from E_body to wrapper.
288- wrapper_joints = find_fixed_joints_with_body0 ( wrapper_prim , wrapper_path )
289- delete_prims (stage , wrapper_joints )
410+ # 4. Reparent child rigid bodies from E_body to wrapper so they are
411+ # siblings across ALL layers (Physics, Geometry, Materials).
412+ _reparent_nested_rigid_bodies (stage , wrapper_prim , all_layers )
290413
291-
292- # --- Main entry point ---
414+ # 5. Add self-collision filtering between all rigid bodies within this
415+ # object. Mirrors MuJoCo's <contact><exclude> pairs that prevent
416+ # adjacent links from colliding (e.g. wardrobe body vs. door).
417+ _add_self_collision_filter (stage , wrapper_prim )
293418
294419
295420def fix_physics_layer (physics_usda_path : Path ) -> dict [str , int ]:
296421 """Fix physics in a Physics.usda file for Isaac Sim compatibility.
297422
423+ Opens the composed stage and fixes all objects. For articulated
424+ objects, reparenting is applied across ALL sublayers (Physics,
425+ Geometry, Materials) so mesh data and materials move with the prims.
426+
298427 Args:
299428 physics_usda_path: Path to the Physics.usda file.
300429
@@ -314,6 +443,15 @@ def fix_physics_layer(physics_usda_path: Path) -> dict[str, int]:
314443 if not geometry_prim :
315444 raise RuntimeError (f"No Geometry scope found at { geometry_path } " )
316445
446+ # Collect ALL sublayers in the Payload directory for reparenting.
447+ # The Payload dir contains Physics.usda, Geometry.usda, Materials.usda.
448+ payload_dir = physics_usda_path .parent
449+ all_layers : list [Sdf .Layer ] = []
450+ for usda_file in sorted (payload_dir .glob ("*.usda" )):
451+ layer = Sdf .Layer .FindOrOpen (str (usda_file ))
452+ if layer :
453+ all_layers .append (layer )
454+
317455 counts : dict [str , int ] = {"static" : 0 , "dynamic" : 0 , "articulated" : 0 }
318456
319457 for wrapper_prim in geometry_prim .GetChildren ():
@@ -339,9 +477,14 @@ def fix_physics_layer(physics_usda_path: Path) -> dict[str, int]:
339477 stage = stage ,
340478 wrapper_prim = wrapper_prim ,
341479 root_path = root_path ,
480+ all_layers = all_layers ,
342481 )
343482
483+ # Save ALL modified layers (Physics + Geometry + Materials).
344484 stage .GetRootLayer ().Save ()
485+ for layer in all_layers :
486+ if layer .dirty :
487+ layer .Save ()
345488
346489 console_logger .info (
347490 f"Fixed { physics_usda_path } : "
0 commit comments