Skip to content

Commit a95939c

Browse files
committed
Convert links and joints: Implemented planar joint
1 parent 994eeb2 commit a95939c

File tree

3 files changed

+251
-18
lines changed

3 files changed

+251
-18
lines changed

tests/testConverterJoints.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -156,31 +156,33 @@ def test_fixed_prismatic_joints(self):
156156
self.assertTrue(Gf.IsClose(prismatic_joint.GetLowerLimitAttr().Get(), 0, self.tolerance))
157157
self.assertTrue(Gf.IsClose(prismatic_joint.GetUpperLimitAttr().Get(), 0.5, self.tolerance))
158158

159-
@patch("urdf_usd_converter._impl.link.Tf.Warn")
160-
def test_fixed_floating_joints(self, mock_warn):
161-
input_path = "tests/data/simple_fixed_floating_joints.urdf"
159+
def test_fixed_planar_joints(self):
160+
input_path = "tests/data/simple_fixed_planar_joints.urdf"
162161
output_dir = self.tmpDir()
163162

164163
converter = Converter()
165164
asset_path = converter.convert(input_path, output_dir)
166165
self.assertIsNotNone(asset_path)
167166
self.assertTrue(pathlib.Path(asset_path.path).exists())
168167

169-
# Verify that Tf.Warn was called with the expected message
170-
mock_warn.assert_called()
168+
stage: Usd.Stage = Usd.Stage.Open(asset_path.path)
169+
self.assertIsValidUsd(stage)
171170

172-
# Check if any call contains the floating joints warning
173-
warning_found = False
174-
for call in mock_warn.call_args_list:
175-
if "Floating joints are not supported" in str(call):
176-
warning_found = True
177-
break
171+
physics_scene_prim = stage.GetPrimAtPath("/PhysicsScene")
172+
self.assertIsNotNone(physics_scene_prim)
178173

179-
self.assertTrue(warning_found, "Expected warning about floating joints not found.")
174+
default_prim = stage.GetDefaultPrim()
175+
self.assertIsNotNone(default_prim)
176+
default_prim_path = default_prim.GetPath()
177+
178+
physics_scope_prim = stage.GetPrimAtPath(default_prim_path.AppendChild("Physics"))
179+
self.assertIsNotNone(physics_scope_prim)
180+
181+
# TODO: Implement test for fixed planar joints.
180182

181183
@patch("urdf_usd_converter._impl.link.Tf.Warn")
182-
def test_fixed_planar_joints(self, mock_warn):
183-
input_path = "tests/data/simple_fixed_planar_joints.urdf"
184+
def test_fixed_floating_joints(self, mock_warn):
185+
input_path = "tests/data/simple_fixed_floating_joints.urdf"
184186
output_dir = self.tmpDir()
185187

186188
converter = Converter()
@@ -194,8 +196,8 @@ def test_fixed_planar_joints(self, mock_warn):
194196
# Check if any call contains the floating joints warning
195197
warning_found = False
196198
for call in mock_warn.call_args_list:
197-
if "Planar joints are not yet implemented" in str(call):
199+
if "Floating joints are not supported" in str(call):
198200
warning_found = True
199201
break
200202

201-
self.assertTrue(warning_found, "Expected warning about planar joints not found.")
203+
self.assertTrue(warning_found, "Expected warning about floating joints not found.")

urdf_usd_converter/_impl/link.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from .data import ConversionData, Tokens
1010
from .geometry import convert_geometry
11+
from .planar_joint import define_physics_planar_joint
1112
from .urdf_parser.elements import (
1213
ElementCollision,
1314
ElementInertia,
@@ -284,8 +285,7 @@ def physics_joints(parent: Usd.Prim, link_hierarchy: LinkHierarchy, link: Elemen
284285
elif joint.type == "floating":
285286
Tf.Warn("Floating joints are not supported.")
286287
elif joint.type == "planar":
287-
# TODO: Implement planar joints.
288-
Tf.Warn("Planar joints are not yet implemented.")
288+
physics_joint = define_physics_planar_joint(parent, joint_safe_name, body0, body1, joint_frame, axis)
289289

290290
if physics_joint:
291291
if joint.name != joint_safe_name:
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import math
4+
5+
import numpy as np
6+
import usdex.core
7+
from pxr import Gf, Sdf, Tf, Usd, UsdGeom, UsdPhysics
8+
9+
__all__ = ["define_physics_planar_joint"]
10+
11+
12+
def define_physics_planar_joint(
13+
parent: Usd.Prim, name: str, body0: Usd.Prim, body1: Usd.Prim, joint_frame: usdex.core.JointFrame, axis: Gf.Vec3f
14+
) -> UsdPhysics.Joint:
15+
"""
16+
Defines functionality equivalent to URDF Planar Joint.
17+
"""
18+
stage = parent.GetStage()
19+
path = parent.GetPath().AppendChild(name)
20+
21+
joint = UsdPhysics.Joint.Define(stage, path)
22+
if not joint:
23+
Tf.Error(f'Unable to define UsdPhysics.Joint at "{path.GetAsString()}"')
24+
return None
25+
26+
prim = joint.GetPrim()
27+
prim.SetSpecifier(Sdf.SpecifierDef)
28+
prim.SetTypeName(prim.GetTypeName())
29+
30+
if body0 and not joint.GetBody0Rel().SetTargets([body0.GetPath()]):
31+
Tf.Error(f'Unable to set body0( "{body0.GetPath().GetAsString()}" ) for PhysicsPlanarJoint at "{path.GetAsString()}"')
32+
return None
33+
34+
if body1 and not joint.GetBody1Rel().SetTargets([body1.GetPath()]):
35+
Tf.Error(f'Unable to set body1( "{body1.GetPath().GetAsString()}" ) for PhysicsPlanarJoint at "{path.GetAsString()}"')
36+
return None
37+
38+
_orientation = joint_frame.orientation
39+
40+
# Get the axis alignment and orientation for the given axis.
41+
axis_token, _orientation = _get_axis_alignment(axis)
42+
43+
if axis_token == UsdPhysics.Tokens.x:
44+
# Constrain in the X-axis direction.
45+
limit_api_x = UsdPhysics.LimitAPI.Apply(joint.GetPrim(), UsdPhysics.Tokens.transX)
46+
limit_api_x.GetLowAttr().Set(0.0)
47+
limit_api_x.GetHighAttr().Set(0.0)
48+
49+
# Rotation is only permitted around the X axis (Constrain rotation on the Y and Z axes).
50+
limit_api_rotation_y = UsdPhysics.LimitAPI.Apply(joint.GetPrim(), UsdPhysics.Tokens.rotY)
51+
limit_api_rotation_y.GetLowAttr().Set(0.0)
52+
limit_api_rotation_y.GetHighAttr().Set(0.0)
53+
limit_api_rotation_z = UsdPhysics.LimitAPI.Apply(joint.GetPrim(), UsdPhysics.Tokens.rotZ)
54+
limit_api_rotation_z.GetLowAttr().Set(0.0)
55+
limit_api_rotation_z.GetHighAttr().Set(0.0)
56+
elif axis_token == UsdPhysics.Tokens.y:
57+
# Constrain in the Y-axis direction.
58+
limit_api_y = UsdPhysics.LimitAPI.Apply(joint.GetPrim(), UsdPhysics.Tokens.transY)
59+
limit_api_y.GetLowAttr().Set(0.0)
60+
limit_api_y.GetHighAttr().Set(0.0)
61+
62+
# Rotation is only permitted around the Y axis (Constrain rotation on the X and Z axes).
63+
limit_api_rotation_x = UsdPhysics.LimitAPI.Apply(joint.GetPrim(), UsdPhysics.Tokens.rotX)
64+
limit_api_rotation_x.GetLowAttr().Set(0.0)
65+
limit_api_rotation_x.GetHighAttr().Set(0.0)
66+
limit_api_rotation_z = UsdPhysics.LimitAPI.Apply(joint.GetPrim(), UsdPhysics.Tokens.rotZ)
67+
limit_api_rotation_z.GetLowAttr().Set(0.0)
68+
limit_api_rotation_z.GetHighAttr().Set(0.0)
69+
elif axis_token == UsdPhysics.Tokens.z:
70+
# Constrain in the Z-axis direction.
71+
limit_api_z = UsdPhysics.LimitAPI.Apply(joint.GetPrim(), UsdPhysics.Tokens.transZ)
72+
limit_api_z.GetLowAttr().Set(0.0)
73+
limit_api_z.GetHighAttr().Set(0.0)
74+
75+
# Rotation is only permitted around the Z axis (Constrain rotation on the X and Y axes).
76+
limit_api_rotation_x = UsdPhysics.LimitAPI.Apply(joint.GetPrim(), UsdPhysics.Tokens.rotX)
77+
limit_api_rotation_x.GetLowAttr().Set(0.0)
78+
limit_api_rotation_x.GetHighAttr().Set(0.0)
79+
limit_api_rotation_y = UsdPhysics.LimitAPI.Apply(joint.GetPrim(), UsdPhysics.Tokens.rotY)
80+
limit_api_rotation_y.GetLowAttr().Set(0.0)
81+
limit_api_rotation_y.GetHighAttr().Set(0.0)
82+
83+
# Get the local to world coordinate transformation matrix for body0 and body1.
84+
xform_cache = UsdGeom.XformCache()
85+
body0_transform = xform_cache.GetLocalToWorldTransform(body0) if body0 else Gf.Matrix4d(1.0)
86+
body1_transform = xform_cache.GetLocalToWorldTransform(body1) if body1 else Gf.Matrix4d(1.0)
87+
88+
if body0:
89+
# Compute the local position and rotation of body0.
90+
local_pos, local_rot = _compute_local_transform(
91+
body0_transform, body1_transform, usdex.core.JointFrame.Space.Body0, joint_frame.space, joint_frame.position, _orientation
92+
)
93+
joint.GetLocalPos0Attr().Set(Gf.Vec3f(local_pos))
94+
joint.GetLocalRot0Attr().Set(Gf.Quatf(local_rot))
95+
96+
if body1:
97+
# Compute the local position and rotation of body1.
98+
local_pos, local_rot = _compute_local_transform(
99+
body1_transform, body0_transform, usdex.core.JointFrame.Space.Body1, joint_frame.space, joint_frame.position, _orientation
100+
)
101+
joint.GetLocalPos1Attr().Set(Gf.Vec3f(local_pos))
102+
joint.GetLocalRot1Attr().Set(Gf.Quatf(local_rot))
103+
104+
return joint
105+
106+
107+
def _align_vector_to_x_axis(axis: Gf.Vec3f) -> Gf.Quatd:
108+
"""
109+
Calculates the rotation of a vector along the x-axis.
110+
"""
111+
epsilon = np.finfo(np.float32).eps
112+
113+
if axis.GetLength() < epsilon:
114+
return Gf.Quatd.GetIdentity()
115+
116+
# If the vector is already aligned with the X-axis or directly opposite
117+
# Handle these edge cases to prevent division by zero or incorrect axis.
118+
if abs(axis[0] - 1.0) < epsilon:
119+
# When axis is (1, 0, 0).
120+
return Gf.Quatd.GetIdentity()
121+
elif abs(axis[0] + 1.0) < epsilon:
122+
# When axis is (-1, 0, 0).
123+
# If aligned with negative X-axis, rotate 180 degrees around Y-axis (or Z-axis)
124+
return Gf.Quatd(0.0, 0.0, 1.0, 0.0) # Quaternion for 180 deg around Y-axis (w=0, x=0, y=sin(90), z=0)
125+
126+
# Calculate the rotation axis (cross product of XAxis and axis)
127+
rotation_axis = Gf.Cross(Gf.Vec3f(1.0, 0.0, 0.0), axis)
128+
rotation_axis_norm = rotation_axis.GetNormalized()
129+
if rotation_axis_norm.GetLength() < epsilon:
130+
return Gf.Quatd.GetIdentity()
131+
132+
# Calculate the angle (dot product of axis and XAxis)
133+
dot_product = Gf.Dot(axis, Gf.Vec3f.XAxis())
134+
135+
# Clip to avoid floating point errors
136+
angle = math.acos(min(max(dot_product, -1.0), 1.0))
137+
138+
# Construct the quaternion (wxyz order)
139+
w = math.cos(angle / 2.0)
140+
x = rotation_axis_norm[0] * math.sin(angle / 2.0)
141+
y = rotation_axis_norm[1] * math.sin(angle / 2.0)
142+
z = rotation_axis_norm[2] * math.sin(angle / 2.0)
143+
144+
return Gf.Quatd(w, x, y, z)
145+
146+
147+
# void getAxisAlignment(const GfVec3f& axis, TfToken& axisToken, GfQuatd& orientation)
148+
def _get_axis_alignment(axis: Gf.Vec3f) -> tuple[str, Gf.Quatd]:
149+
"""
150+
Get the axis alignment and orientation for the given axis.
151+
"""
152+
epsilon = np.finfo(np.float32).eps
153+
_axis = axis.GetNormalized()
154+
axis_token = UsdPhysics.Tokens.x
155+
orientation = Gf.Quatd.GetIdentity()
156+
157+
if _axis.GetLength() < epsilon:
158+
return axis_token, orientation
159+
160+
if abs(_axis[0] - 1.0) < epsilon:
161+
# When _axis is (1, 0, 0).
162+
axis_token = UsdPhysics.Tokens.x
163+
elif abs(_axis[1] - 1.0) < epsilon:
164+
# When _axis is (0, 1, 0).
165+
axis_token = UsdPhysics.Tokens.y
166+
elif abs(_axis[2] - 1.0) < epsilon:
167+
# When _axis is (0, 0, 1).
168+
axis_token = UsdPhysics.Tokens.z
169+
elif abs(_axis[0] + 1.0) < epsilon:
170+
# When _axis is (-1, 0, 0).
171+
axis_token = UsdPhysics.Tokens.x
172+
axis_to_x = Gf.Quatd(_axis[1], _axis[2], _axis[0], 0.0)
173+
orientation = orientation * axis_to_x
174+
elif abs(_axis[1] + 1.0) < epsilon:
175+
# When _axis is (0, -1, 0).
176+
axis_token = UsdPhysics.Tokens.y
177+
axis_to_y = Gf.Quatd(_axis[0], _axis[1], _axis[2], 0.0)
178+
orientation = orientation * axis_to_y
179+
elif abs(_axis[2] + 1.0) < epsilon:
180+
# When _axis is (0, 0, -1).
181+
axis_token = UsdPhysics.Tokens.z
182+
axis_to_z = Gf.Quatd(_axis[1], _axis[2], _axis[0], 0.0)
183+
orientation = orientation * axis_to_z
184+
else:
185+
# If neither XYZ applies, rotation is performed around _axis.
186+
axis_token = UsdPhysics.Tokens.x
187+
rotation = _align_vector_to_x_axis(_axis)
188+
orientation = orientation * rotation
189+
190+
return axis_token, orientation
191+
192+
193+
def _compute_local_transform(
194+
target_body_transform: Gf.Matrix4d,
195+
other_body_transform: Gf.Matrix4d,
196+
target_space: usdex.core.JointFrame.Space,
197+
frame_space: usdex.core.JointFrame.Space,
198+
position: Gf.Vec3d,
199+
orientation: Gf.Quatd,
200+
) -> tuple[Gf.Vec3d, Gf.Quatd]:
201+
"""
202+
Compute the local transform of the joint.
203+
This function calculates the local position and rotation (orientation) of body0 and body1, which are the parameters of the physics joint.
204+
Transforms the 'position' and 'orientation' given in the coordinate system of 'frameSpace' into local coordinates of 'targetSpace'
205+
(body0 or body1).
206+
"""
207+
world_pos = position
208+
world_rot = orientation
209+
210+
# If the transformation on body0 is for frameSpace = body0, it will be returned as local coordinates.
211+
# If the transformation on body1 is for frameSpace = body1, it will be returned as local coordinates.
212+
if (frame_space == usdex.core.JointFrame.Space.Body0 and target_space == usdex.core.JointFrame.Space.Body0) or (
213+
frame_space == usdex.core.JointFrame.Space.Body1 and target_space == usdex.core.JointFrame.Space.Body1
214+
):
215+
return position, orientation
216+
217+
# When transforming on body1, if frameSpace is body0, convert position and rotation to world coordinates.
218+
elif (frame_space == usdex.core.JointFrame.Space.Body0 and target_space == usdex.core.JointFrame.Space.Body1) or (
219+
frame_space == usdex.core.JointFrame.Space.Body1 and target_space == usdex.core.JointFrame.Space.Body0
220+
):
221+
world_pos = other_body_transform.Transform(position)
222+
world_rot = other_body_transform.RemoveScaleShear().ExtractRotation().GetQuat() * orientation
223+
# Otherwise, worldPos and worldRot contain the position and rotation in world coordinates, respectively.
224+
225+
# The world transformation matrix for body0 or body1 is in 'targetBodyTransform'.
226+
# This matrix is used to convert to local coordinate position and rotation by multiplying with the inverse matrix.
227+
# USD physics does not allow unequal scales and shear components to be introduced in joint localRot.
228+
# Therefore, we first remove the scale and shear from the matrix.
229+
local_pos = target_body_transform.GetInverse().Transform(Gf.Vec3d(world_pos))
230+
local_rot = target_body_transform.RemoveScaleShear().ExtractRotation().GetInverse().GetQuat() * world_rot
231+
return local_pos, local_rot

0 commit comments

Comments
 (0)