Skip to content

Commit ab8ba52

Browse files
committed
Use clone-plan positions for OVRTX roots
Make the published clone plan authoritative for environment root translation in both OVRTX scene paths. Remove fallback plan resolution and stage transform capture so clone ownership stays explicit.
1 parent d914d4a commit ab8ba52

4 files changed

Lines changed: 160 additions & 298 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed OVRTX environment placement by authoring root translations from the clone plan after
5+
cloning instead of capturing transforms from the USD stage.

source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py

Lines changed: 16 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import math
2525
import os
2626
import re
27-
from itertools import compress
2827
from pathlib import Path
2928
from typing import TYPE_CHECKING, Any, NoReturn, cast
3029

@@ -251,49 +250,6 @@ def _write_file(output_dir: Path, file_name: str, content: str) -> None:
251250
logger.info("Wrote USD file: %s", output_path)
252251

253252

254-
def _create_homogeneous_clone_plan(num_envs: int) -> ClonePlan:
255-
"""Create a homogeneous fallback plan that replicates ``env_0`` to every environment."""
256-
return ClonePlan(
257-
sources=("/World/envs/env_0",),
258-
destinations=("/World/envs/env_{}",),
259-
clone_mask=torch.ones((1, num_envs), dtype=torch.bool, device="cpu"),
260-
)
261-
262-
263-
def _resolve_clone_plan(num_envs: int) -> ClonePlan:
264-
"""Resolve clone plan for local use.
265-
266-
If no clone plan is published by the scene or it has no active rows, returns a homogeneous clone plan.
267-
If the clone plan has some inactive rows, returns a copy of the published clone plan with only the active rows.
268-
If the clone plan has all active rows, returns the published clone plan (shallow copy).
269-
"""
270-
published_clone_plan = SimulationContext.instance().get_clone_plan()
271-
272-
if published_clone_plan is None:
273-
logger.warning("No clone plan is published by the scene; returning homogeneous clone plan")
274-
return _create_homogeneous_clone_plan(num_envs)
275-
276-
active_rows = published_clone_plan.clone_mask.any(dim=1)
277-
278-
# If no rows are active, return a homogeneous clone plan.
279-
if not active_rows.any():
280-
logger.warning("Clone plan has no active rows, returning homogeneous clone plan")
281-
return _create_homogeneous_clone_plan(num_envs)
282-
283-
# If some rows are inactive, return a copy of the published clone plan with only the active rows.
284-
if not active_rows.all():
285-
logger.warning("Clone plan has some inactive rows; returning a copy with only active rows")
286-
active = active_rows.tolist()
287-
return ClonePlan(
288-
sources=tuple(compress(published_clone_plan.sources, active)),
289-
destinations=tuple(compress(published_clone_plan.destinations, active)),
290-
clone_mask=published_clone_plan.clone_mask[active_rows],
291-
)
292-
293-
# If all rows are active, return the published clone plan (shallow copy).
294-
return published_clone_plan
295-
296-
297253
class OVRTXRenderData:
298254
"""OVRTX-specific RenderData. Holds warp output buffers sized from :class:`CameraRenderSpec`."""
299255

@@ -435,27 +391,20 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None:
435391
if stage is None:
436392
return
437393

394+
self._clone_plan = SimulationContext.instance().get_clone_plan()
395+
if self._clone_plan is None or self._clone_plan.positions is None:
396+
raise RuntimeError("Clone plan with environment positions is required when preparing OVRTX stage")
397+
438398
# If temp_usd_dir is set, write the pre-ovrtx stage to a temporary file.
439399
if self.cfg.temp_usd_dir is not None:
440400
_write_file(Path(self.cfg.temp_usd_dir), "pre_ovrtx_renderer_stage.usda", stage.ExportToString())
441401

442402
logger.info("Preparing stage (%d envs)...", num_envs)
443403
create_scene_partition_attributes(stage, num_envs)
444404

445-
# Resolve the clone plan for local use.
446-
self._clone_plan = _resolve_clone_plan(num_envs)
447-
if self._clone_plan is None:
448-
raise RuntimeError("Clone plan is required when preparing OVRTX stage")
449-
450-
# The ovstage path cannot read env-root transforms back after load (see
451-
# _clone_sources_ovstage), so snapshot them here while the full USD stage is still live.
452-
if self._use_ovstage:
453-
self._capture_env_root_xforms_ovstage(stage, num_envs)
454-
455405
# keep_env_roots is False on the ovstage path: ovstage's ``Stage.clone`` requires each target
456406
# path to not already exist, so the non-source env roots must be trimmed from the exported
457-
# stage for it to recreate them. Their xforms were captured just above, since trimming them
458-
# is what makes them unreadable afterwards.
407+
# stage for it to recreate them.
459408
self._exported_usd_string = export_stage_to_string(
460409
stage,
461410
num_envs,
@@ -563,35 +512,11 @@ def _initialize_from_spec_legacy(self, spec: CameraRenderSpec):
563512
def _clone_sources_in_ovrtx(self):
564513
"""Clone sources in OVRTX using the scene :class:`~isaaclab.cloner.ClonePlan`."""
565514
clone_plan = self._clone_plan
566-
if clone_plan is None:
567-
raise RuntimeError("Clone plan is required when using OVRTX cloning")
515+
if clone_plan is None or clone_plan.positions is None:
516+
raise RuntimeError("Clone plan with environment positions is required when using OVRTX cloning")
568517

569518
num_envs = clone_plan.clone_mask.shape[1]
570519
env_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)]
571-
xform_attr_name = "omni:xform"
572-
573-
# Snapshot per-env root transforms before clone_usd overwrites them with the source env root.
574-
#
575-
# We only create xform bindings for prims in the body_label list, so their transforms are
576-
# driven each frame from simulation data. Prims not in that list (e.g. static rigid objects
577-
# such as tables) get no binding, and we never reset their xform stack. Their world placement
578-
# therefore relies on every ancestor holding a correct xform in the ovrtx data. In
579-
# particular, if an env root has no valid xform, these prims collapse toward env_0 frame
580-
# (e.g. tables ending up at env_0 and missing from the other tiles).
581-
#
582-
# Snapshotting the env-root xforms here and restoring them after clone (see below) keeps those
583-
# hierarchy-positioned prims correctly placed per env. This is a cheap one-off operation,
584-
# preferable to forcing every static object into the body_label list just to bind its xform.
585-
#
586-
env_root_xforms = np.empty((num_envs, 4, 4), dtype=np.float64)
587-
self._renderer.read_attribute(
588-
xform_attr_name,
589-
env_prim_paths,
590-
prim_mode=PrimMode.MUST_EXIST,
591-
dest=env_root_xforms,
592-
)
593-
logger.info("Captured per-env root transforms before cloning")
594-
595520
logger.info("Cloning sources in OVRTX...")
596521
env_ids = torch.arange(num_envs, dtype=torch.int32, device=clone_plan.clone_mask.device)
597522

@@ -618,15 +543,15 @@ def _clone_sources_in_ovrtx(self):
618543

619544
logger.info("Cloned %d sources successfully in OVRTX", num_cloned_sources)
620545

621-
# Restore the pre-clone xforms.
546+
env_root_xforms = np.tile(np.eye(4, dtype=np.float64), (num_envs, 1, 1))
547+
env_root_xforms[:, 3, :3] = clone_plan.positions.cpu().numpy()
622548
self._renderer.write_attribute(
623549
prim_paths=env_prim_paths,
624-
attribute_name=xform_attr_name,
550+
attribute_name="omni:xform",
625551
tensor=env_root_xforms,
626552
semantic=Semantic.XFORM_MAT4x4,
627553
prim_mode=PrimMode.MUST_EXIST,
628554
)
629-
logger.info("Restored per-env root transforms after cloning")
630555

631556
def _update_scene_partitions_after_clone(self, num_envs: int):
632557
"""Update scene partition attributes on cloned environments and cameras in OvRTX."""
@@ -1567,7 +1492,6 @@ def _init_fields_ovstage(self) -> None:
15671492
self._deformable_paths_list = None
15681493
self._particle_points_query = None
15691494
self._particle_paths_list = None
1570-
self._env_root_xforms: np.ndarray | None = None
15711495

15721496
def _initialize_from_spec_ovstage(self, spec: CameraRenderSpec) -> None:
15731497
"""Initialize the OVRTX renderer with internal environment cloning (ovstage path).
@@ -1678,55 +1602,15 @@ def _initialize_from_spec_ovstage(self, spec: CameraRenderSpec) -> None:
16781602
logger.info("OVRTX loaded USD from string successfully via ovstage")
16791603
self._current_ordinal += 1
16801604

1681-
def _capture_env_root_xforms_ovstage(self, stage: Any, num_envs: int) -> None:
1682-
"""Capture per-env root transforms from the live USD stage before export.
1683-
1684-
Must be called before :func:`export_stage_to_string`, which trims non-source
1685-
env geometry so those transforms cannot be read back reliably from ovstage after load.
1686-
The captured array is consumed by :meth:`_clone_sources_ovstage` and cleared after use.
1687-
"""
1688-
from pxr import UsdGeom
1689-
1690-
xform_cache = UsdGeom.XformCache()
1691-
self._env_root_xforms = np.empty((num_envs, 4, 4), dtype=np.float64)
1692-
for i in range(num_envs):
1693-
prim = stage.GetPrimAtPath(f"/World/envs/env_{i}")
1694-
self._env_root_xforms[i] = np.array(xform_cache.GetLocalToWorldTransform(prim), dtype=np.float64).reshape(
1695-
4, 4
1696-
)
1697-
16981605
def _clone_sources_ovstage(self):
16991606
"""Clone sources in OVRTX using the scene :class:`~isaaclab.cloner.ClonePlan` (ovstage path)."""
17001607
clone_plan = self._clone_plan
1701-
if clone_plan is None:
1702-
raise RuntimeError("Clone plan is required when using OVRTX cloning")
1608+
if clone_plan is None or clone_plan.positions is None:
1609+
raise RuntimeError("Clone plan with environment positions is required when using OVRTX cloning")
17031610

17041611
num_envs = clone_plan.clone_mask.shape[1]
17051612
env_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)]
17061613

1707-
env_paths_list = self._stage_paths.create_path_list_from_strings(env_prim_paths)
1708-
env_query = self._stage.query_from_path_list(env_paths_list)
1709-
1710-
# Snapshot per-env root transforms before clone overwrites them with the source env root.
1711-
#
1712-
# We only create xform queries for prims in the body_label list, so their transforms are
1713-
# driven each frame from simulation data. Prims not in that list (e.g. static rigid objects
1714-
# such as tables) get no query, and we never reset their xform stack. Their world placement
1715-
# therefore relies on every ancestor holding a correct xform in the ovstage data. In
1716-
# particular, if an env root has no valid xform, these prims collapse toward env_0 frame
1717-
# (e.g. tables ending up at env_0 and missing from the other tiles).
1718-
#
1719-
# Snapshotting the env-root xforms here and restoring them after clone (see below) keeps those
1720-
# hierarchy-positioned prims correctly placed per env. This is a cheap one-off operation,
1721-
# preferable to forcing every static object into the body_label list just to bind its xform.
1722-
#
1723-
# Transforms were captured from the live USD stage in prepare_stage before export
1724-
# stripped non-source envs — they are not readable from ovstage at this point.
1725-
env_root_xforms = self._env_root_xforms
1726-
if env_root_xforms is None:
1727-
raise RuntimeError("env_root_xforms not captured; ensure prepare_stage was called first")
1728-
logger.info("Using pre-captured per-env root transforms for post-clone restore")
1729-
17301614
logger.info("Cloning sources in OVRTX...")
17311615
env_ids = torch.arange(num_envs, dtype=torch.int32, device=clone_plan.clone_mask.device)
17321616

@@ -1752,7 +1636,10 @@ def _clone_sources_ovstage(self):
17521636

17531637
logger.info("Cloned %d sources successfully in OVRTX", num_cloned_sources)
17541638

1755-
# Restore the pre-clone xforms.
1639+
env_root_xforms = np.tile(np.eye(4, dtype=np.float64), (num_envs, 1, 1))
1640+
env_root_xforms[:, 3, :3] = clone_plan.positions.cpu().numpy()
1641+
env_paths_list = self._stage_paths.create_path_list_from_strings(env_prim_paths)
1642+
env_query = self._stage.query_from_path_list(env_paths_list)
17561643
self._stage.write_attribute(
17571644
env_query,
17581645
"omni:xform",
@@ -1761,8 +1648,6 @@ def _clone_sources_ovstage(self):
17611648
is_array=False,
17621649
semantic=ovstage.AttributeSemantic.MATRIX,
17631650
).wait()
1764-
self._env_root_xforms = None
1765-
logger.info("Restored per-env root transforms after cloning")
17661651

17671652
self._stage.release_query(env_query).wait()
17681653
self._stage_paths.destroy_path_list(env_paths_list)
@@ -2220,7 +2105,6 @@ def _safe_destroy_path_list(path_list, name: str) -> None:
22202105
self._deformable_particle_counts = []
22212106
self._particle_visual_offsets = []
22222107
self._particle_visual_counts = []
2223-
self._env_root_xforms = None
22242108

22252109
# Detach before closing ExitStack: the renderer holds a live reference into the stage,
22262110
# so detaching first avoids a use-after-free when ExitStack destroys Stage and PathDictionary.

0 commit comments

Comments
 (0)