Skip to content

Commit e06a067

Browse files
Mayankm96Copilotkellyguo11
authored
Adds functions to obtain prim pose and scale from USD Xformable (#3371)
# Description This MR adds two functions to obtain the pose and scale of a prim respectively. This is needed for #3298. ## Type of change - New feature (non-breaking change which adds functionality) - This change requires a documentation update ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Mayank Mittal <12863862+Mayankm96@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Kelly Guo <kellyg@nvidia.com>
1 parent c346ac8 commit e06a067

File tree

4 files changed

+261
-1
lines changed

4 files changed

+261
-1
lines changed

source/isaaclab/config/extension.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22

33
# Note: Semantic Versioning is used: https://semver.org/
4-
version = "0.47.0"
4+
version = "0.47.1"
55

66
# Description
77
title = "Isaac Lab framework for Robot Learning"

source/isaaclab/docs/CHANGELOG.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
Changelog
22
---------
33

4+
0.47.1 (2025-10-17)
5+
~~~~~~~~~~~~~~~~~~~
6+
7+
Added
8+
^^^^^
9+
10+
* Added :meth:`~isaaclab.sim.utils.resolve_prim_pose` to resolve the pose of a prim with respect to another prim.
11+
* Added :meth:`~isaaclab.sim.utils.resolve_prim_scale` to resolve the scale of a prim in the world frame.
12+
13+
414
0.47.0 (2025-10-14)
515
~~~~~~~~~~~~~~~~~~~
616

source/isaaclab/isaaclab/sim/utils.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,92 @@ def make_uninstanceable(prim_path: str | Sdf.Path, stage: Usd.Stage | None = Non
571571
all_prims += child_prim.GetFilteredChildren(Usd.TraverseInstanceProxies())
572572

573573

574+
def resolve_prim_pose(
575+
prim: Usd.Prim, ref_prim: Usd.Prim | None = None
576+
) -> tuple[tuple[float, float, float], tuple[float, float, float, float]]:
577+
"""Resolve the pose of a prim with respect to another prim.
578+
579+
Note:
580+
This function ignores scale and skew by orthonormalizing the transformation
581+
matrix at the final step. However, if any ancestor prim in the hierarchy
582+
has non-uniform scale, that scale will still affect the resulting position
583+
and orientation of the prim (because it's baked into the transform before
584+
scale removal).
585+
586+
In other words: scale **is not removed hierarchically**. If you need
587+
completely scale-free poses, you must walk the transform chain and strip
588+
scale at each level. Please open an issue if you need this functionality.
589+
590+
Args:
591+
prim: The USD prim to resolve the pose for.
592+
ref_prim: The USD prim to compute the pose with respect to.
593+
Defaults to None, in which case the world frame is used.
594+
595+
Returns:
596+
A tuple containing the position (as a 3D vector) and the quaternion orientation
597+
in the (w, x, y, z) format.
598+
599+
Raises:
600+
ValueError: If the prim or ref prim is not valid.
601+
"""
602+
# check if prim is valid
603+
if not prim.IsValid():
604+
raise ValueError(f"Prim at path '{prim.GetPath().pathString}' is not valid.")
605+
# get prim xform
606+
xform = UsdGeom.Xformable(prim)
607+
prim_tf = xform.ComputeLocalToWorldTransform(Usd.TimeCode.Default())
608+
# sanitize quaternion
609+
# this is needed, otherwise the quaternion might be non-normalized
610+
prim_tf = prim_tf.GetOrthonormalized()
611+
612+
if ref_prim is not None:
613+
# check if ref prim is valid
614+
if not ref_prim.IsValid():
615+
raise ValueError(f"Ref prim at path '{ref_prim.GetPath().pathString}' is not valid.")
616+
# get ref prim xform
617+
ref_xform = UsdGeom.Xformable(ref_prim)
618+
ref_tf = ref_xform.ComputeLocalToWorldTransform(Usd.TimeCode.Default())
619+
# make sure ref tf is orthonormal
620+
ref_tf = ref_tf.GetOrthonormalized()
621+
# compute relative transform to get prim in ref frame
622+
prim_tf = prim_tf * ref_tf.GetInverse()
623+
624+
# extract position and orientation
625+
prim_pos = [*prim_tf.ExtractTranslation()]
626+
prim_quat = [prim_tf.ExtractRotationQuat().real, *prim_tf.ExtractRotationQuat().imaginary]
627+
return tuple(prim_pos), tuple(prim_quat)
628+
629+
630+
def resolve_prim_scale(prim: Usd.Prim) -> tuple[float, float, float]:
631+
"""Resolve the scale of a prim in the world frame.
632+
633+
At an attribute level, a USD prim's scale is a scaling transformation applied to the prim with
634+
respect to its parent prim. This function resolves the scale of the prim in the world frame,
635+
by computing the local to world transform of the prim. This is equivalent to traversing up
636+
the prim hierarchy and accounting for the rotations and scales of the prims.
637+
638+
For instance, if a prim has a scale of (1, 2, 3) and it is a child of a prim with a scale of (4, 5, 6),
639+
then the scale of the prim in the world frame is (4, 10, 18).
640+
641+
Args:
642+
prim: The USD prim to resolve the scale for.
643+
644+
Returns:
645+
The scale of the prim in the x, y, and z directions in the world frame.
646+
647+
Raises:
648+
ValueError: If the prim is not valid.
649+
"""
650+
# check if prim is valid
651+
if not prim.IsValid():
652+
raise ValueError(f"Prim at path '{prim.GetPath().pathString}' is not valid.")
653+
# compute local to world transform
654+
xform = UsdGeom.Xformable(prim)
655+
world_transform = xform.ComputeLocalToWorldTransform(Usd.TimeCode.Default())
656+
# extract scale
657+
return tuple([*(v.GetLength() for v in world_transform.ExtractRotationMatrix())])
658+
659+
574660
"""
575661
USD Stage traversal.
576662
"""

source/isaaclab/test/sim/test_utils.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
"""Rest everything follows."""
1414

1515
import numpy as np
16+
import torch
1617

1718
import isaacsim.core.utils.prims as prim_utils
1819
import isaacsim.core.utils.stage as stage_utils
1920
import pytest
2021
from pxr import Sdf, Usd, UsdGeom, UsdPhysics
2122

2223
import isaaclab.sim as sim_utils
24+
import isaaclab.utils.math as math_utils
2325
from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR
2426

2527

@@ -175,3 +177,165 @@ def test_select_usd_variants():
175177

176178
# Check if the variant selection is correct
177179
assert variant_set.GetVariantSelection() == "red"
180+
181+
182+
def test_resolve_prim_pose():
183+
"""Test resolve_prim_pose() function."""
184+
# number of objects
185+
num_objects = 20
186+
# sample random scales for x, y, z
187+
rand_scales = np.random.uniform(0.5, 1.5, size=(num_objects, 3, 3))
188+
rand_widths = np.random.uniform(0.1, 10.0, size=(num_objects,))
189+
# sample random positions
190+
rand_positions = np.random.uniform(-100, 100, size=(num_objects, 3, 3))
191+
# sample random rotations
192+
rand_quats = np.random.randn(num_objects, 3, 4)
193+
rand_quats /= np.linalg.norm(rand_quats, axis=2, keepdims=True)
194+
195+
# create objects
196+
for i in range(num_objects):
197+
# simple cubes
198+
cube_prim = prim_utils.create_prim(
199+
f"/World/Cubes/instance_{i:02d}",
200+
"Cube",
201+
translation=rand_positions[i, 0],
202+
orientation=rand_quats[i, 0],
203+
scale=rand_scales[i, 0],
204+
attributes={"size": rand_widths[i]},
205+
)
206+
# xform hierarchy
207+
xform_prim = prim_utils.create_prim(
208+
f"/World/Xform/instance_{i:02d}",
209+
"Xform",
210+
translation=rand_positions[i, 1],
211+
orientation=rand_quats[i, 1],
212+
scale=rand_scales[i, 1],
213+
)
214+
geometry_prim = prim_utils.create_prim(
215+
f"/World/Xform/instance_{i:02d}/geometry",
216+
"Sphere",
217+
translation=rand_positions[i, 2],
218+
orientation=rand_quats[i, 2],
219+
scale=rand_scales[i, 2],
220+
attributes={"radius": rand_widths[i]},
221+
)
222+
dummy_prim = prim_utils.create_prim(
223+
f"/World/Xform/instance_{i:02d}/dummy",
224+
"Sphere",
225+
)
226+
227+
# cube prim w.r.t. world frame
228+
pos, quat = sim_utils.resolve_prim_pose(cube_prim)
229+
pos, quat = np.array(pos), np.array(quat)
230+
quat = quat if np.sign(rand_quats[i, 0, 0]) == np.sign(quat[0]) else -quat
231+
np.testing.assert_allclose(pos, rand_positions[i, 0], atol=1e-3)
232+
np.testing.assert_allclose(quat, rand_quats[i, 0], atol=1e-3)
233+
# xform prim w.r.t. world frame
234+
pos, quat = sim_utils.resolve_prim_pose(xform_prim)
235+
pos, quat = np.array(pos), np.array(quat)
236+
quat = quat if np.sign(rand_quats[i, 1, 0]) == np.sign(quat[0]) else -quat
237+
np.testing.assert_allclose(pos, rand_positions[i, 1], atol=1e-3)
238+
np.testing.assert_allclose(quat, rand_quats[i, 1], atol=1e-3)
239+
# dummy prim w.r.t. world frame
240+
pos, quat = sim_utils.resolve_prim_pose(dummy_prim)
241+
pos, quat = np.array(pos), np.array(quat)
242+
quat = quat if np.sign(rand_quats[i, 1, 0]) == np.sign(quat[0]) else -quat
243+
np.testing.assert_allclose(pos, rand_positions[i, 1], atol=1e-3)
244+
np.testing.assert_allclose(quat, rand_quats[i, 1], atol=1e-3)
245+
246+
# geometry prim w.r.t. xform prim
247+
pos, quat = sim_utils.resolve_prim_pose(geometry_prim, ref_prim=xform_prim)
248+
pos, quat = np.array(pos), np.array(quat)
249+
quat = quat if np.sign(rand_quats[i, 2, 0]) == np.sign(quat[0]) else -quat
250+
np.testing.assert_allclose(pos, rand_positions[i, 2] * rand_scales[i, 1], atol=1e-3)
251+
# TODO: Enabling scale causes the test to fail because the current implementation of
252+
# resolve_prim_pose does not correctly handle non-identity scales on Xform prims. This is a known
253+
# limitation. Until this is fixed, the test is disabled here to ensure the test passes.
254+
np.testing.assert_allclose(quat, rand_quats[i, 2], atol=1e-3)
255+
256+
# dummy prim w.r.t. xform prim
257+
pos, quat = sim_utils.resolve_prim_pose(dummy_prim, ref_prim=xform_prim)
258+
pos, quat = np.array(pos), np.array(quat)
259+
np.testing.assert_allclose(pos, np.zeros(3), atol=1e-3)
260+
np.testing.assert_allclose(quat, np.array([1, 0, 0, 0]), atol=1e-3)
261+
# xform prim w.r.t. cube prim
262+
pos, quat = sim_utils.resolve_prim_pose(xform_prim, ref_prim=cube_prim)
263+
pos, quat = np.array(pos), np.array(quat)
264+
# -- compute ground truth values
265+
gt_pos, gt_quat = math_utils.subtract_frame_transforms(
266+
torch.from_numpy(rand_positions[i, 0]).unsqueeze(0),
267+
torch.from_numpy(rand_quats[i, 0]).unsqueeze(0),
268+
torch.from_numpy(rand_positions[i, 1]).unsqueeze(0),
269+
torch.from_numpy(rand_quats[i, 1]).unsqueeze(0),
270+
)
271+
gt_pos, gt_quat = gt_pos.squeeze(0).numpy(), gt_quat.squeeze(0).numpy()
272+
quat = quat if np.sign(gt_quat[0]) == np.sign(quat[0]) else -quat
273+
np.testing.assert_allclose(pos, gt_pos, atol=1e-3)
274+
np.testing.assert_allclose(quat, gt_quat, atol=1e-3)
275+
276+
277+
def test_resolve_prim_scale():
278+
"""Test resolve_prim_scale() function.
279+
280+
To simplify the test, we assume that the effective scale at a prim
281+
is the product of the scales of the prims in the hierarchy:
282+
283+
scale = scale_of_xform * scale_of_geometry_prim
284+
285+
This is only true when rotations are identity or the transforms are
286+
orthogonal and uniformly scaled. Otherwise, scale is not composable
287+
like that in local component-wise fashion.
288+
"""
289+
# number of objects
290+
num_objects = 20
291+
# sample random scales for x, y, z
292+
rand_scales = np.random.uniform(0.5, 1.5, size=(num_objects, 3, 3))
293+
rand_widths = np.random.uniform(0.1, 10.0, size=(num_objects,))
294+
# sample random positions
295+
rand_positions = np.random.uniform(-100, 100, size=(num_objects, 3, 3))
296+
297+
# create objects
298+
for i in range(num_objects):
299+
# simple cubes
300+
cube_prim = prim_utils.create_prim(
301+
f"/World/Cubes/instance_{i:02d}",
302+
"Cube",
303+
translation=rand_positions[i, 0],
304+
scale=rand_scales[i, 0],
305+
attributes={"size": rand_widths[i]},
306+
)
307+
# xform hierarchy
308+
xform_prim = prim_utils.create_prim(
309+
f"/World/Xform/instance_{i:02d}",
310+
"Xform",
311+
translation=rand_positions[i, 1],
312+
scale=rand_scales[i, 1],
313+
)
314+
geometry_prim = prim_utils.create_prim(
315+
f"/World/Xform/instance_{i:02d}/geometry",
316+
"Sphere",
317+
translation=rand_positions[i, 2],
318+
scale=rand_scales[i, 2],
319+
attributes={"radius": rand_widths[i]},
320+
)
321+
dummy_prim = prim_utils.create_prim(
322+
f"/World/Xform/instance_{i:02d}/dummy",
323+
"Sphere",
324+
)
325+
326+
# cube prim
327+
scale = sim_utils.resolve_prim_scale(cube_prim)
328+
scale = np.array(scale)
329+
np.testing.assert_allclose(scale, rand_scales[i, 0], atol=1e-5)
330+
# xform prim
331+
scale = sim_utils.resolve_prim_scale(xform_prim)
332+
scale = np.array(scale)
333+
np.testing.assert_allclose(scale, rand_scales[i, 1], atol=1e-5)
334+
# geometry prim
335+
scale = sim_utils.resolve_prim_scale(geometry_prim)
336+
scale = np.array(scale)
337+
np.testing.assert_allclose(scale, rand_scales[i, 1] * rand_scales[i, 2], atol=1e-5)
338+
# dummy prim
339+
scale = sim_utils.resolve_prim_scale(dummy_prim)
340+
scale = np.array(scale)
341+
np.testing.assert_allclose(scale, rand_scales[i, 1], atol=1e-5)

0 commit comments

Comments
 (0)