Skip to content

Commit 15ed1de

Browse files
committed
Fix Fabric seed for scaled parents and scaled children
_sync_fabric_from_usd_initial had two scale-related bugs in the USD->Fabric seed path that produced silently wrong matrices whenever a parent or child had a non-unit scale. Both kernels that recompute world<->local consistency read those seeded matrices, so the error propagated. * Parent worldMatrix was composed with a hardcoded (1, 1, 1) scale. Orthonormalize() strips scale from the local-to-world transform, so we now extract the scale via Gf.Transform.GetScale() *before* orthonormalizing and pass it through to the compose kernel. * Child localMatrix was composed with the empty-array sentinel for the scale slot, leaving the kernel-side scale at the identity default. We now pass the locally-authored scale (already fetched via _usd_view.get_scales()) so the matrix carries the right scale. * Child worldMatrix is still composed from get_world_poses() position and orientation plus the child's local scale, which is wrong when a parent has non-unit world scale. Instead of fixing the seed by hand (would require per-child world-scale lookups), mark the view dirty at the end of the seed. The very next world read fires _sync_world_from_local_if_dirty, which computes child_world = parent_world * child_local on the GPU - and with both matrices now correctly scaled, the multiply produces the right world-space scale automatically. Added test_initial_seed_with_scaled_parent regression test: parent world scale (2, 1, 1), child local scale (3, 1, 1). Locally verified the test fails when either fix is reverted in isolation.
1 parent 7a6f3b0 commit 15ed1de

2 files changed

Lines changed: 70 additions & 9 deletions

File tree

source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
import torch
3535
import warp as wp
3636

37-
from pxr import Usd, UsdGeom
37+
from pxr import Gf, Usd, UsdGeom
3838

3939
import isaaclab.sim as sim_utils
4040
from isaaclab.app.settings_manager import SettingsManager
@@ -769,15 +769,18 @@ def _sync_fabric_from_usd_initial(self) -> None:
769769
],
770770
device=self._device,
771771
)
772-
# Compose into child localMatrix.
772+
# Compose into child localMatrix. Pass the locally-authored scale so
773+
# that a subsequent ``_sync_world_from_local_if_dirty`` produces the
774+
# right world-space scale (``world = parent_world * local`` carries
775+
# ``local``'s scale through the multiply).
773776
wp.launch(
774777
kernel=fabric_utils.compose_indexed_fabric_transforms,
775778
dim=self.count,
776779
inputs=[
777780
self._local_ifa_rw,
778781
_to_float32_2d(local_pos_ta.warp),
779782
_to_float32_2d(local_ori_ta.warp),
780-
self._fabric_empty_2d_array_sentinel,
783+
_to_float32_2d(scales_wp),
781784
False,
782785
False,
783786
False,
@@ -793,23 +796,25 @@ def _sync_fabric_from_usd_initial(self) -> None:
793796
xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default())
794797
world_pos_rows: list[list[float]] = []
795798
world_ori_rows: list[list[float]] = []
799+
world_scale_rows: list[list[float]] = []
800+
decomposer = Gf.Transform()
796801
for path in unique_parent_paths:
797802
prim = usd_stage.GetPrimAtPath(path)
798803
tf = xform_cache.GetLocalToWorldTransform(prim)
804+
# Extract scale before ``Orthonormalize`` strips it from the rows.
805+
decomposer.SetMatrix(tf)
806+
s = decomposer.GetScale()
799807
tf.Orthonormalize()
800808
t = tf.ExtractTranslation()
801809
q = tf.ExtractRotationQuat()
802810
img, real = q.GetImaginary(), q.GetReal()
803811
world_pos_rows.append([float(t[0]), float(t[1]), float(t[2])])
804812
world_ori_rows.append([float(img[0]), float(img[1]), float(img[2]), float(real)])
813+
world_scale_rows.append([float(s[0]), float(s[1]), float(s[2])])
805814
parent_view_indices = wp.array(list(range(len(unique_parent_paths))), dtype=wp.uint32, device=self._device)
806815
parent_pos_wp = wp.array(world_pos_rows, dtype=wp.float32, device=self._device)
807816
parent_ori_wp = wp.array(world_ori_rows, dtype=wp.float32, device=self._device)
808-
parent_unit_scale = wp.array(
809-
[[1.0, 1.0, 1.0]] * len(unique_parent_paths),
810-
dtype=wp.float32,
811-
device=self._device,
812-
)
817+
parent_scale_wp = wp.array(world_scale_rows, dtype=wp.float32, device=self._device)
813818
# Compose worldMatrix for parents (use a one-shot indexed array against
814819
# ``world_sel_rw`` keyed on the unique parent paths).
815820
parent_world_rw = wp.indexedfabricarray(
@@ -823,7 +828,7 @@ def _sync_fabric_from_usd_initial(self) -> None:
823828
parent_world_rw,
824829
parent_pos_wp,
825830
parent_ori_wp,
826-
parent_unit_scale,
831+
parent_scale_wp,
827832
False,
828833
False,
829834
False,
@@ -833,6 +838,14 @@ def _sync_fabric_from_usd_initial(self) -> None:
833838
)
834839
wp.synchronize()
835840

841+
# The child worldMatrix above was composed with the child's *local* scale,
842+
# which is wrong whenever a parent has a non-unit world scale. Mark the
843+
# view dirty so the next world read fires ``_sync_world_from_local_if_dirty``
844+
# and recomputes ``child_world = parent_world * child_local`` — that
845+
# multiply produces the correct world-space scale because the parent and
846+
# local matrices now both carry the right scale (seeded above).
847+
self._world_dirty = True
848+
836849
def _compute_fabric_indices_for(self, selection, paths: list[str]) -> wp.array:
837850
"""Path-dict lookup helper used to build one-shot indexed arrays for a custom path set."""
838851
fabric_paths = selection.GetPaths()

source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,51 @@ def test_set_world_then_get_local_with_rotated_parent(device):
357357
local_pos, _ = view.get_local_poses()
358358
expected = torch.tensor([[0.0, -5.0, 1.0]], dtype=torch.float32, device=device)
359359
torch.testing.assert_close(local_pos.torch, expected, atol=1e-5, rtol=0)
360+
361+
362+
@pytest.mark.parametrize("device", ["cpu", "cuda:0"])
363+
def test_initial_seed_with_scaled_parent(device):
364+
"""Verify the initial USD→Fabric seed handles non-unit scales correctly.
365+
366+
Sets up a parent with world scale (2, 1, 1) and a child with local scale
367+
(3, 1, 1) at local translation (1, 0, 0). Expected world-space values for
368+
the child:
369+
370+
* world scale = parent_scale * child_local_scale = (6, 1, 1)
371+
* world position = parent_pos + parent_scale * child_local_pos
372+
= (0, 0, 1) + (2 * 1, 0, 0) = (2, 0, 1)
373+
374+
If the parent's worldMatrix is seeded with a hardcoded unit scale,
375+
``get_scales`` returns (3, 1, 1) instead of (6, 1, 1) and ``get_world_poses``
376+
returns (1, 0, 1) instead of (2, 0, 1). If the child's localMatrix is
377+
seeded without scale, after ``_sync_world_from_local_if_dirty`` the world
378+
scale collapses to (2, 1, 1). This test catches both regressions.
379+
"""
380+
_skip_if_unavailable(device)
381+
stage = sim_utils.get_current_stage()
382+
sim_utils.create_prim("/World/Parent_0", "Xform", translation=(0.0, 0.0, 1.0), scale=(2.0, 1.0, 1.0), stage=stage)
383+
sim_utils.create_prim(
384+
"/World/Parent_0/Child",
385+
"Camera",
386+
translation=(1.0, 0.0, 0.0),
387+
scale=(3.0, 1.0, 1.0),
388+
stage=stage,
389+
)
390+
sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True))
391+
view = FrameView("/World/Parent_.*/Child", device=device)
392+
393+
world_pos, _ = view.get_world_poses()
394+
torch.testing.assert_close(
395+
world_pos.torch,
396+
torch.tensor([[2.0, 0.0, 1.0]], dtype=torch.float32, device=device),
397+
atol=1e-5,
398+
rtol=0,
399+
)
400+
401+
scales = wp.to_torch(view.get_scales())
402+
torch.testing.assert_close(
403+
scales,
404+
torch.tensor([[6.0, 1.0, 1.0]], dtype=torch.float32, device=device),
405+
atol=1e-5,
406+
rtol=0,
407+
)

0 commit comments

Comments
 (0)