Source code for isaaclab_newton.cloner.replicate

# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import contextlib
import copy
import re
from collections.abc import Callable, Iterator, Sequence
from typing import TYPE_CHECKING, TypeAlias

import torch
import warp as wp
from newton import ModelBuilder
from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx

from pxr import Usd

from isaaclab.physics import PhysicsManager
from isaaclab.sim.utils.newton_model_utils import replace_newton_builder_shape_colors

from isaaclab_newton.cloner.newton_clone_utils import (
    _restore_visible_colliders_without_visual_shapes,
    build_source_builders,
    rename_builder_labels,
    replicate_builder_mapping,
)
from isaaclab_newton.physics import NewtonManager

if TYPE_CHECKING:
    _MappingBatch: TypeAlias = tuple[
        tuple[str, ...], tuple[str, ...], torch.Tensor, torch.Tensor, torch.Tensor | None, torch.Tensor | None
    ]
else:
    _MappingBatch = tuple


def copy_newton_clone_source(source_path: str, xform: wp.transform | None = None) -> ModelBuilder:
    """Copy a retained clone-source builder without sharing mutable shape geometry.

    Args:
        source_path: Clone-plan source prim path retained during Newton replication.
        xform: Optional transform applied while copying the source.

    Returns:
        An independent builder that is safe to finalize or extend.

    Raises:
        RuntimeError: If Newton replication did not retain the requested source.
    """
    source = NewtonManager._cl_protos.get(source_path)
    if source is None:
        raise RuntimeError(f"No retained Newton clone source for {source_path!r}.")
    builder = ModelBuilder(up_axis=source.up_axis)
    if xform is None:
        builder.add_builder(source)
    else:
        builder.add_builder(source, xform=xform)
    builder.shape_source = [
        value.copy() if callable(getattr(value, "copy", None)) else copy.copy(value) for value in builder.shape_source
    ]
    return builder


[docs] @contextlib.contextmanager def newton_builder_world_hook( hook: Callable[[ModelBuilder, int, list[float], list[float]], None], ) -> Iterator[None]: """Temporarily extend every world built by Newton replication. The callback must not already be registered. On exit, the context removes only its callback and preserves hooks owned by other callers. Args: hook: Callback receiving the builder, world index, world position [m], and world orientation quaternion in xyzw order during replication. Yields: Control while the callback is registered. Raises: RuntimeError: If the callback is already registered. """ hooks = NewtonManager._per_world_builder_hooks if hook in hooks: raise RuntimeError("Newton world-builder hook is already registered.") hooks.append(hook) try: yield finally: if hook in hooks: hooks.remove(hook)
def _build_newton_builder_from_mapping( stage: Usd.Stage, sources: Sequence[str], destinations: Sequence[str], env_ids: torch.Tensor, mapping: torch.Tensor, positions: torch.Tensor | None = None, quaternions: torch.Tensor | None = None, up_axis: str = "Z", load_visual_shapes: bool = True, ) -> tuple[ModelBuilder, object, dict, list, dict[str, ModelBuilder]]: """Build a Newton model builder from clone mapping inputs. Also returns the per-source builders (``{source_path: ModelBuilder}``) so the committing path can retain them for single-model consumers such as the batched Newton IK action. """ if positions is None: positions = torch.zeros((mapping.size(1), 3), device=mapping.device, dtype=torch.float32) if quaternions is None: quaternions = torch.zeros((mapping.size(1), 4), device=mapping.device, dtype=torch.float32) quaternions[:, 3] = 1.0 schema_resolvers = [SchemaResolverNewton(), SchemaResolverPhysx()] manager_cls = PhysicsManager._sim.physics_manager builder = manager_cls.create_builder(up_axis=up_axis) # Swap height-field-tagged terrain colliders for Newton heightfields before the # mesh import, and skip those prims in add_usd so the terrain is not imported twice. hf_ignore_paths = manager_cls._inject_terrain_heightfields(stage, builder) stage_info = builder.add_usd( stage, ignore_paths=["/World/envs", *sources, *hf_ignore_paths], schema_resolvers=schema_resolvers, load_visual_shapes=load_visual_shapes, ) _restore_visible_colliders_without_visual_shapes(builder, stage, stage_info["path_shape_map"], load_visual_shapes) replace_newton_builder_shape_colors(builder, stage) # Deformable prim paths are handled by per_world_builder_hooks, not add_usd. # Resolve the regex prim_path patterns to concrete env_0 paths so add_usd # can skip them via ignore_paths. deformable_patterns = tuple( re.compile(entry.prim_path.replace(".*", "[^/]*")) for entry in NewtonManager._deformable_registry ) deformable_ignore_paths = [] if deformable_patterns: for source in sources: for child in Usd.PrimRange(stage.GetPrimAtPath(source)): child_path = str(child.GetPath()) if any(pattern.fullmatch(child_path) for pattern in deformable_patterns): deformable_ignore_paths.append(child_path) source_builders = build_source_builders( stage, sources, lambda: manager_cls.create_builder(up_axis=up_axis), schema_resolvers, ignore_paths=deformable_ignore_paths or None, load_visual_shapes=load_visual_shapes, ) # Inject registered sites into source builders (and global sites into main builder). global_sites, source_sites, root_sites = NewtonManager._cl_inject_sites(builder, source_builders) replicate_args = (builder, sources, mapping, positions, quaternions, source_builders) local_site_map, world_xforms = replicate_builder_mapping( *replicate_args, source_site_indices=source_sites, env_root_sites=root_sites, per_world_builder_hooks=NewtonManager._per_world_builder_hooks, ) site_index_map = {label: (idx, None) for label, idx in global_sites.items()} site_index_map.update((label, (None, per_world)) for label, per_world in local_site_map.items()) return builder, stage_info, site_index_map, world_xforms, source_builders def _renderer_wants_visual_shapes() -> bool: """Whether anything in this run will draw the Newton model's visual-only shapes. Visual shapes are consumed by the viewers, offscreen ``rgb_array`` capture, and camera sensors on any renderer backend. A headless training run without cameras draws none of them, so importing them only costs USD parse time and memory. """ from isaaclab.sim import SimulationContext sim = SimulationContext.instance() if sim is None: return True return bool(sim.is_rendering or sim.can_render_rgb_array() or sim.visual_shapes_required)
[docs] class NewtonReplicateContext: """Queue and run Newton replication work for one stage."""
[docs] def __init__( self, stage: Usd.Stage, *, device: str = "cpu", up_axis: str = "Z", load_visual_shapes: bool | None = None, commit_to_manager: bool = True, ): """Initialize the context. Args: stage: USD stage containing source assets. device: Device used by the finalized Newton model builder. up_axis: Up axis for the Newton model builder. load_visual_shapes: Whether to import visual-only geometry. If ``None``, read from the active :class:`NewtonCfg`, which itself defaults to importing them only when a renderer or visualizer is active. commit_to_manager: Whether :meth:`replicate` should publish the builder to :class:`NewtonManager`. """ self.stage = stage self.device = device self.up_axis = up_axis if load_visual_shapes is None: from isaaclab_newton.physics import NewtonCfg cfg = PhysicsManager._cfg load_visual_shapes = cfg.load_visual_shapes if isinstance(cfg, NewtonCfg) else None self.load_visual_shapes = _renderer_wants_visual_shapes() if load_visual_shapes is None else load_visual_shapes self.commit_to_manager = commit_to_manager self._queue: list[_MappingBatch] = []
def queue_mapping( self, sources: Sequence[str], destinations: Sequence[str], env_ids: torch.Tensor, mapping: torch.Tensor, *, positions: torch.Tensor | None = None, quaternions: torch.Tensor | None = None, ) -> None: """Queue replication rows from the current flat clone mapping. Args: sources: Source prim paths used for cloning. destinations: Destination prim path templates. env_ids: Environment ids for destination worlds. mapping: Boolean source-to-environment mapping matrix. positions: Optional per-environment world positions [m]. quaternions: Optional per-environment orientations in xyzw order. """ self._queue.append((tuple(sources), tuple(destinations), env_ids, mapping, positions, quaternions)) @staticmethod def _merge_optional_tensor( name: str, current: torch.Tensor | None, incoming: torch.Tensor | None ) -> torch.Tensor | None: """Merge optional tensors, requiring equal values when both are present.""" if current is None: return incoming if incoming is None: return current if current.device != incoming.device or current.shape != incoming.shape or not torch.equal(current, incoming): raise ValueError(f"Queued Newton mappings must use the same {name} tensor.") return current def _merged_mapping(self) -> _MappingBatch: """Merge queued mapping batches into the legacy flat mapping shape.""" if not self._queue: raise RuntimeError("Cannot replicate without queued Newton mappings.") sources: list[str] = [] destinations: list[str] = [] mappings: list[torch.Tensor] = [] env_ids = self._queue[0][2] positions = self._queue[0][4] quaternions = self._queue[0][5] for ( queued_sources, queued_destinations, queued_env_ids, mapping, queued_positions, queued_quaternions, ) in self._queue: if ( env_ids.device != queued_env_ids.device or env_ids.shape != queued_env_ids.shape or not torch.equal(env_ids, queued_env_ids) ): raise ValueError("Queued Newton mappings must use the same env_ids tensor.") sources.extend(queued_sources) destinations.extend(queued_destinations) mappings.append(mapping) positions = self._merge_optional_tensor("positions", positions, queued_positions) quaternions = self._merge_optional_tensor("quaternions", quaternions, queued_quaternions) return tuple(sources), tuple(destinations), env_ids, torch.cat(mappings, dim=0), positions, quaternions def replicate(self) -> tuple[ModelBuilder, object, dict]: """Build the Newton model builder from queued mappings and optionally publish it.""" sources, destinations, env_ids, mapping, positions, quaternions = self._merged_mapping() builder, stage_info, site_index_map, world_xforms, source_builders = _build_newton_builder_from_mapping( stage=self.stage, sources=sources, destinations=destinations, env_ids=env_ids, mapping=mapping, positions=positions, quaternions=quaternions, up_axis=self.up_axis, load_visual_shapes=self.load_visual_shapes, ) fabric_body_bindings = rename_builder_labels(builder, sources, destinations, env_ids, mapping) if self.commit_to_manager: NewtonManager._cl_site_index_map = site_index_map NewtonManager._cl_fabric_body_bindings = fabric_body_bindings NewtonManager._world_xforms = world_xforms NewtonManager._cl_protos = source_builders NewtonManager.set_builder(builder) NewtonManager._num_envs = mapping.size(1) self._queue.clear() return builder, stage_info, site_index_map
PHYSICS_CONTEXT = NewtonReplicateContext """Physics-only replication context for Newton assets. USD replication is added automatically by :func:`~isaaclab.cloner.replicate` when the asset has a spawner and Kit is available.""" def newton_physics_replicate( stage: Usd.Stage, sources: Sequence[str], destinations: Sequence[str], env_ids: torch.Tensor, mapping: torch.Tensor, positions: torch.Tensor | None = None, quaternions: torch.Tensor | None = None, device: str = "cpu", up_axis: str = "Z", ): """Replicate prims into a Newton ``ModelBuilder`` using a per-source mapping. Args: stage: USD stage containing source assets. sources: Source prim paths used for cloning. destinations: Destination prim path templates. env_ids: Environment ids for destination worlds. mapping: Boolean source-to-environment mapping matrix. positions: Optional per-environment world positions. quaternions: Optional per-environment orientations in xyzw order. device: Device used by the finalized Newton model builder. up_axis: Up axis for the Newton model builder. Returns: Tuple of the populated Newton model builder and stage metadata. """ ctx = NewtonReplicateContext(stage, device=device, up_axis=up_axis, commit_to_manager=True) ctx.queue_mapping(sources, destinations, env_ids, mapping, positions=positions, quaternions=quaternions) builder, stage_info, _site_index_map = ctx.replicate() return builder, stage_info