Skip to content

Commit becd3ff

Browse files
committed
feat: add FabricStageCache service for shared hierarchy handles
Introduces FabricStageCache — a lightweight cache for the usdrt stage attachment and IFabricHierarchy handles, registered as a service on SimulationContext via the service locator (set_service/get_service). Multiple FabricFrameView instances now share a single hierarchy handle instead of each creating its own. The cache is automatically closed on SimulationContext.clear_instance(). Also replaces the assert device check with a proper RuntimeError and removes the now-unused isaaclab.sim import from fabric_frame_view.py.
1 parent 8b63997 commit becd3ff

4 files changed

Lines changed: 194 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Added
2+
^^^^^
3+
4+
* Added :class:`~isaaclab_physx.sim.FabricStageCache` — a lightweight cache for the
5+
``usdrt.Usd.Stage`` attachment and ``IFabricHierarchy`` handles, registered as a
6+
service on :class:`~isaaclab.sim.SimulationContext` via ``set_service()``.
7+
8+
Multiple :class:`~isaaclab_physx.sim.views.FabricFrameView` instances now share a
9+
single hierarchy handle instead of each creating its own. The cache is automatically
10+
closed on ``SimulationContext.clear_instance()``.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Fabric stage and hierarchy cache, registered as a service on SimulationContext."""
7+
8+
from __future__ import annotations
9+
10+
from pxr import UsdUtils
11+
12+
13+
class FabricStageCache:
14+
"""Caches the usdrt stage attachment and IFabricHierarchy handles.
15+
16+
Registered as a singleton service on :class:`~isaaclab.sim.SimulationContext` via
17+
``set_service(FabricStageCache, ...)``. Multiple
18+
:class:`~isaaclab_physx.sim.views.FabricFrameView` instances share a single
19+
hierarchy handle per Fabric attachment.
20+
21+
The hierarchy cache is keyed by ``fabric_id_int`` (the stable ``.id`` integer from
22+
``FabricId``). Currently Isaac Lab always has exactly one Fabric attachment per
23+
stage, so this dict will hold at most one entry. A dict is used rather than a plain
24+
attribute so the design naturally extends to multi-Fabric scenarios without an API change.
25+
"""
26+
27+
def __init__(self, usd_stage) -> None:
28+
import usdrt # noqa: PLC0415
29+
30+
stage_id = UsdUtils.StageCache.Get().GetId(usd_stage).ToLongInt()
31+
self._stage = usdrt.Usd.Stage.Attach(stage_id)
32+
self._stage.SynchronizeToFabric()
33+
self._hierarchy_cache: dict[int, object] = {}
34+
35+
@property
36+
def stage(self):
37+
"""The usdrt stage (already attached and synchronized)."""
38+
return self._stage
39+
40+
def close(self) -> None:
41+
"""Release cached handles. Called by SimulationContext on teardown."""
42+
self._hierarchy_cache.clear()
43+
self._stage = None
44+
45+
def get_hierarchy(self):
46+
"""Return the IFabricHierarchy handle for the current Fabric attachment.
47+
48+
Creates and caches the handle on first call. Change-tracking is enabled
49+
for both local and world xforms.
50+
51+
Returns:
52+
A tuple of ``(hierarchy_handle, fabric_id_int)``.
53+
"""
54+
import usdrt # noqa: PLC0415
55+
56+
fabric_id = self._stage.GetFabricId()
57+
fabric_id_int = fabric_id.id
58+
59+
if fabric_id_int not in self._hierarchy_cache:
60+
hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy(
61+
fabric_id, self._stage.GetStageIdAsStageId()
62+
)
63+
hierarchy.track_local_xform_changes(True)
64+
hierarchy.track_world_xform_changes(True)
65+
self._hierarchy_cache[fabric_id_int] = hierarchy
66+
67+
return self._hierarchy_cache[fabric_id_int], fabric_id_int

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
from pxr import Usd
1616

17-
import isaaclab.sim as sim_utils
1817
from isaaclab.app.settings_manager import SettingsManager
1918
from isaaclab.sim.views.base_frame_view import BaseFrameView
2019
from isaaclab.sim.views.usd_frame_view import UsdFrameView
@@ -373,8 +372,21 @@ def _initialize_fabric(self) -> None:
373372
import usdrt # noqa: PLC0415
374373
from usdrt import Rt # noqa: PLC0415
375374

376-
stage_id = sim_utils.get_current_stage_id()
377-
fabric_stage = usdrt.Usd.Stage.Attach(stage_id)
375+
from isaaclab.sim import SimulationContext # noqa: PLC0415
376+
377+
from isaaclab_physx.sim.fabric_stage_cache import FabricStageCache # noqa: PLC0415
378+
379+
sim_context = SimulationContext.instance()
380+
if sim_context is None:
381+
raise RuntimeError("SimulationContext must be initialized before FabricFrameView.")
382+
383+
# Get or create the FabricStageCache service.
384+
cache = sim_context.services[FabricStageCache]
385+
if cache is None:
386+
cache = FabricStageCache(sim_context.stage)
387+
sim_context.services[FabricStageCache] = cache
388+
389+
fabric_stage = cache.stage
378390

379391
for i in range(self.count):
380392
rt_prim = fabric_stage.GetPrimAtPath(self.prim_paths[i])
@@ -393,9 +405,7 @@ def _initialize_fabric(self) -> None:
393405
rt_prim.CreateAttribute(self._view_index_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True)
394406
rt_prim.GetAttribute(self._view_index_attr).Set(i)
395407

396-
self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy(
397-
fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId()
398-
)
408+
self._fabric_hierarchy, _ = cache.get_hierarchy()
399409
self._fabric_hierarchy.update_world_xforms()
400410

401411
self._default_view_indices = wp.zeros((self.count,), dtype=wp.uint32, device=self._device)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Tests for FabricStageCache service lifecycle."""
7+
8+
from isaaclab.app import AppLauncher
9+
10+
simulation_app = AppLauncher(headless=True).app
11+
12+
import pytest # noqa: E402
13+
from isaaclab_physx.sim.fabric_stage_cache import FabricStageCache # noqa: E402
14+
15+
import isaaclab.sim as sim_utils # noqa: E402
16+
from isaaclab.sim import SimulationContext # noqa: E402
17+
18+
pytestmark = pytest.mark.isaacsim_ci
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def setup_teardown():
23+
"""Create and clear stage for each test."""
24+
sim_utils.create_new_stage()
25+
sim_utils.update_stage()
26+
yield
27+
if SimulationContext.instance() is not None:
28+
sim_utils.clear_stage()
29+
SimulationContext.clear_instance()
30+
31+
32+
class TestFabricStageCacheService:
33+
"""Test that FabricStageCache integrates with SimulationContext's service locator."""
34+
35+
def test_register_and_retrieve(self):
36+
"""Service can be registered and retrieved via services[]."""
37+
sim_context = SimulationContext()
38+
cache = FabricStageCache(sim_context.stage)
39+
sim_context.services[FabricStageCache] = cache
40+
41+
retrieved = sim_context.services[FabricStageCache]
42+
assert retrieved is cache
43+
44+
def test_stage_attached(self):
45+
"""The cached usdrt stage is not None after construction."""
46+
sim_context = SimulationContext()
47+
cache = FabricStageCache(sim_context.stage)
48+
assert cache.stage is not None
49+
50+
def test_get_hierarchy_returns_handle(self):
51+
"""get_hierarchy returns a hierarchy handle and fabric id."""
52+
sim_context = SimulationContext()
53+
cache = FabricStageCache(sim_context.stage)
54+
55+
hierarchy, fabric_id_int = cache.get_hierarchy()
56+
assert hierarchy is not None
57+
assert isinstance(fabric_id_int, int)
58+
59+
def test_get_hierarchy_is_cached(self):
60+
"""Repeated calls return the same hierarchy handle."""
61+
sim_context = SimulationContext()
62+
cache = FabricStageCache(sim_context.stage)
63+
64+
h1, id1 = cache.get_hierarchy()
65+
h2, id2 = cache.get_hierarchy()
66+
assert h1 is h2
67+
assert id1 == id2
68+
69+
def test_close_clears_state(self):
70+
"""close() clears internal caches."""
71+
sim_context = SimulationContext()
72+
cache = FabricStageCache(sim_context.stage)
73+
cache.get_hierarchy() # populate cache
74+
cache.close()
75+
assert cache.stage is None
76+
77+
def test_clear_instance_closes_service(self):
78+
"""SimulationContext.clear_instance() calls close() on registered services."""
79+
sim_context = SimulationContext()
80+
cache = FabricStageCache(sim_context.stage)
81+
sim_context.services[FabricStageCache] = cache
82+
83+
SimulationContext.clear_instance()
84+
85+
# After clear_instance, the cache should have been closed
86+
assert cache.stage is None
87+
88+
def test_replacement_caller_closes_old(self):
89+
"""Caller is responsible for closing old service before replacing."""
90+
sim_context = SimulationContext()
91+
cache1 = FabricStageCache(sim_context.stage)
92+
sim_context.services[FabricStageCache] = cache1
93+
94+
cache1.close()
95+
cache2 = FabricStageCache(sim_context.stage)
96+
sim_context.services[FabricStageCache] = cache2
97+
98+
# Old cache was closed by caller
99+
assert cache1.stage is None
100+
# New cache should be active
101+
assert cache2.stage is not None

0 commit comments

Comments
 (0)