Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions source/isaaclab/changelog.d/schemas-kitless-fixed-joint.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Changed
^^^^^^^

* Changed :meth:`~isaaclab.sim.schemas.modify_articulation_root_properties` to author
the fixed-root-link world joint directly with USD on every backend instead of calling
``omni.physx.scripts.utils.createJoint``. This removes the ``omni.physx`` dependency
from the spawn path, which previously raised ``ModuleNotFoundError`` when spawning
fixed-base articulations on kitless backends (e.g. Newton).
30 changes: 5 additions & 25 deletions source/isaaclab/isaaclab/physics/physics_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
from enum import Enum
from typing import TYPE_CHECKING, Any, ClassVar

from pxr import Usd, UsdPhysics

from isaaclab.sim.schemas.schemas import create_world_fixed_joint
from isaaclab.sim.utils import find_global_fixed_joint_prim
from isaaclab.sim.utils.stage import get_current_stage
from isaaclab.utils._device import set_cuda_device

Expand Down Expand Up @@ -119,15 +123,6 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any
Raises:
NotImplementedError: If a new joint is needed and the root is not a rigid body.
"""
# Keep this import local to avoid the SimulationContext -> PhysicsManager ->
# sim.utils.queries -> SimulationContext import cycle.
# Keep pxr local as well: this module is imported while environment configs load (via the
# manager classes), and config loading must not pull USD/omni modules before the simulation
# app starts.
from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415

from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415

if stage is None:
stage = get_current_stage()
root_path = articulation_prim.GetPath().pathString
Expand All @@ -138,17 +133,7 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any
if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI):
raise NotImplementedError(f"Cannot fix non-rigid articulation root '{root_path}'.")

joint_path = f"{root_path}/FixedJoint"
index = 0
while stage.GetPrimAtPath(joint_path).IsValid():
index += 1
joint_path = f"{root_path}/FixedJoint{index}"

world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear()
joint = UsdPhysics.FixedJoint.Define(stage, joint_path)
joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()])
joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation()))
joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat()))
create_world_fixed_joint(articulation_prim, stage)
return articulation_prim

@staticmethod
Expand All @@ -158,11 +143,6 @@ def _relocate_articulation_root(
companion_namespace: str,
) -> Any:
"""Move root-bearing schemas and authored properties to the root link's parent."""
# Keep pxr local: this module is imported while environment configs load (via the manager
# classes), and config loading must not pull USD/omni modules before the simulation app
# starts.
from pxr import Usd, UsdPhysics # noqa: PLC0415

new_root = articulation_prim.GetParent()
if new_root.HasAPI(UsdPhysics.ArticulationRootAPI):
raise RuntimeError(
Expand Down
57 changes: 53 additions & 4 deletions source/isaaclab/isaaclab/sim/schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import numpy as np
import warp as wp

from pxr import Sdf, Usd, UsdGeom, UsdPhysics
from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics

from isaaclab.sim.utils.stage import get_current_stage
from isaaclab.utils.string import string_to_callable, to_camel_case
Expand Down Expand Up @@ -413,6 +413,57 @@ def define_articulation_root_properties(
modify_articulation_root_properties(prim_path, cfg, stage)


def create_world_fixed_joint(articulation_prim: Usd.Prim, stage: Usd.Stage) -> None:
"""Author a ``UsdPhysics.FixedJoint`` fixing an articulation root link to the world frame.

This is a pure-USD equivalent of
``omni.physx.scripts.utils.createJoint(joint_type="Fixed", from_prim=None, to_prim=articulation_prim)``.
Authoring directly with USD keeps the fixed-root-link spawn path backend-agnostic:
it works identically under Kit/PhysX and on kitless backends (e.g. Newton) where
``omni.physx`` is unavailable. Only the single-body (world-attached) case is handled,
matching the fixed-root-link spawn path.

Args:
articulation_prim: The articulation root link prim to fix to the world.
stage: The stage that owns the prim.
"""
# ``MAX_FLOAT`` used by ``omni.physx.createJoint`` for an effectively unbreakable joint.
max_break = 3.40282347e38

to_path = articulation_prim.GetPath().pathString

# Instanceable/prototype/instance-proxy prims are not authorable; walk up to the first
# writable ancestor so the joint can be defined there (mirrors ``omni.physx.createJoint``).
base_prim = articulation_prim
pseudo_root = stage.GetPseudoRoot()
while base_prim != pseudo_root and (
base_prim.IsInPrototype() or base_prim.IsInstanceProxy() or base_prim.IsInstanceable()
):
base_prim = base_prim.GetParent()
joint_base_path = str(base_prim.GetPrimPath())
if joint_base_path == "/":
joint_base_path = ""

# Find a unique joint name under the writable base (mirrors ``create_unused_path``).
joint_name = "FixedJoint"
if stage.GetPrimAtPath(f"{joint_base_path}/{joint_name}").IsValid():
uniquifier = 0
while stage.GetPrimAtPath(f"{joint_base_path}/{joint_name}{uniquifier}").IsValid():
uniquifier += 1
joint_name = f"{joint_name}{uniquifier}"
joint = UsdPhysics.FixedJoint.Define(stage, f"{joint_base_path}/{joint_name}")

# Anchor the joint at the root link's world pose (body0 = world, body1 = root link).
world_pose = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear()
joint.CreateBody1Rel().SetTargets([Sdf.Path(to_path)])
joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_pose.ExtractTranslation()))
joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_pose.ExtractRotationQuat()))
joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0))
joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0))
joint.CreateBreakForceAttr().Set(max_break)
joint.CreateBreakTorqueAttr().Set(max_break)


@apply_nested
def modify_articulation_root_properties(
prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None
Expand Down Expand Up @@ -501,9 +552,7 @@ def modify_articulation_root_properties(
)

# create a fixed joint between the root link and the world frame
from omni.physx.scripts import utils as physx_utils

physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim)
create_world_fixed_joint(articulation_prim, stage)

# Having a fixed joint on a rigid body is not treated as "fixed base articulation".
# instead, it is treated as a part of the maximal coordinate tree.
Expand Down
83 changes: 83 additions & 0 deletions source/isaaclab/test/sim/test_schemas_kitless_fixed_joint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

"""Regression tests for authoring the world fixed joint with pure USD.

These tests run on an in-memory USD stage and intentionally do NOT launch Isaac Sim /
Kit. :func:`create_world_fixed_joint` is the single fixed-root-link authoring path for
every backend, so passing here also covers kitless backends (e.g. Newton) where
``omni.physx`` is unavailable.
"""

import math

from pxr import Gf, Usd, UsdGeom, UsdPhysics

from isaaclab.sim.schemas.schemas import create_world_fixed_joint


def _make_root_prim(stage: Usd.Stage, path: str, translation: tuple[float, float, float]) -> Usd.Prim:
"""Create an xform articulation-root prim with a rigid body and a world translation."""
xform = UsdGeom.Xform.Define(stage, path)
xform.AddTranslateOp().Set(Gf.Vec3d(*translation))
prim = xform.GetPrim()
UsdPhysics.ArticulationRootAPI.Apply(prim)
UsdPhysics.RigidBodyAPI.Apply(prim)
return prim


def _find_fixed_joint(stage: Usd.Stage) -> UsdPhysics.FixedJoint | None:
"""Return the first ``UsdPhysics.FixedJoint`` authored on the stage."""
for prim in stage.Traverse():
if prim.IsA(UsdPhysics.FixedJoint):
return UsdPhysics.FixedJoint(prim)
return None


def test_create_world_fixed_joint_authors_world_anchored_joint():
"""Authoring must materialize a ``UsdPhysics.FixedJoint`` anchored to the world.

A world-attached fixed joint targets only ``body1`` (the root link), leaving ``body0``
empty, and anchors ``localPos0`` at the root link's world translation.
"""
stage = Usd.Stage.CreateInMemory()
prim = _make_root_prim(stage, "/World/Robot", translation=(1.0, 2.0, 3.0))

create_world_fixed_joint(prim, stage)

joint = _find_fixed_joint(stage)
assert joint is not None, "no UsdPhysics.FixedJoint was authored"
# world-attached joint: body0 empty, body1 -> root link
assert joint.GetBody0Rel().GetTargets() == []
assert joint.GetBody1Rel().GetTargets() == [prim.GetPath()]
# localPos0 anchors the joint at the root link's world pose
local_pos0 = joint.GetLocalPos0Attr().Get()
assert math.isclose(local_pos0[0], 1.0) and math.isclose(local_pos0[1], 2.0) and math.isclose(local_pos0[2], 3.0)
# the joint must be effectively unbreakable
assert joint.GetBreakForceAttr().Get() > 1e37
assert joint.GetBreakTorqueAttr().Get() > 1e37
Comment on lines +40 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 localRot0 code path is untested

Both test functions use a prim with a zero or identity rotation, so joint.GetLocalRot0Attr().Get() is always the identity quaternion and the Gf.Quatf(world_pose.ExtractRotationQuat()) branch is never exercised. If the rotation-extraction logic regresses (e.g. wrong matrix decomposition order, inadvertent RemoveScaleShear stripping the rotation for scaled parents), no test would catch it. Adding a third test that translates and rotates the prim, then asserts the authored localRot0 matches the expected quaternion, would close this gap.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!



def test_create_world_fixed_joint_skips_instanceable_root():
"""The joint must be authored on a writable ancestor when the root prim is instanceable.

Instanceable/instance-proxy/prototype prims are not authorable, so the joint prim must
be created under the first writable ancestor rather than under the instanceable root.
"""
stage = Usd.Stage.CreateInMemory()
UsdGeom.Xform.Define(stage, "/World")
prim = _make_root_prim(stage, "/World/Robot", translation=(0.0, 0.0, 0.0))
prim.SetInstanceable(True)
assert prim.IsInstanceable()

create_world_fixed_joint(prim, stage)

joint = _find_fixed_joint(stage)
assert joint is not None, "no UsdPhysics.FixedJoint was authored"
# the joint prim must not be parented under the instanceable root
joint_path = joint.GetPrim().GetPath().pathString
assert not joint_path.startswith("/World/Robot/"), f"joint authored under instanceable root: {joint_path}"
# it still targets the root link
assert joint.GetBody1Rel().GetTargets() == [prim.GetPath()]
Comment on lines +63 to +83

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Uniquifier logic not tested

The name-deduplication loop inside _create_world_fixed_joint (FixedJointFixedJoint0 → …) is never exercised. A test that calls _create_world_fixed_joint twice on the same parent prim and asserts that two distinct joint prims exist with unique paths would cover this branch and protect against off-by-one regressions in the uniquifier counter.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Loading