diff --git a/source/isaaclab_ov/changelog.d/fix-legacy-ovrtx-instance-cloning.rst b/source/isaaclab_ov/changelog.d/fix-legacy-ovrtx-instance-cloning.rst new file mode 100644 index 000000000000..89726882fc2b --- /dev/null +++ b/source/isaaclab_ov/changelog.d/fix-legacy-ovrtx-instance-cloning.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed missing cloned visuals in the legacy OVRTX renderer by expanding nested USD instances + in its exported clone sources. Simulation stages and the ovstage renderer path retained + their original instancing. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 641aa4af78a9..d7cdd9b28619 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -440,12 +440,15 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None: # Composed scales must be read while the full stage is still live, before export trims it. self._capture_object_scales(stage, self._clone_plan) - # The clone plan already identifies every source row. Keep those rows independent so - # backend bindings for dynamic assets retain the paths they were compiled against. + # Unused variant rows retain placeholder source paths but have no spawned prim. + # Keep active rows independent so dynamic assets retain their compiled binding paths. + active_sources = tuple( + path for path, mask in zip(self._clone_plan.sources, self._clone_plan.clone_mask, strict=True) if mask.any() + ) self._exported_usd_string = export_stage_to_string( stage, num_envs, - source_paths=self._clone_plan.sources, + source_paths=active_sources, keep_env_roots=not self._use_ovstage, ) diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index 2d008bd25e1e..891a9e9c0a7d 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -14,6 +14,8 @@ from pxr import Sdf, Usd, UsdGeom +from isaaclab.sim.utils import make_uninstanceable + logger = logging.getLogger(__name__) @@ -426,9 +428,10 @@ def export_stage_to_string( anonymous session layer used only for export, so the input stage remains unchanged. When ``keep_env_roots`` is True (the legacy ``renderer.clone_usd`` path) the non-source env root prims stay - active so the exported stage retains a slot for every env. The ovstage ``stage.clone`` path passes False, which - additionally trims the non-source env roots themselves; ``stage.clone`` recreates them and the RenderProduct's - camera relationship is re-authored after clone. + active so the exported stage retains a slot for every env. Nested instances in source subtrees are expanded + on the export session because legacy cloning otherwise drops their geometry. The ovstage ``stage.clone`` + path passes False, retaining source instancing and trimming the non-source env roots themselves; + ``stage.clone`` recreates them and the RenderProduct's camera relationship is re-authored after clone. Args: stage: USD stage to export. @@ -455,6 +458,10 @@ def export_stage_to_string( prim_paths: list[Sdf.Path] = [] if keep_env_roots: + # Native legacy cloning omits instanced visuals; expand only the renderer's copy. + with Usd.EditContext(export_stage, export_session): + for source_path in source_paths: + make_uninstanceable(source_path, stage=export_stage) for child in envs_prim.GetChildren(): # Legacy code path: keep env roots so we can query their xforms after opening stage child_path = child.GetPath() diff --git a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py index c5d215ecc82d..265e8d201d39 100644 --- a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py +++ b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py @@ -350,6 +350,35 @@ def test_prepare_stage_rejects_non_dense_environment_ids(monkeypatch: pytest.Mon _make_ovrtx_renderer_without_backend().prepare_stage(_make_multi_env_stage(2), 2) +@pytest.mark.parametrize("use_ovstage", [False, True]) +def test_prepare_stage_ignores_unspawned_variant_sources(monkeypatch: pytest.MonkeyPatch, use_ovstage: bool): + """Unused variants retain plan rows but have no source prim to export.""" + num_envs, num_variants = 4, 16 + stage = _make_multi_env_stage(num_envs) + plan = ClonePlan( + sources=tuple(f"/World/envs/env_{index}/Robot" for index in range(num_variants)), + destinations=("/World/envs/env_{}/Robot",) * num_variants, + clone_mask=np.eye(num_variants, num_envs, dtype=np.bool_), + env_ids=np.arange(num_envs, dtype=np.int64), + positions=np.zeros((num_envs, 3), dtype=np.float32), + ) + _patch_simulation_context(monkeypatch, plan) + renderer = _make_ovrtx_renderer_without_backend() + renderer._use_ovstage = use_ovstage + + renderer.prepare_stage(stage, num_envs) + + exported_layer = Sdf.Layer.CreateAnonymous(".usda") + assert exported_layer.ImportFromString(renderer._exported_usd_string) + exported_stage = Usd.Stage.Open(exported_layer) + for source in plan.sources[:num_envs]: + assert exported_stage.GetPrimAtPath(source) + for source in plan.sources[num_envs:]: + assert not exported_stage.GetPrimAtPath(source) + assert renderer._clone_plan is plan + assert len(plan.sources) == num_variants + + def test_capture_object_scales_populates_source_and_destination_scale_array(): """Projected source scales reach the body array without replacing a real destination scale.""" stage = Usd.Stage.CreateInMemory() diff --git a/source/isaaclab_ov/test/test_ovrtx_usd.py b/source/isaaclab_ov/test/test_ovrtx_usd.py index 1dc05838c820..d45cc07e059b 100644 --- a/source/isaaclab_ov/test/test_ovrtx_usd.py +++ b/source/isaaclab_ov/test/test_ovrtx_usd.py @@ -430,8 +430,8 @@ def test_export_stage_without_keep_env_roots_trims_non_source_env_roots(): """The ovstage clone path also trims the non-source env roots themselves. ``ovstage.Stage.clone`` requires every target path to not already exist, so the exported stage - must not retain env roots that the clone will recreate. This is the only difference from the - legacy ``renderer.clone_usd`` path, which keeps the roots as placeholders. + must not retain env roots that the clone will recreate. The legacy ``renderer.clone_usd`` + path keeps the roots as placeholders. """ num_envs = 4 stage = _make_multi_env_stage(num_envs) @@ -494,6 +494,44 @@ def test_export_stage_restores_active_state(): assert stage.GetPrimAtPath(f"{env_path}/Object_env{env_idx}_only").IsActive() +@pytest.mark.parametrize( + ("num_envs", "keep_env_roots", "keep_instances"), + [(4, True, False), (4, False, True), (1, True, True), (1, False, True)], +) +def test_export_stage_expands_nested_instances_only_for_legacy_cloning(num_envs, keep_env_roots, keep_instances): + """Legacy cloning receives concrete geometry without changing the simulation's instancing.""" + stage = _make_multi_env_stage(num_envs) + mesh = UsdGeom.Mesh.Define(stage, "/MeshPrototype/Mesh") + mesh.CreatePointsAttr([(0, 0, 0), (1, 0, 0), (0, 1, 0)]) + mesh.CreateFaceVertexCountsAttr([3]) + mesh.CreateFaceVertexIndicesAttr([0, 1, 2]) + visuals = UsdGeom.Xform.Define(stage, "/RobotPrototype/Visuals").GetPrim() + visuals.GetReferences().AddInternalReference("/MeshPrototype") + visuals.SetInstanceable(True) + robot_path = "/World/envs/env_0/Robot" + robot = stage.GetPrimAtPath(robot_path) + robot.GetReferences().AddInternalReference("/RobotPrototype") + robot.SetInstanceable(True) + mesh_path = f"{robot_path}/Visuals/Mesh" + assert robot.IsInstance() + assert stage.GetPrimAtPath(mesh_path).IsInstanceProxy() + input_layers = [layer.ExportToString() for layer in stage.GetLayerStack()] + + exported = export_stage_to_string(stage, num_envs, ("/World/envs/env_0",), keep_env_roots) + exported_layer = Sdf.Layer.CreateAnonymous("exported.usda") + exported_layer.ImportFromString(exported) + exported_stage = Usd.Stage.Open(exported_layer) + + assert exported_stage.GetPrimAtPath(robot_path).IsInstance() == keep_instances + assert exported_stage.GetPrimAtPath(f"{robot_path}/Visuals").IsInstanceable() == keep_instances + exported_mesh = exported_stage.GetPrimAtPath(mesh_path) + assert exported_mesh.IsInstanceProxy() == keep_instances + assert UsdGeom.Mesh(exported_mesh).GetPointsAttr().Get() == mesh.GetPointsAttr().Get() + assert [layer.ExportToString() for layer in stage.GetLayerStack()] == input_layers + assert robot.IsInstance() + assert stage.GetPrimAtPath(mesh_path).IsInstanceProxy() + + def test_create_scene_partition_attributes_all_envs(): """Scene partition attributes are authored on every env root and camera.""" num_envs = 4