Skip to content

Commit 0a07a07

Browse files
committed
Improve articulated support in fix_usd_isaac_sim.py
1 parent f2ddd14 commit 0a07a07

1 file changed

Lines changed: 226 additions & 83 deletions

File tree

scripts/fix_usd_isaac_sim.py

Lines changed: 226 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
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
2222
Usage:
2323
# Fix single scene USD directory.
@@ -39,9 +39,6 @@
3939
console_logger = logging.getLogger(__name__)
4040

4141

42-
# --- Helper functions ---
43-
44-
4542
def 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

119245
def 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-
143271
def 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

295420
def 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

Comments
 (0)