From a3884c548f89e98753bff207b86535dea72ee2c3 Mon Sep 17 00:00:00 2001 From: camevor Date: Tue, 25 Aug 2026 15:54:15 +0200 Subject: [PATCH 1/9] Include env name in env-local site labels --- .../brewster-env-root-site-labels.rst | 7 ++++ .../cloner/newton_clone_utils.py | 32 +++++++++++---- .../isaaclab_newton/cloner/replicate.py | 1 + .../isaaclab_newton/physics/newton_manager.py | 37 +++++++++++------ .../ray_caster/newton_raycast_sensor.py | 5 ++- .../sim/views/newton_site_frame_view.py | 38 +++++++++++------- .../test/cloner/test_rename_builder_labels.py | 40 ++++++++++++++++++- .../test/sensors/test_site_injection.py | 33 ++++++++++++--- 8 files changed, 152 insertions(+), 41 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/brewster-env-root-site-labels.rst diff --git a/source/isaaclab_newton/changelog.d/brewster-env-root-site-labels.rst b/source/isaaclab_newton/changelog.d/brewster-env-root-site-labels.rst new file mode 100644 index 000000000000..e42eaa3fb046 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/brewster-env-root-site-labels.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed a bodyless per-environment site carrying the label ``ft_0`` in every environment. Such a + site is now registered with the destination template of the clone-plan row that requested it and + labelled from the environment it lands in, so it reads e.g. ``/World/envs/env_3/ft_0``. Sites are + still resolved by index, so consumers that look them up by label and index are unaffected. diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index a6b233f4a874..e1d0784a609c 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -186,6 +186,11 @@ def _invert_xform(xform: Sequence[float] | np.ndarray) -> np.ndarray: return np.concatenate([-_quat_rotate(quat_inv, xform[:3]), quat_inv]) +def _site_label(env_root: str | None, label: str) -> str: + """Site label, beneath *env_root* when it has one and bare otherwise.""" + return f"{env_root}/{label}" if env_root else label + + def replicate_builder_mapping( builder: ModelBuilder, sources: Sequence[str], @@ -195,12 +200,17 @@ def replicate_builder_mapping( source_builders: dict[str, ModelBuilder], *, source_site_indices: dict[int, dict[str, list[int]]] | None = None, - env_root_sites: dict[str, wp.transform] | None = None, + env_root_sites: dict[str, tuple[wp.transform, str | None]] | None = None, + env_ids: torch.Tensor | None = None, per_world_builder_hooks: Sequence[Callable[[ModelBuilder, int, list[float], list[float]], None]] = (), ) -> tuple[dict[str, list[list[int]]], list[wp.transform]]: - """Replicate source builders into per-env Newton worlds.""" + """Replicate source builders into per-env Newton worlds. + + ``env_root_sites`` maps a label to its transform and the destination template naming its env. + """ source_site_indices = source_site_indices or {} env_root_sites = env_root_sites or {} + env_ids_list = env_ids.tolist() if env_ids is not None else None num_worlds = mapping.size(1) local_site_map: dict[str, list[list[int]]] = {} positions_np = positions.detach().cpu().numpy().astype(np.float32, copy=False) @@ -223,8 +233,12 @@ def replicate_builder_mapping( # by world_xforms[0] so R_w = world_xform_w * inv(world_xform_0) lands each # copy at world_xform_w * xform. site_local_indices: dict[str, list[int]] = {} - for label, xform in env_root_sites.items(): - idx = source_builder.add_site(body=-1, xform=wp.transform_multiply(world_xforms[0], xform), label=label) + for label, (xform, destination_template) in env_root_sites.items(): + # Every copy shares one label, so ``rename_builder_labels`` names them per env -- + # exact here because ``can_batch`` requires the single row to cover every world. + root = sources[0] if destination_template and env_ids_list else None + site_xform = wp.transform_multiply(world_xforms[0], xform) + idx = source_builder.add_site(body=-1, xform=site_xform, label=_site_label(root, label)) site_local_indices.setdefault(label, []).append(idx) for label, indices in source_site_indices.get(id(source_builder), {}).items(): site_local_indices.setdefault(label, []).extend(indices) @@ -248,7 +262,8 @@ def replicate_builder_mapping( # Per-world placements for every env-root site, composed up front so the per-world loop # below only indexes rows. root_site_xforms = { - label: _compose_world_xforms(positions_np, quaternions_np, xform) for label, xform in env_root_sites.items() + label: (_compose_world_xforms(positions_np, quaternions_np, xform), destination_template) + for label, (xform, destination_template) in env_root_sites.items() } # Same for the source placements, but only for the occupied ``(row, col)`` pairs of the # mapping: composing a dense ``num_rows x num_worlds`` table would blow up on heterogeneous @@ -274,8 +289,11 @@ def replicate_builder_mapping( for col in range(num_worlds): builder.begin_world() - for label, world_site_xforms in root_site_xforms.items(): - site_idx = builder.add_site(body=-1, xform=world_site_xforms[col], label=label) + for label, (world_site_xforms, destination_template) in root_site_xforms.items(): + # Named here, not by ``rename_builder_labels``: that only rewrites labels in the + # worlds the requesting row covers, and an env-root site sits in every world. + env_root = destination_template.format(env_ids_list[col]) if destination_template and env_ids_list else None + site_idx = builder.add_site(body=-1, xform=world_site_xforms[col], label=_site_label(env_root, label)) local_site_map.setdefault(label, [[] for _ in range(num_worlds)])[col].append(site_idx) for row in rows_per_world[col]: diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py index 8d0f201ffe38..a7cb342a9339 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py @@ -174,6 +174,7 @@ def _build_newton_builder_from_mapping( *replicate_args, source_site_indices=source_sites, env_root_sites=root_sites, + env_ids=env_ids, per_world_builder_hooks=NewtonManager._per_world_builder_hooks, ) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 0765df0e3700..716ae9ac35d3 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -499,9 +499,9 @@ class NewtonManager(PhysicsManager): # CL: Cloning / Replication logic # TODO: These attributes support cloning-specific logic and should be moved into a cloner class # Pending site requests from sensors. - # Key: (body_pattern, per_world, xform_floats), Value: (label, wp.transform) - # identical (body_pattern, per_world, transform) reuses the same site. - _cl_pending_sites: dict[tuple[str | None, bool, tuple[float, ...]], tuple[str, wp.transform]] = {} + # Key: (body_pattern, per_world, xform_floats, destination_template), Value: (label, wp.transform) + # identical keys reuse the same site. + _cl_pending_sites: dict[tuple[str | None, bool, tuple[float, ...], str | None], tuple[str, wp.transform]] = {} # Maps each site label to its resolved global or local site entry. _GlobalSite = tuple[int, None] @@ -1203,14 +1203,21 @@ def _prepare_builder_for_finalize(cls, builder: ModelBuilder) -> None: """ @classmethod - def cl_register_site(cls, body_pattern: str | None, xform: wp.transform, *, per_world: bool = False) -> str: + def cl_register_site( + cls, + body_pattern: str | None, + xform: wp.transform, + *, + per_world: bool = False, + destination_template: str | None = None, + ) -> str: """Register a site request for injection into prototypes before replication. Sensors call this during ``__init__``. Sites are injected into prototype builders by :meth:`_cl_inject_sites` (called from ``newton_replicate``) before ``add_builder``, so they replicate correctly per-world. - Identical ``(body_pattern, per_world, transform)`` registrations share sites. + Identical ``(body_pattern, per_world, transform, destination_template)`` registrations share sites. The *body_pattern* is matched against prototype-local body labels (e.g. ``"Robot/link.*"``) when replication is active, or against the @@ -1225,14 +1232,19 @@ def cl_register_site(cls, body_pattern: str | None, xform: wp.transform, *, per_ xform: Site transform relative to body. per_world: When ``True``, ``body_pattern`` must be ``None`` and one bodyless site is created in each cloned world's frame. + destination_template: Destination template of the clone-plan row that requested a + ``per_world`` site, so its label names the environment it lands in. A site + registered without one reads the same in every environment. Returns: Assigned site label suffix. """ if per_world and body_pattern is not None: raise ValueError("per_world site registration requires body_pattern=None.") + if destination_template is not None and not per_world: + raise ValueError("destination_template applies to per_world site registration.") xform_key = tuple(xform) - key = (body_pattern, per_world, xform_key) + key = (body_pattern, per_world, xform_key, destination_template) if key in cls._cl_pending_sites: return cls._cl_pending_sites[key][0] label = f"ft_{len(cls._cl_pending_sites)}" @@ -1270,7 +1282,7 @@ def _cl_inject_sites( cls, main_builder: ModelBuilder, source_builders: dict[str, ModelBuilder], - ) -> tuple[dict[str, int], dict[int, dict[str, list[int]]], dict[str, wp.transform]]: + ) -> tuple[dict[str, int], dict[int, dict[str, list[int]]], dict[str, tuple[wp.transform, str | None]]]: """Inject registered sites into source builders before replication. Non-global sites are matched against source builder body labels using @@ -1291,16 +1303,17 @@ def _cl_inject_sites( Tuple of ``(global_site_indices, source_site_indices, env_root_sites)`` where *global_site_indices* maps ``{label: main_builder_shape_idx}``, *source_site_indices* maps ``{id(source_builder): {label: [source_local_shape_idx, ...]}}``, - and *env_root_sites* maps ``{label: env_root_relative_transform}``. + and *env_root_sites* maps ``{label: (env_root_relative_transform, destination_template)}``, + where *destination_template* names the environment the site lands in, or ``None``. """ global_site_indices: dict[str, int] = {} source_site_indices: dict[int, dict[str, list[int]]] = {} - env_root_sites: dict[str, wp.transform] = {} + env_root_sites: dict[str, tuple[wp.transform, str | None]] = {} - for (body_pattern, per_world, _xform_key), (label, xform) in cls._cl_pending_sites.items(): + for (body_pattern, per_world, _xform_key, template), (label, xform) in cls._cl_pending_sites.items(): if per_world: - env_root_sites[label] = xform + env_root_sites[label] = (xform, template) continue if body_pattern is None: site_idx = main_builder.add_site(body=-1, xform=xform, label=label) @@ -1348,7 +1361,7 @@ def _cl_inject_sites_fallback(cls) -> None: builder = cls._builder body_labels = list(builder.body_label) - for (body_pattern, per_world, _xform_key), (label, xform) in cls._cl_pending_sites.items(): + for (body_pattern, per_world, _xform_key, _template), (label, xform) in cls._cl_pending_sites.items(): if per_world: site_idx = builder.add_site(body=-1, xform=xform, label=label) cls._cl_site_index_map[label] = (None, [[site_idx]]) diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py index 04a519db26eb..c5685bdc8cc3 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py @@ -104,7 +104,10 @@ def _register_sites_for_expr(self, prim_expr: str) -> list[str]: for destination_template in plan.destinations: matched = cloner.path.match(prim_expr, destination_template) if matched is not None and not matched.suffix: - return [NewtonManager.cl_register_site(None, wp.transform(), per_world=True)] + site = NewtonManager.cl_register_site( + None, wp.transform(), per_world=True, destination_template=destination_template + ) + return [site] try: body_expr, fixed_pos, fixed_quat = self._resolve_rigid_body_ancestor_expr() diff --git a/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py index 89e5d528889c..9f0f17ca6c5a 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py @@ -177,6 +177,17 @@ def _scatter_xform_scales( site_xform_scale[indices[i]] = new_scales[i] +_SiteSpec = tuple[ + tuple[str, ...] | None, # body label patterns, or None for a bodyless site + wp.transform, # site transform [m], body-local when body patterns are set, else env-root-local + tuple[float, float, float], # scale + bool, # bodyless site: one per world rather than one globally + tuple[int, ...] | None, # env ids the site covers, or None for all of them + str | None, # destination template naming the env it lands in, for a per-world site +] +"""One resolved site request.""" + + class NewtonSiteFrameView(BaseFrameView): """Batched Newton site view for non-physics frames. @@ -233,9 +244,11 @@ def __init__( if model is not None: self._initialize_from_specs(model) else: - for body_patterns, xform, scale, per_world, _env_ids in self._site_specs: + for body_patterns, xform, scale, per_world, _env_ids, template in self._site_specs: if body_patterns is None: - self._site_labels.append(NewtonManager.cl_register_site(None, xform, per_world=per_world)) + self._site_labels.append( + NewtonManager.cl_register_site(None, xform, per_world=per_world, destination_template=template) + ) self._site_label_scales.append(scale) else: for body_pattern in body_patterns: @@ -245,18 +258,14 @@ def __init__( self._on_physics_ready, PhysicsEvent.PHYSICS_READY, name=f"site_view_{self._prim_path}" ) - def _resolve_site_specs( - self, stage, validate_xform_ops: bool - ) -> list[tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None]]: + def _resolve_site_specs(self, stage, validate_xform_ops: bool) -> list[_SiteSpec]: """Resolve source prims into Newton site registration specs.""" plan = sim_utils.SimulationContext.instance().get_clone_plan() model = NewtonManager.get_model() body_labels = list(model.body_label) if model is not None else () shape_labels = list(model.shape_label) if model is not None else () use_clone_body_pattern = model is None - specs: list[ - tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None] - ] = [] + specs: list[_SiteSpec] = [] for path_expr in self._prim_paths: if resolve_matching_names(path_expr, body_labels, raise_when_no_match=False)[1]: @@ -313,7 +322,7 @@ def _resolve_source_prim( env_ids: tuple[int, ...] | None, use_clone_body_pattern: bool, stage, - ) -> tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None]: + ) -> _SiteSpec: """Resolve one source prim into body patterns, local frame, and xform scale.""" prim_path = prim.GetPath().pathString if prim.HasAPI(UsdPhysics.RigidBodyAPI) or prim.HasAPI(UsdPhysics.ArticulationRootAPI): @@ -352,7 +361,8 @@ def _resolve_source_prim( raise RuntimeError( f"FrameView destination root '{destination_root}' does not end with '{suffix}'." ) - return (destination_root[: -len(suffix)],), wp.transform(pos, quat), scale, False, env_ids + root = destination_root[: -len(suffix)] + return (root,), wp.transform(pos, quat), scale, False, env_ids, None body_patterns = [] for env_id in env_ids: destination_root = destination_template.format(env_id) @@ -361,7 +371,7 @@ def _resolve_source_prim( f"FrameView destination root '{destination_root}' does not end with '{suffix}'." ) body_patterns.append(destination_root[: -len(suffix)]) - return tuple(body_patterns), wp.transform(pos, quat), scale, False, env_ids + return tuple(body_patterns), wp.transform(pos, quat), scale, False, env_ids, None else: raise RuntimeError(f"FrameView source body '{body_path}' is not under '{source_root}'.") if use_clone_body_pattern: @@ -370,7 +380,7 @@ def _resolve_source_prim( body_patterns = tuple(destination_template.format(env_id) + suffix for env_id in env_ids) else: body_patterns = (body_path,) - return body_patterns, wp.transform(pos, quat), scale, False, env_ids + return body_patterns, wp.transform(pos, quat), scale, False, env_ids, None body_prim = body_prim.GetParent() ref_path = source_root @@ -381,7 +391,7 @@ def _resolve_source_prim( ref_path = source_root[: -len(source_suffix)] if source_suffix else source_root ref_prim = stage.GetPrimAtPath(ref_path) if ref_path is not None else None pos, quat = sim_utils.resolve_prim_pose(prim, ref_prim if ref_prim and ref_prim.IsValid() else None) - return None, wp.transform(pos, quat), scale, source_root is not None, env_ids + return None, wp.transform(pos, quat), scale, source_root is not None, env_ids, destination_template def _on_physics_ready(self, _event) -> None: """Callback invoked when the Newton model becomes available.""" @@ -421,7 +431,7 @@ def _initialize_from_specs(self, model) -> None: site_locals: list[list[float]] = [] site_scales: list[tuple[float, float, float]] = [] - for body_patterns, xform, scale, per_world, env_ids in self._site_specs: + for body_patterns, xform, scale, per_world, env_ids, _template in self._site_specs: if body_patterns is None: if per_world: if NewtonManager._world_xforms is None: diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index ee81d39e83b7..b90ed6cae71c 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -400,7 +400,8 @@ def test_env_root_sites_batched_at_correct_world_positions(self): positions, quaternions, {_SRC: source}, - env_root_sites={"origin": env_root_offset}, + env_root_sites={"origin": (env_root_offset, None)}, + env_ids=torch.arange(3, dtype=torch.long), ) replicate.assert_called_once() @@ -555,5 +556,42 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) ) +class TestEnvRootSiteLabels(unittest.TestCase): + """A per-world site names the environment it lands in, rather than repeating one name.""" + + _DST = "/World/envs/env_{}/Robot" + _WORLDS = 4 + + def _site_labels(self, sources, mapping): + """Replicate one env-root site across worlds and return its label in each, in world order.""" + builder = newton.ModelBuilder() + env_ids = torch.arange(self._WORLDS, dtype=torch.long) + replicate_builder_mapping( + builder, + sources, + mapping, + torch.zeros((self._WORLDS, 3)), + torch.tensor([[0.0, 0.0, 0.0, 1.0]] * self._WORLDS), + {source: newton.ModelBuilder() for source in sources}, + env_root_sites={"ft_0": (wp.transform(), self._DST)}, + env_ids=env_ids, + ) + rename_builder_labels(builder, sources, [self._DST] * len(sources), env_ids, mapping) + return [label for label in builder.shape_label if label.endswith("ft_0")] + + def _expected(self): + return [self._DST.format(env_id) + "/ft_0" for env_id in range(self._WORLDS)] + + def test_one_row_covering_every_world_names_each_environment(self): + mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) + self.assertEqual(self._site_labels(("/World/envs/env_0/Robot",), mapping), self._expected()) + + def test_rows_partitioning_the_worlds_name_each_environment(self): + """Prototype variants split the envs between them, so no single row covers every world.""" + sources = ("/World/envs/env_0/Robot", "/World/envs/env_2/Robot") + mapping = torch.tensor([[True, True, False, False], [False, False, True, True]]) + self.assertEqual(self._site_labels(sources, mapping), self._expected()) + + if __name__ == "__main__": unittest.main() diff --git a/source/isaaclab_newton/test/sensors/test_site_injection.py b/source/isaaclab_newton/test/sensors/test_site_injection.py index 0b9fec3c8adc..31456e039918 100644 --- a/source/isaaclab_newton/test/sensors/test_site_injection.py +++ b/source/isaaclab_newton/test/sensors/test_site_injection.py @@ -86,7 +86,7 @@ def setup_method(self): def test_global_site_entry_is_int_none_tuple(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {(None, False, tuple(xform)): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {(None, False, tuple(xform), None): ("ft_0", xform)} NewtonManager._cl_inject_sites_fallback() entry = NewtonManager._cl_site_index_map["ft_0"] @@ -96,7 +96,7 @@ def test_global_site_entry_is_int_none_tuple(self): def test_global_site_pending_cleared(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {(None, False, tuple(xform)): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {(None, False, tuple(xform), None): ("ft_0", xform)} NewtonManager._cl_inject_sites_fallback() assert len(NewtonManager._cl_pending_sites) == 0 @@ -111,7 +111,7 @@ def setup_method(self): def test_single_body_entry_shape(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {("Robot/base", False, tuple(xform)): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {("Robot/base", False, tuple(xform), None): ("ft_0", xform)} NewtonManager._cl_inject_sites_fallback() entry = NewtonManager._cl_site_index_map["ft_0"] @@ -132,7 +132,7 @@ def setup_method(self): def test_wildcard_entry_shape(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {("Robot/.*_foot", False, tuple(xform)): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {("Robot/.*_foot", False, tuple(xform), None): ("ft_0", xform)} NewtonManager._cl_inject_sites_fallback() entry = NewtonManager._cl_site_index_map["ft_0"] @@ -143,7 +143,7 @@ def test_wildcard_entry_shape(self): def test_no_match_raises(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {("Robot/nonexistent", False, tuple(xform)): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {("Robot/nonexistent", False, tuple(xform), None): ("ft_0", xform)} with pytest.raises(ValueError): NewtonManager._cl_inject_sites_fallback() @@ -180,9 +180,30 @@ def test_inject_sites_returns_world_sites(self): assert global_sites == {} assert proto_sites == {} - assert world_sites[label] == xform + assert world_sites[label] == (xform, None) assert NewtonManager._cl_pending_sites == {} + def test_a_world_site_carries_the_template_naming_its_environment(self): + """So replication can label it with the env it lands in rather than one shared name.""" + xform = wp.transform((1.0, 2.0, 3.0), wp.quat_identity()) + template = "/World/envs/env_{}/Robot" + label = NewtonManager.cl_register_site(None, xform, per_world=True, destination_template=template) + _, _, world_sites = NewtonManager._cl_inject_sites(MockBuilder([]), {}) + + assert world_sites[label] == (xform, template) + + def test_world_sites_with_different_templates_get_different_labels(self): + xform = wp.transform() + label_0 = NewtonManager.cl_register_site(None, xform, per_world=True, destination_template="/World/env_{}/A") + label_1 = NewtonManager.cl_register_site(None, xform, per_world=True, destination_template="/World/env_{}/B") + + assert label_0 != label_1 + + def test_destination_template_requires_a_per_world_site(self): + xform = wp.transform() + with pytest.raises(ValueError): + NewtonManager.cl_register_site(None, xform, destination_template="/World/envs/env_{}/Robot") + # --------------------------------------------------------------------------- # FrameTransformer._validate_site_map From 5b12d9b20fa8f9eb8e727d7751648395df1e4c50 Mon Sep 17 00:00:00 2001 From: camevor Date: Wed, 26 Aug 2026 11:59:43 +0200 Subject: [PATCH 2/9] Delegate per-env clone naming to `ModelBuilder.replicate()` Rewriting every merged label after replication is a second full pass over what replication just produced, and it scales with environment count. `replicate()` now takes a per-world label prefix, so the prototype's labels are rebased once and each copy comes out named for the environment it lands in; `ClonePlan` records `env_template` to supply that boundary. A prototype the boundary cannot express keeps the old path, and the string custom attributes are still rewritten here. --- .../brewster-replication-names-copies.rst | 9 ++ source/isaaclab/isaaclab/cloner/clone_plan.py | 11 ++ .../isaaclab/cloner/replicate_session.py | 2 +- source/isaaclab/isaaclab/cloner/usd.py | 3 +- .../test/cloner/test_replicate_session.py | 3 +- ...test_newton_manager_visualization_state.py | 2 +- .../brewster-replication-names-copies.rst | 10 ++ .../cloner/newton_clone_utils.py | 88 +++++++++-- .../isaaclab_newton/cloner/replicate.py | 28 +++- .../isaaclab_newton/physics/newton_manager.py | 2 +- .../test/cloner/test_rename_builder_labels.py | 148 +++++++++++++++++- .../test/physics/test_vbd_core.py | 2 +- .../brewster-replication-names-copies.skip | 1 + .../isaaclab_ov/cloner/replicate.py | 3 +- 14 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 source/isaaclab/changelog.d/brewster-replication-names-copies.rst create mode 100644 source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst create mode 100644 source/isaaclab_ov/changelog.d/brewster-replication-names-copies.skip diff --git a/source/isaaclab/changelog.d/brewster-replication-names-copies.rst b/source/isaaclab/changelog.d/brewster-replication-names-copies.rst new file mode 100644 index 000000000000..fc0492e71eaf --- /dev/null +++ b/source/isaaclab/changelog.d/brewster-replication-names-copies.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab.cloner.ClonePlan.env_template`, the destination template for one + environment. Every row's destination is that template followed by the asset's path below the + environment, so it names the part a clone varies while the remainder is shared. It was + previously a constructor argument that the plan discarded, leaving a consumer holding a row + unable to recover it -- a destination carries no mark of where the environment ends. Backend + replication contexts receive it alongside ``global_paths``. diff --git a/source/isaaclab/isaaclab/cloner/clone_plan.py b/source/isaaclab/isaaclab/cloner/clone_plan.py index 3b47592ac026..19e132683eee 100644 --- a/source/isaaclab/isaaclab/cloner/clone_plan.py +++ b/source/isaaclab/isaaclab/cloner/clone_plan.py @@ -64,6 +64,13 @@ class ClonePlan: global_paths: tuple[str, ...] = () """Unique prim paths for scene assets shared by every environment.""" + env_template: str = DEFAULT_ENV_TEMPLATE + """Destination template for one environment, with ``"{}"`` for the env id. + + Every row's destination is this template followed by the asset's path below the + environment, so this names the part a clone varies from the part it shares. + """ + def grid_transforms(N: int, spacing: float = 1.0, up_axis: str = "z", device="cpu"): """Create a centered grid of transforms for ``N`` instances. @@ -293,6 +300,7 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: positions=positions, cfg_rows={}, global_paths=global_paths, + env_template=env_template, ) # 3) Homogeneous (every cfg is single-variant): emit the simpler env-root plan. @@ -308,6 +316,7 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: positions=positions, cfg_rows=cfg_rows, global_paths=global_paths, + env_template=env_template, ) # 4) Heterogeneous: enumerate prototype combos, build per-row mask, mutate spawn paths. @@ -377,6 +386,7 @@ def validate_combo_tensor(combos: torch.Tensor, name: str, expected_rows: int | positions=positions, cfg_rows=cfg_rows, global_paths=global_paths, + env_template=env_template, ) @@ -419,4 +429,5 @@ def clone_plan_from_env_0( positions=positions, cfg_rows=cfg_rows, global_paths=global_paths, + env_template=destination, ) diff --git a/source/isaaclab/isaaclab/cloner/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index 34dcb3afc94c..12704e434c61 100644 --- a/source/isaaclab/isaaclab/cloner/replicate_session.py +++ b/source/isaaclab/isaaclab/cloner/replicate_session.py @@ -99,7 +99,7 @@ def replicate(plan: ClonePlan, *, stage: Usd.Stage, replicate_physics: bool = Tr backend_ctxs: dict[type, Any] = {} for BackendCtxCls, row_set in backend_rows.items(): - ctx = BackendCtxCls(stage, global_paths=plan.global_paths) + ctx = BackendCtxCls(stage, global_paths=plan.global_paths, env_template=plan.env_template) backend_ctxs[BackendCtxCls] = ctx row_list = sorted(row_set) ctx.queue_mapping( diff --git a/source/isaaclab/isaaclab/cloner/usd.py b/source/isaaclab/isaaclab/cloner/usd.py index 187a00429154..7094cf1cb56a 100644 --- a/source/isaaclab/isaaclab/cloner/usd.py +++ b/source/isaaclab/isaaclab/cloner/usd.py @@ -12,6 +12,7 @@ from pxr import Gf, Sdf, Usd, UsdGeom, Vt from ._fabric_notices import disabled_fabric_change_notifies +from .cloner_cfg import DEFAULT_ENV_TEMPLATE from .path import split @@ -30,7 +31,7 @@ class UsdReplicateContext: replicate_priority = 100 - def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): + def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = (), env_template: str = DEFAULT_ENV_TEMPLATE): """Initialize the context. Args: diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index 638a0ed86fa8..a59c5f4cbc73 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -35,8 +35,9 @@ class FakeUsdContext: replicate_priority = 100 instances: list["FakeUsdContext"] = [] - def __init__(self, stage, *, global_paths): + def __init__(self, stage, *, global_paths, env_template): self.global_paths = global_paths + self.env_template = env_template FakeUsdContext.instances.append(self) def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None): diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index 823afc3e222e..8db96a603e84 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -836,7 +836,7 @@ def test_clone_visualization_builder_ignores_non_env_deformables_on_world_import monkeypatch.setattr(vb, "_restore_visible_colliders_without_visual_shapes", lambda *args, **kwargs: None) monkeypatch.setattr(vb, "import_builder_visual_material_paths", lambda *args, **kwargs: None) monkeypatch.setattr(vb, "build_source_builders", lambda *args, **kwargs: {}) - monkeypatch.setattr(vb, "replicate_builder_mapping", lambda *args, **kwargs: None) + monkeypatch.setattr(vb, "replicate_builder_mapping", lambda *args, **kwargs: ({}, [], False)) monkeypatch.setattr(vb, "rename_builder_labels", lambda *args, **kwargs: None) _builder, (shadow_entities, registry_groups) = vb.build_visualization_builder_from_stage_envs( diff --git a/source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst b/source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst new file mode 100644 index 000000000000..fbe2e8714a9c --- /dev/null +++ b/source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst @@ -0,0 +1,10 @@ +Changed +^^^^^^^ + +* Changed the homogeneous Newton cloning path to let replication name each cloned entity for + the environment it lands in, instead of rewriting every replicated label afterwards. The + prototype's labels are rebased once -- a few hundred entries -- and + :meth:`~newton.ModelBuilder.replicate` is given the per-env roots, replacing a pass over + every label in every world that cost 215 ms on ``Isaac-Velocity-Flat-G1`` at 4096 + environments. The labels are identical either way; a prototype whose labels a per-world + prefix cannot spell keeps the previous path. diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index e1d0784a609c..017ecbe7d89f 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -16,6 +16,7 @@ from pxr import Usd, UsdGeom, UsdPhysics from isaaclab.cloner import path as clone_path +from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from isaaclab.sim.utils.newton_model_utils import replace_newton_builder_shape_colors from isaaclab_newton.renderers.visual_material import import_builder_visual_material_paths @@ -191,6 +192,32 @@ def _site_label(env_root: str | None, label: str) -> str: return f"{env_root}/{label}" if env_root else label +def _rebase_to_env(builder: ModelBuilder, env_root: str) -> bool: + """Rewrite every entity label relative to its environment root, in place. + + Replication makes N copies that differ only in the environment they sit in, so a label is + ```` and only the first part varies. Returns whether every label could be + written that way: one outside the environment, or one naming the environment root itself, + has no within-environment part a per-world prefix could carry. + """ + rebased = [] + for labels in ( + builder.body_label, + builder.joint_label, + builder.shape_label, + builder.articulation_label, + builder.constraint_mimic_label, + ): + for index, label in enumerate(labels): + suffix = clone_path.relative_to(label, env_root) if isinstance(label, str) else None + if not suffix: + return False + rebased.append((labels, index, suffix.lstrip("/"))) + for labels, index, suffix in rebased: + labels[index] = suffix + return True + + def replicate_builder_mapping( builder: ModelBuilder, sources: Sequence[str], @@ -202,11 +229,20 @@ def replicate_builder_mapping( source_site_indices: dict[int, dict[str, list[int]]] | None = None, env_root_sites: dict[str, tuple[wp.transform, str | None]] | None = None, env_ids: torch.Tensor | None = None, + env_template: str = DEFAULT_ENV_TEMPLATE, per_world_builder_hooks: Sequence[Callable[[ModelBuilder, int, list[float], list[float]], None]] = (), -) -> tuple[dict[str, list[list[int]]], list[wp.transform]]: +) -> tuple[dict[str, list[list[int]]], list[wp.transform], bool]: """Replicate source builders into per-env Newton worlds. - ``env_root_sites`` maps a label to its transform and the destination template naming its env. + Returns the per-env site indices, the per-env world transforms, and whether replication + already named each entity for the env it landed in. + + Args: + env_root_sites: Site transform and the destination template naming its env, per label. + env_ids: Environment ids for the destination worlds. Given with an environment that + owns the prototype, replication names each copy and the caller does not have to + rewrite the entity labels afterwards. + env_template: Destination template for one environment, from the clone plan. """ source_site_indices = source_site_indices or {} env_root_sites = env_root_sites or {} @@ -234,9 +270,9 @@ def replicate_builder_mapping( # copy at world_xform_w * xform. site_local_indices: dict[str, list[int]] = {} for label, (xform, destination_template) in env_root_sites.items(): - # Every copy shares one label, so ``rename_builder_labels`` names them per env -- - # exact here because ``can_batch`` requires the single row to cover every world. - root = sources[0] if destination_template and env_ids_list else None + # Every copy shares one label; the env name is applied after, by ``label_prefixes`` + # below or by ``rename_builder_labels``. ``can_batch`` makes either one exact. + root = sources[0] if destination_template else None site_xform = wp.transform_multiply(world_xforms[0], xform) idx = source_builder.add_site(body=-1, xform=site_xform, label=_site_label(root, label)) site_local_indices.setdefault(label, []).append(idx) @@ -248,14 +284,22 @@ def replicate_builder_mapping( stride = source_builder.shape_count source_xform_inv = _invert_xform(xforms_np[0]) xforms = _compose_world_xforms(positions_np, quaternions_np, source_xform_inv) - builder.replicate(source_builder, num_worlds, xforms=xforms) + + # One source populating every world is the shape replication can name itself: rebase the + # prototype's labels once -- a few hundred entries -- and let each copy carry its own + # env root, instead of rewriting every label in every world afterwards. + label_prefixes = None + prototype_env = clone_path.match(sources[0], env_template) if env_ids is not None else None + if prototype_env is not None and _rebase_to_env(source_builder, env_template.format(prototype_env.instance)): + label_prefixes = [env_template.format(env_id) for env_id in env_ids.tolist()] + builder.replicate(source_builder, num_worlds, xforms=xforms, label_prefixes=label_prefixes) for label, local_indices in site_local_indices.items(): local_site_map[label] = [ [base_shape + world * stride + local for local in local_indices] for world in range(num_worlds) ] - return local_site_map, world_xforms + return local_site_map, world_xforms, label_prefixes is not None source_world_indices = mapping.to(dtype=torch.int64).argmax(dim=1).tolist() @@ -309,7 +353,7 @@ def replicate_builder_mapping( hook(builder, col, xform_rows[col][:3], xform_rows[col][3:]) builder.end_world() - return local_site_map, world_xforms + return local_site_map, world_xforms, False _BUILTIN_LABEL_TYPES: tuple[str, ...] = ( @@ -328,8 +372,17 @@ def rename_builder_labels( destinations: Sequence[str], env_ids: torch.Tensor, mapping: torch.Tensor, + *, + skip_entity_labels: bool = False, ) -> list[tuple[str, int]]: - """Rewrite source-root labels to per-env destination roots and return Fabric body bindings.""" + """Rewrite source-root labels to per-env destination roots and return Fabric body bindings. + + Args: + skip_entity_labels: Whether the entity labels already name the env they are in, as they + do when replication was given the per-env prefixes. Only the string custom + attributes are rewritten then, since Newton cannot tell which of those name + entities. + """ fabric_body_bindings: list[tuple[str, int]] = [] bound_body_indices: set[int] = set() env_ids_list = env_ids.tolist() @@ -364,14 +417,15 @@ def _rename_pair(values, worlds, src_root=source_root, roots=world_roots, *, col fabric_body_bindings.append((renamed_value, index)) bound_body_indices.add(index) - for labels, worlds, collect_body_bindings in ( - (builder.body_label, builder.body_world, True), - (builder.joint_label, builder.joint_world, False), - (builder.shape_label, builder.shape_world, False), - (builder.articulation_label, builder.articulation_world, False), - (builder.constraint_mimic_label, builder.constraint_mimic_world, False), - ): - _rename_pair(labels, worlds, collect_body_bindings=collect_body_bindings) + if not skip_entity_labels: + for labels, worlds, collect_body_bindings in ( + (builder.body_label, builder.body_world, True), + (builder.joint_label, builder.joint_world, False), + (builder.shape_label, builder.shape_world, False), + (builder.articulation_label, builder.articulation_world, False), + (builder.constraint_mimic_label, builder.constraint_mimic_world, False), + ): + _rename_pair(labels, worlds, collect_body_bindings=collect_body_bindings) custom_attrs = builder.custom_attributes.values() worlds_by_freq = {attr.frequency: attr.values for attr in custom_attrs if attr.references == "world"} diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py index a7cb342a9339..54a02c6a59df 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py @@ -18,6 +18,7 @@ from pxr import Usd +from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from isaaclab.physics import PhysicsManager from isaaclab.sim.utils.newton_model_utils import replace_newton_builder_shape_colors @@ -106,12 +107,14 @@ def _build_newton_builder_from_mapping( up_axis: str = "Z", load_visual_shapes: bool = True, global_paths: tuple[str, ...] = (), -) -> tuple[ModelBuilder, object, dict, list, dict[str, ModelBuilder]]: + env_template: str = DEFAULT_ENV_TEMPLATE, +) -> tuple[ModelBuilder, object, dict, list, dict[str, ModelBuilder], bool]: """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. + batched Newton IK action, and whether replication already named each entity for the env it + landed in. """ if positions is None: positions = torch.zeros((mapping.size(1), 3), device=mapping.device, dtype=torch.float32) @@ -170,17 +173,18 @@ def _build_newton_builder_from_mapping( 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( + local_site_map, world_xforms, labels_are_per_env = replicate_builder_mapping( *replicate_args, source_site_indices=source_sites, env_root_sites=root_sites, env_ids=env_ids, + env_template=env_template, 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 + return builder, stage_info, site_index_map, world_xforms, source_builders, labels_are_per_env def _renderer_wants_visual_shapes() -> bool: @@ -207,6 +211,7 @@ def __init__( self, stage: Usd.Stage, global_paths: tuple[str, ...] = (), + env_template: str = DEFAULT_ENV_TEMPLATE, device: str = "cpu", up_axis: str = "Z", load_visual_shapes: bool | None = None, @@ -226,6 +231,7 @@ def __init__( :class:`NewtonManager`. """ self.stage = stage + self.env_template = env_template self._global_paths = global_paths self.device = device self.up_axis = up_axis @@ -310,7 +316,14 @@ def _merged_mapping(self) -> _MappingBatch: 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( + ( + builder, + stage_info, + site_index_map, + world_xforms, + source_builders, + labels_are_per_env, + ) = _build_newton_builder_from_mapping( stage=self.stage, sources=sources, destinations=destinations, @@ -319,10 +332,13 @@ def replicate(self) -> tuple[ModelBuilder, object, dict]: positions=positions, quaternions=quaternions, up_axis=self.up_axis, + env_template=self.env_template, load_visual_shapes=self.load_visual_shapes, global_paths=self._global_paths, ) - fabric_body_bindings = rename_builder_labels(builder, sources, destinations, env_ids, mapping) + fabric_body_bindings = rename_builder_labels( + builder, sources, destinations, env_ids, mapping, skip_entity_labels=labels_are_per_env + ) if self.commit_to_manager: NewtonManager._cl_site_index_map = site_index_map NewtonManager._cl_fabric_body_bindings = fabric_body_bindings diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 716ae9ac35d3..693d4b404b05 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -1957,7 +1957,7 @@ def instantiate_builder_from_stage(cls): quaternions = torch.tensor([quat for _, quat in poses], dtype=torch.float32) mapping = torch.ones((1, len(env_paths)), dtype=torch.bool) replicate_args = (builder, (proto_path,), mapping, positions, quaternions, source_builders) - local_site_map, world_xforms = replicate_builder_mapping( + local_site_map, world_xforms, _ = replicate_builder_mapping( *replicate_args, source_site_indices=source_site_indices, env_root_sites=env_root_sites, diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index b90ed6cae71c..60bda125b765 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -364,7 +364,7 @@ def test_source_local_sites_batched_with_correct_indices(self): quaternions = torch.tensor([[0.0, 0.0, 0.0, 1.0]] * 3) with mock.patch.object(builder, "replicate", wraps=builder.replicate) as replicate: - local_site_map, _ = replicate_builder_mapping( + local_site_map, _, _ = replicate_builder_mapping( builder, (_SRC,), torch.ones((1, 3), dtype=torch.bool), @@ -393,7 +393,7 @@ def test_env_root_sites_batched_at_correct_world_positions(self): env_root_offset = wp.transform((0.1, 0.0, 0.0), wp.quat_identity()) with mock.patch.object(builder, "replicate", wraps=builder.replicate) as replicate: - local_site_map, _ = replicate_builder_mapping( + local_site_map, _, _ = replicate_builder_mapping( builder, (_SRC,), torch.ones((1, 3), dtype=torch.bool), @@ -593,5 +593,149 @@ def test_rows_partitioning_the_worlds_name_each_environment(self): self.assertEqual(self._site_labels(sources, mapping), self._expected()) +class TestReplicationNamesItsCopies: + """Replication naming each copy must give exactly what rewriting afterwards gives.""" + + _SRC, _DST, _WORLDS = "/World/envs/env_0/Robot", "/World/envs/env_{}/Robot", 4 + _ENV = "/World/envs/env_{}" + + @classmethod + def _prototype(cls) -> newton.ModelBuilder: + source = newton.ModelBuilder() + body = source.add_link(xform=wp.transform(), label=cls._SRC) + source.add_shape_box(body=body, label=f"{cls._SRC}/base") + child = source.add_link(xform=wp.transform(), label=f"{cls._SRC}/link") + source.add_joint_revolute(parent=body, child=child, axis=(0.0, 0.0, 1.0), label=f"{cls._SRC}/hinge") + source.add_articulation([0], label=cls._SRC) + return source + + def _run(self, *, delegate: bool, env_root_site: bool = False): + builder = newton.ModelBuilder() + env_ids = torch.arange(self._WORLDS, dtype=torch.long) + mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) + positions = torch.zeros((self._WORLDS, 3), dtype=torch.float32) + quaternions = torch.zeros((self._WORLDS, 4), dtype=torch.float32) + quaternions[:, 3] = 1.0 + extra = {"env_ids": env_ids, "env_template": self._ENV} if delegate else {} + sites = {"ft_0": (wp.transform(), self._DST)} if env_root_site else {} + _, _, named = replicate_builder_mapping( + builder, + [self._SRC], + mapping, + positions, + quaternions, + {self._SRC: self._prototype()}, + env_root_sites=sites, + **extra, + ) + rename_builder_labels(builder, [self._SRC], [self._DST], env_ids, mapping, skip_entity_labels=named) + return builder, named + + @staticmethod + def _labels(builder) -> dict[str, list[str]]: + return { + name: list(getattr(builder, name)) + for name in ("body_label", "joint_label", "shape_label", "articulation_label") + } + + def test_every_label_matches_the_rewritten_path(self): + delegated, named = self._run(delegate=True) + rewritten, _ = self._run(delegate=False) + + assert named is True + assert self._labels(delegated) == self._labels(rewritten) + + def test_an_env_root_site_matches_too(self): + delegated, named = self._run(delegate=True, env_root_site=True) + rewritten, _ = self._run(delegate=False, env_root_site=True) + + assert named is True + assert self._labels(delegated) == self._labels(rewritten) + + def test_each_copy_names_its_own_environment(self): + builder, _ = self._run(delegate=True) + + assert builder.shape_label == [f"{self._DST.format(env_id)}/base" for env_id in range(self._WORLDS)] + + def test_a_prototype_outside_the_environments_is_not_delegated(self): + """A prototype that is not an instance of the env template has no env to prefix with.""" + builder = newton.ModelBuilder() + env_ids = torch.arange(self._WORLDS, dtype=torch.long) + mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) + positions = torch.zeros((self._WORLDS, 3), dtype=torch.float32) + quaternions = torch.zeros((self._WORLDS, 4), dtype=torch.float32) + quaternions[:, 3] = 1.0 + source = newton.ModelBuilder() + source.add_link(xform=wp.transform(), label="/Sources/protoA") + + _, _, named = replicate_builder_mapping( + builder, + ["/Sources/protoA"], + mapping, + positions, + quaternions, + {"/Sources/protoA": source}, + env_ids=env_ids, + env_template="/World/envs/env_{}", + ) + + assert named is False + + def test_a_label_outside_the_prototype_is_not_delegated(self): + """Replication prefixes every label, so one that is not under the split cannot be spelled.""" + source = self._prototype() + source.add_link(xform=wp.transform(), label="/elsewhere/beacon") + + builder = newton.ModelBuilder() + env_ids = torch.arange(self._WORLDS, dtype=torch.long) + mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) + positions = torch.zeros((self._WORLDS, 3), dtype=torch.float32) + quaternions = torch.zeros((self._WORLDS, 4), dtype=torch.float32) + quaternions[:, 3] = 1.0 + _, _, named = replicate_builder_mapping( + builder, + [self._SRC], + mapping, + positions, + quaternions, + {self._SRC: source}, + env_ids=env_ids, + env_template=self._ENV, + ) + + assert named is False + assert "/elsewhere/beacon" in builder.body_label + + def test_a_generated_sibling_of_the_root_is_rebased(self): + """add_body derives "_free_joint", which the split above the leaf still covers.""" + source = newton.ModelBuilder() + source.add_body(xform=wp.transform(), label=self._SRC) + + builder = newton.ModelBuilder() + env_ids = torch.arange(self._WORLDS, dtype=torch.long) + mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) + positions = torch.zeros((self._WORLDS, 3), dtype=torch.float32) + quaternions = torch.zeros((self._WORLDS, 4), dtype=torch.float32) + quaternions[:, 3] = 1.0 + _, _, named = replicate_builder_mapping( + builder, + [self._SRC], + mapping, + positions, + quaternions, + {self._SRC: source}, + env_ids=env_ids, + env_template=self._ENV, + ) + + assert named is True + assert builder.joint_label == [f"{self._DST.format(env_id)}_free_joint" for env_id in range(self._WORLDS)] + + def test_without_a_plan_the_caller_still_rewrites(self): + _, named = self._run(delegate=False) + + assert named is False + + if __name__ == "__main__": unittest.main() diff --git a/source/isaaclab_newton/test/physics/test_vbd_core.py b/source/isaaclab_newton/test/physics/test_vbd_core.py index 3604fa2d8fc7..166380f59b4f 100644 --- a/source/isaaclab_newton/test/physics/test_vbd_core.py +++ b/source/isaaclab_newton/test/physics/test_vbd_core.py @@ -132,7 +132,7 @@ def create_builder(cls, *, up_axis): def replicate(*args, **kwargs): replicate_calls.append(kwargs) - return {}, [object() for _ in env_paths] + return {}, [object() for _ in env_paths], False monkeypatch.setattr(newton_module, "get_current_stage", lambda: stage) monkeypatch.setattr(pxr, "UsdGeom", usd_geom) diff --git a/source/isaaclab_ov/changelog.d/brewster-replication-names-copies.skip b/source/isaaclab_ov/changelog.d/brewster-replication-names-copies.skip new file mode 100644 index 000000000000..39fd30abae89 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/brewster-replication-names-copies.skip @@ -0,0 +1 @@ +Signature-only: the replication context accepts the clone plan's env_template alongside global_paths, and ignores it. diff --git a/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py b/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py index 1a6f43bd2396..c80ee07bab36 100644 --- a/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py +++ b/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py @@ -28,6 +28,7 @@ from pxr import Gf, Sdf, Usd, UsdGeom from isaaclab import cloner +from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from isaaclab_ov._clone import CloneTransform, clone_transforms_from_positions @@ -80,7 +81,7 @@ class OvPhysxReplicateContext: replicate_priority = 0 - def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): + def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = (), env_template: str = DEFAULT_ENV_TEMPLATE): """Initialize the context. Args: From cbb17ab6edfbc43aedf4e48158510b1c8c81a407 Mon Sep 17 00:00:00 2001 From: camevor Date: Mon, 31 Aug 2026 18:55:36 +0200 Subject: [PATCH 3/9] Update newton pin --- pyproject.toml | 2 +- uv.lock | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e35ada57136c..c80a8b3f1adb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -394,7 +394,7 @@ override-dependencies = [ "numpy>=2", "mujoco~=3.11.0", "mujoco-warp~=3.11.0", - "newton[sim]==1.5.1", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@24bd863528d6b91137408930d0fbe8fa216ad962", # Force the Newton-matched schemas over isaacsim's ==0.2.0 pin. "newton-usd-schemas>=0.4.1", "torch==2.11.0", diff --git a/uv.lock b/uv.lock index 8f55e6ef7253..ed3d493bfcc9 100644 --- a/uv.lock +++ b/uv.lock @@ -20,7 +20,7 @@ overrides = [ { name = "coverage", specifier = ">=7.6.1" }, { name = "mujoco", specifier = "~=3.11.0" }, { name = "mujoco-warp", specifier = "~=3.11.0" }, - { name = "newton", extras = ["sim"], specifier = "==1.5.1" }, + { name = "newton", extras = ["sim"], git = "https://github.com/newton-physics/newton.git?rev=24bd863528d6b91137408930d0fbe8fa216ad962" }, { name = "newton-usd-schemas", specifier = ">=0.4.1" }, { name = "numpy", specifier = ">=2" }, { name = "packaging", specifier = ">=20,<27" }, @@ -3352,14 +3352,11 @@ wheels = [ [[package]] name = "newton" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } +version = "1.6.0.dev0" +source = { git = "https://github.com/newton-physics/newton.git?rev=24bd863528d6b91137408930d0fbe8fa216ad962#24bd863528d6b91137408930d0fbe8fa216ad962" } dependencies = [ { name = "warp-lang", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d6a3e799c25cf04b5ab35b9903cafd09731df123a0c91f4d27caed28d431/newton-1.5.1-py3-none-any.whl", hash = "sha256:a757269fe03ca2e50edb9636b3c7eb91c2d1e359b6e9c992b33dc6d9058b5285", size = 5564220, upload-time = "2026-08-27T20:33:23.381Z" }, -] [package.optional-dependencies] sim = [ From e989fdd13decdae482633095f7ebabc199c6dad0 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 2 Sep 2026 02:30:28 -0700 Subject: [PATCH 4/9] Simplify Newton clone label prefixing --- pyproject.toml | 5 +- .../brewster-replication-names-copies.rst | 9 - source/isaaclab/isaaclab/cloner/clone_plan.py | 11 - .../isaaclab/cloner/replicate_session.py | 2 +- source/isaaclab/isaaclab/cloner/usd.py | 3 +- .../test/cloner/test_replicate_session.py | 3 +- ...ip_install_isaaclab_all_trains_cartpole.py | 8 +- .../test/install_ci/uv_pip/uv-overrides.txt | 2 +- ...test_newton_manager_visualization_state.py | 3 +- .../brewster-env-root-site-labels.rst | 7 - .../brewster-replication-names-copies.rst | 8 +- .../cloner/newton_clone_utils.py | 162 +++----- .../isaaclab_newton/cloner/replicate.py | 58 +-- .../isaaclab_newton/physics/newton_manager.py | 37 +- .../physics/visualization_builder.py | 4 +- .../ray_caster/newton_raycast_sensor.py | 5 +- .../sim/views/newton_site_frame_view.py | 38 +- .../test/cloner/test_rename_builder_labels.py | 366 ++++-------------- .../test/physics/test_vbd_core.py | 2 +- .../test/sensors/test_site_injection.py | 33 +- .../brewster-replication-names-copies.skip | 1 - .../isaaclab_ov/cloner/replicate.py | 3 +- tools/wheel_builder/uv-overrides.txt | 2 +- uv.lock | 2 +- 24 files changed, 197 insertions(+), 577 deletions(-) delete mode 100644 source/isaaclab/changelog.d/brewster-replication-names-copies.rst delete mode 100644 source/isaaclab_newton/changelog.d/brewster-env-root-site-labels.rst delete mode 100644 source/isaaclab_ov/changelog.d/brewster-replication-names-copies.skip diff --git a/pyproject.toml b/pyproject.toml index c80a8b3f1adb..8c8243986b4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,9 +85,8 @@ dependencies = [ "rsl-rl-lib==5.4.1", # default RL framework "onnxscript>=0.5", # ----- newton (default physics engine) ----- - # Loose bound so the wheel co-resolves with isaacsim's newton[sim]==1.2.0 pin; the - # exact PyPI release is forced via [tool.uv].override-dependencies (uv sync only). - "newton[sim]>=1.2.0", + # Pin the Newton build used by both workspace and wheel installs until the 1.6 release. + "newton[sim] @ git+https://github.com/newton-physics/newton.git@24bd863528d6b91137408930d0fbe8fa216ad962", # Import and mesh-processing packages used by Newton, including the ones that honoring # USD-authored ``physics:approximation`` requires. Keep these explicit instead of selecting # newton[importers], whose standalone USD dependency would overlap with usd-exchange. diff --git a/source/isaaclab/changelog.d/brewster-replication-names-copies.rst b/source/isaaclab/changelog.d/brewster-replication-names-copies.rst deleted file mode 100644 index fc0492e71eaf..000000000000 --- a/source/isaaclab/changelog.d/brewster-replication-names-copies.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab.cloner.ClonePlan.env_template`, the destination template for one - environment. Every row's destination is that template followed by the asset's path below the - environment, so it names the part a clone varies while the remainder is shared. It was - previously a constructor argument that the plan discarded, leaving a consumer holding a row - unable to recover it -- a destination carries no mark of where the environment ends. Backend - replication contexts receive it alongside ``global_paths``. diff --git a/source/isaaclab/isaaclab/cloner/clone_plan.py b/source/isaaclab/isaaclab/cloner/clone_plan.py index 19e132683eee..3b47592ac026 100644 --- a/source/isaaclab/isaaclab/cloner/clone_plan.py +++ b/source/isaaclab/isaaclab/cloner/clone_plan.py @@ -64,13 +64,6 @@ class ClonePlan: global_paths: tuple[str, ...] = () """Unique prim paths for scene assets shared by every environment.""" - env_template: str = DEFAULT_ENV_TEMPLATE - """Destination template for one environment, with ``"{}"`` for the env id. - - Every row's destination is this template followed by the asset's path below the - environment, so this names the part a clone varies from the part it shares. - """ - def grid_transforms(N: int, spacing: float = 1.0, up_axis: str = "z", device="cpu"): """Create a centered grid of transforms for ``N`` instances. @@ -300,7 +293,6 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: positions=positions, cfg_rows={}, global_paths=global_paths, - env_template=env_template, ) # 3) Homogeneous (every cfg is single-variant): emit the simpler env-root plan. @@ -316,7 +308,6 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: positions=positions, cfg_rows=cfg_rows, global_paths=global_paths, - env_template=env_template, ) # 4) Heterogeneous: enumerate prototype combos, build per-row mask, mutate spawn paths. @@ -386,7 +377,6 @@ def validate_combo_tensor(combos: torch.Tensor, name: str, expected_rows: int | positions=positions, cfg_rows=cfg_rows, global_paths=global_paths, - env_template=env_template, ) @@ -429,5 +419,4 @@ def clone_plan_from_env_0( positions=positions, cfg_rows=cfg_rows, global_paths=global_paths, - env_template=destination, ) diff --git a/source/isaaclab/isaaclab/cloner/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index 12704e434c61..34dcb3afc94c 100644 --- a/source/isaaclab/isaaclab/cloner/replicate_session.py +++ b/source/isaaclab/isaaclab/cloner/replicate_session.py @@ -99,7 +99,7 @@ def replicate(plan: ClonePlan, *, stage: Usd.Stage, replicate_physics: bool = Tr backend_ctxs: dict[type, Any] = {} for BackendCtxCls, row_set in backend_rows.items(): - ctx = BackendCtxCls(stage, global_paths=plan.global_paths, env_template=plan.env_template) + ctx = BackendCtxCls(stage, global_paths=plan.global_paths) backend_ctxs[BackendCtxCls] = ctx row_list = sorted(row_set) ctx.queue_mapping( diff --git a/source/isaaclab/isaaclab/cloner/usd.py b/source/isaaclab/isaaclab/cloner/usd.py index 7094cf1cb56a..187a00429154 100644 --- a/source/isaaclab/isaaclab/cloner/usd.py +++ b/source/isaaclab/isaaclab/cloner/usd.py @@ -12,7 +12,6 @@ from pxr import Gf, Sdf, Usd, UsdGeom, Vt from ._fabric_notices import disabled_fabric_change_notifies -from .cloner_cfg import DEFAULT_ENV_TEMPLATE from .path import split @@ -31,7 +30,7 @@ class UsdReplicateContext: replicate_priority = 100 - def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = (), env_template: str = DEFAULT_ENV_TEMPLATE): + def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): """Initialize the context. Args: diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index a59c5f4cbc73..638a0ed86fa8 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -35,9 +35,8 @@ class FakeUsdContext: replicate_priority = 100 instances: list["FakeUsdContext"] = [] - def __init__(self, stage, *, global_paths, env_template): + def __init__(self, stage, *, global_paths): self.global_paths = global_paths - self.env_template = env_template FakeUsdContext.instances.append(self) def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None): diff --git a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_trains_cartpole.py b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_trains_cartpole.py index 973014079b00..bc50847f67dd 100644 --- a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_trains_cartpole.py +++ b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_trains_cartpole.py @@ -15,8 +15,8 @@ Reinstall AFTER the wheel install: unsafe-best-match re-resolves torch from PyPI to CPU.) - (aarch64 only) export LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 Tests: - - python -c "import importlib.metadata as m; assert m.version('newton') == '1.5.1'" - -> verify the wheel resolves Newton 1.5 + - python -c "import importlib.metadata as m; assert m.version('newton') == '1.6.0.dev0'" + -> verify the wheel resolves the pinned Newton 1.6 development build - uv run isaaclab train --rl_library rsl_rl --task Isaac-Cartpole-Direct --num_envs 16 presets=newton_mjwarp --max_iterations 5; uv run isaaclab train --rl_library rsl_rl --task Isaac-Cartpole-Camera-Direct --num_envs 16 presets=newton_mjwarp,newton_renderer --max_iterations 2 @@ -57,11 +57,11 @@ def test_uv_pip_install_isaaclab_all_trains_cartpole(self, isaaclab_root, wheel, assert result.returncode == 0, f"uv pip install {wheel}[all] failed:\n{result.stdout}\n{result.stderr}" result = self.run_in_uv_env( - ["python", "-c", "import importlib.metadata as m; assert m.version('newton') == '1.5.1'"], + ["python", "-c", "import importlib.metadata as m; assert m.version('newton') == '1.6.0.dev0'"], cwd=isaaclab_root, ) assert result.returncode == 0, ( - f"isaaclab[all] did not resolve Newton 1.5:\n{result.stdout}\n{result.stderr}" + f"isaaclab[all] did not resolve the pinned Newton build:\n{result.stdout}\n{result.stderr}" ) # Restore the CUDA build selected for this architecture. diff --git a/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt b/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt index 95a0eec25afa..bbf4df976ab0 100644 --- a/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt +++ b/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt @@ -1,7 +1,7 @@ numpy>=2 mujoco~=3.11.0 mujoco-warp~=3.11.0 -newton[sim]==1.5.1 +newton[sim] @ git+https://github.com/newton-physics/newton.git@24bd863528d6b91137408930d0fbe8fa216ad962 newton-usd-schemas>=0.4.1 torch==2.11.0 torchvision==0.26.0 diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index 8db96a603e84..407d4e639818 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -836,8 +836,7 @@ def test_clone_visualization_builder_ignores_non_env_deformables_on_world_import monkeypatch.setattr(vb, "_restore_visible_colliders_without_visual_shapes", lambda *args, **kwargs: None) monkeypatch.setattr(vb, "import_builder_visual_material_paths", lambda *args, **kwargs: None) monkeypatch.setattr(vb, "build_source_builders", lambda *args, **kwargs: {}) - monkeypatch.setattr(vb, "replicate_builder_mapping", lambda *args, **kwargs: ({}, [], False)) - monkeypatch.setattr(vb, "rename_builder_labels", lambda *args, **kwargs: None) + monkeypatch.setattr(vb, "replicate_builder_mapping", lambda *args, **kwargs: ({}, [], [])) _builder, (shadow_entities, registry_groups) = vb.build_visualization_builder_from_stage_envs( stage, diff --git a/source/isaaclab_newton/changelog.d/brewster-env-root-site-labels.rst b/source/isaaclab_newton/changelog.d/brewster-env-root-site-labels.rst deleted file mode 100644 index e42eaa3fb046..000000000000 --- a/source/isaaclab_newton/changelog.d/brewster-env-root-site-labels.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed a bodyless per-environment site carrying the label ``ft_0`` in every environment. Such a - site is now registered with the destination template of the clone-plan row that requested it and - labelled from the environment it lands in, so it reads e.g. ``/World/envs/env_3/ft_0``. Sites are - still resolved by index, so consumers that look them up by label and index are unaffected. diff --git a/source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst b/source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst index fbe2e8714a9c..4d409dd5d33b 100644 --- a/source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst +++ b/source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst @@ -1,10 +1,4 @@ Changed ^^^^^^^ -* Changed the homogeneous Newton cloning path to let replication name each cloned entity for - the environment it lands in, instead of rewriting every replicated label afterwards. The - prototype's labels are rebased once -- a few hundred entries -- and - :meth:`~newton.ModelBuilder.replicate` is given the per-env roots, replacing a pass over - every label in every world that cost 215 ms on ``Isaac-Velocity-Flat-G1`` at 4096 - environments. The labels are identical either way; a prototype whose labels a per-world - prefix cannot spell keeps the previous path. +* Changed homogeneous Newton cloning to assign labels during replication, avoiding a second full-model pass. diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index 017ecbe7d89f..e306b0325d2e 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -16,7 +16,6 @@ from pxr import Usd, UsdGeom, UsdPhysics from isaaclab.cloner import path as clone_path -from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from isaaclab.sim.utils.newton_model_utils import replace_newton_builder_shape_colors from isaaclab_newton.renderers.visual_material import import_builder_visual_material_paths @@ -187,35 +186,35 @@ def _invert_xform(xform: Sequence[float] | np.ndarray) -> np.ndarray: return np.concatenate([-_quat_rotate(quat_inv, xform[:3]), quat_inv]) -def _site_label(env_root: str | None, label: str) -> str: - """Site label, beneath *env_root* when it has one and bare otherwise.""" - return f"{env_root}/{label}" if env_root else label - - -def _rebase_to_env(builder: ModelBuilder, env_root: str) -> bool: - """Rewrite every entity label relative to its environment root, in place. - - Replication makes N copies that differ only in the environment they sit in, so a label is - ```` and only the first part varies. Returns whether every label could be - written that way: one outside the environment, or one naming the environment root itself, - has no within-environment part a per-world prefix could carry. - """ - rebased = [] - for labels in ( - builder.body_label, - builder.joint_label, - builder.shape_label, - builder.articulation_label, - builder.constraint_mimic_label, - ): +def _label_groups(builder: ModelBuilder) -> dict[str, list]: + """Return every entity-label container owned by a Newton builder.""" + groups = { + name: value for name, value in vars(builder).items() if name.endswith("_label") and isinstance(value, list) + } + groups["mujoco:equality_constraint_label"] = builder.custom_attributes["mujoco:equality_constraint_label"].values + return groups + + +def _rebase_labels(builder: ModelBuilder, source: str, destination: str) -> str: + """Make entity labels relative to the nearest templated destination ancestor.""" + source = source.rstrip("/") or "/" + destination = destination.rstrip("/") or "/" + prefix, _, destination_name = destination.rpartition("/") + if "{}" in destination_name: + prefix, destination_name = destination, "" + for labels in _label_groups(builder).values(): for index, label in enumerate(labels): - suffix = clone_path.relative_to(label, env_root) if isinstance(label, str) else None - if not suffix: - return False - rebased.append((labels, index, suffix.lstrip("/"))) - for labels, index, suffix in rebased: - labels[index] = suffix - return True + if not isinstance(label, str) or not label or not label.startswith("/"): + continue + suffix = clone_path.relative_to(label, source) + if suffix is None: + suffix = label[len(source) :] if label.startswith(source + "_") else None + if suffix is None: + raise ValueError(f"Newton label {label!r} is outside clone source {source!r}.") + labels[index] = (destination_name + suffix).lstrip("/") + if not labels[index]: + raise ValueError(f"Newton label {label!r} cannot be prefixed by destination {destination!r}.") + return prefix def replicate_builder_mapping( @@ -225,28 +224,16 @@ def replicate_builder_mapping( positions: torch.Tensor, quaternions: torch.Tensor, source_builders: dict[str, ModelBuilder], + destinations: Sequence[str] | None = None, + env_ids: torch.Tensor | None = None, *, source_site_indices: dict[int, dict[str, list[int]]] | None = None, - env_root_sites: dict[str, tuple[wp.transform, str | None]] | None = None, - env_ids: torch.Tensor | None = None, - env_template: str = DEFAULT_ENV_TEMPLATE, + env_root_sites: dict[str, wp.transform] | None = None, per_world_builder_hooks: Sequence[Callable[[ModelBuilder, int, list[float], list[float]], None]] = (), -) -> tuple[dict[str, list[list[int]]], list[wp.transform], bool]: - """Replicate source builders into per-env Newton worlds. - - Returns the per-env site indices, the per-env world transforms, and whether replication - already named each entity for the env it landed in. - - Args: - env_root_sites: Site transform and the destination template naming its env, per label. - env_ids: Environment ids for the destination worlds. Given with an environment that - owns the prototype, replication names each copy and the caller does not have to - rewrite the entity labels afterwards. - env_template: Destination template for one environment, from the clone plan. - """ +) -> tuple[dict[str, list[list[int]]], list[wp.transform], list[tuple[str, int]]]: + """Replicate source builders, naming homogeneous copies at their destinations.""" source_site_indices = source_site_indices or {} env_root_sites = env_root_sites or {} - env_ids_list = env_ids.tolist() if env_ids is not None else None num_worlds = mapping.size(1) local_site_map: dict[str, list[list[int]]] = {} positions_np = positions.detach().cpu().numpy().astype(np.float32, copy=False) @@ -261,6 +248,8 @@ def replicate_builder_mapping( and num_worlds > 0 and bool(mapping.all().item()) and not per_world_builder_hooks + and bool(destinations) + and env_ids is not None ) if can_batch: source_builder = source_builders[sources[0]] @@ -269,12 +258,8 @@ def replicate_builder_mapping( # by world_xforms[0] so R_w = world_xform_w * inv(world_xform_0) lands each # copy at world_xform_w * xform. site_local_indices: dict[str, list[int]] = {} - for label, (xform, destination_template) in env_root_sites.items(): - # Every copy shares one label; the env name is applied after, by ``label_prefixes`` - # below or by ``rename_builder_labels``. ``can_batch`` makes either one exact. - root = sources[0] if destination_template else None - site_xform = wp.transform_multiply(world_xforms[0], xform) - idx = source_builder.add_site(body=-1, xform=site_xform, label=_site_label(root, label)) + for label, xform in env_root_sites.items(): + idx = source_builder.add_site(body=-1, xform=wp.transform_multiply(world_xforms[0], xform), label=label) site_local_indices.setdefault(label, []).append(idx) for label, indices in source_site_indices.get(id(source_builder), {}).items(): site_local_indices.setdefault(label, []).extend(indices) @@ -285,29 +270,30 @@ def replicate_builder_mapping( source_xform_inv = _invert_xform(xforms_np[0]) xforms = _compose_world_xforms(positions_np, quaternions_np, source_xform_inv) - # One source populating every world is the shape replication can name itself: rebase the - # prototype's labels once -- a few hundred entries -- and let each copy carry its own - # env root, instead of rewriting every label in every world afterwards. - label_prefixes = None - prototype_env = clone_path.match(sources[0], env_template) if env_ids is not None else None - if prototype_env is not None and _rebase_to_env(source_builder, env_template.format(prototype_env.instance)): - label_prefixes = [env_template.format(env_id) for env_id in env_ids.tolist()] - builder.replicate(source_builder, num_worlds, xforms=xforms, label_prefixes=label_prefixes) + label_groups = _label_groups(source_builder) + original_labels = {name: list(labels) for name, labels in label_groups.items()} + try: + prefix = _rebase_labels(source_builder, sources[0], destinations[0]) + prefixes = [prefix.format(env_id) for env_id in env_ids.tolist()] + builder.replicate(source_builder, num_worlds, xforms=xforms, label_prefixes=prefixes) + finally: + for name, labels in original_labels.items(): + label_groups[name][:] = labels for label, local_indices in site_local_indices.items(): local_site_map[label] = [ [base_shape + world * stride + local for local in local_indices] for world in range(num_worlds) ] - return local_site_map, world_xforms, label_prefixes is not None + bindings = rename_builder_labels(builder, sources, destinations, env_ids, mapping, skip_entity_labels=True) + return local_site_map, world_xforms, bindings source_world_indices = mapping.to(dtype=torch.int64).argmax(dim=1).tolist() # Per-world placements for every env-root site, composed up front so the per-world loop # below only indexes rows. root_site_xforms = { - label: (_compose_world_xforms(positions_np, quaternions_np, xform), destination_template) - for label, (xform, destination_template) in env_root_sites.items() + label: _compose_world_xforms(positions_np, quaternions_np, xform) for label, xform in env_root_sites.items() } # Same for the source placements, but only for the occupied ``(row, col)`` pairs of the # mapping: composing a dense ``num_rows x num_worlds`` table would blow up on heterogeneous @@ -332,38 +318,22 @@ def replicate_builder_mapping( for col in range(num_worlds): builder.begin_world() - - for label, (world_site_xforms, destination_template) in root_site_xforms.items(): - # Named here, not by ``rename_builder_labels``: that only rewrites labels in the - # worlds the requesting row covers, and an env-root site sits in every world. - env_root = destination_template.format(env_ids_list[col]) if destination_template and env_ids_list else None - site_idx = builder.add_site(body=-1, xform=world_site_xforms[col], label=_site_label(env_root, label)) + for label, world_site_xforms in root_site_xforms.items(): + site_idx = builder.add_site(body=-1, xform=world_site_xforms[col], label=label) local_site_map.setdefault(label, [[] for _ in range(num_worlds)])[col].append(site_idx) - for row in rows_per_world[col]: source_builder = source_builders[sources[row]] offset = builder.shape_count builder.add_builder(source_builder, xform=source_xforms[row, col]) - for label, source_shape_indices in source_site_indices.get(id(source_builder), {}).items(): local_indices = local_site_map.setdefault(label, [[] for _ in range(num_worlds)])[col] local_indices.extend(offset + shape_idx for shape_idx in source_shape_indices) - for hook in per_world_builder_hooks: hook(builder, col, xform_rows[col][:3], xform_rows[col][3:]) builder.end_world() - return local_site_map, world_xforms, False - - -_BUILTIN_LABEL_TYPES: tuple[str, ...] = ( - "body", - "joint", - "shape", - "articulation", - "constraint_mimic", - "equality_constraint", -) + bindings = rename_builder_labels(builder, sources, destinations, env_ids, mapping) if destinations else [] + return local_site_map, world_xforms, bindings def rename_builder_labels( @@ -375,14 +345,7 @@ def rename_builder_labels( *, skip_entity_labels: bool = False, ) -> list[tuple[str, int]]: - """Rewrite source-root labels to per-env destination roots and return Fabric body bindings. - - Args: - skip_entity_labels: Whether the entity labels already name the env they are in, as they - do when replication was given the per-env prefixes. Only the string custom - attributes are rewritten then, since Newton cannot tell which of those name - entities. - """ + """Rewrite source-root labels to per-env destination roots and return Fabric body bindings.""" fabric_body_bindings: list[tuple[str, int]] = [] bound_body_indices: set[int] = set() env_ids_list = env_ids.tolist() @@ -392,10 +355,7 @@ def rename_builder_labels( world_cols = torch.nonzero(mapping[source_index], as_tuple=True)[0].tolist() # Pre-normalize the destination roots destination = destinations[source_index] - world_roots = { - env_id: (destination.format(env_id).rstrip("/") or "/") - for env_id in (env_ids_list[col] for col in world_cols) - } + world_roots = {col: (destination.format(env_ids_list[col]).rstrip("/") or "/") for col in world_cols} def _rename_pair(values, worlds, src_root=source_root, roots=world_roots, *, collect_body_bindings=False): rows = ( @@ -418,14 +378,10 @@ def _rename_pair(values, worlds, src_root=source_root, roots=world_roots, *, col bound_body_indices.add(index) if not skip_entity_labels: - for labels, worlds, collect_body_bindings in ( - (builder.body_label, builder.body_world, True), - (builder.joint_label, builder.joint_world, False), - (builder.shape_label, builder.shape_world, False), - (builder.articulation_label, builder.articulation_world, False), - (builder.constraint_mimic_label, builder.constraint_mimic_world, False), - ): - _rename_pair(labels, worlds, collect_body_bindings=collect_body_bindings) + for name, labels in vars(builder).items(): + worlds = getattr(builder, f"{name[:-6]}_world", None) if name.endswith("_label") else None + if isinstance(labels, list) and worlds is not None: + _rename_pair(labels, worlds, collect_body_bindings=name == "body_label") custom_attrs = builder.custom_attributes.values() worlds_by_freq = {attr.frequency: attr.values for attr in custom_attrs if attr.references == "world"} diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py index 54a02c6a59df..5ed9cecd7ce0 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py @@ -18,14 +18,12 @@ from pxr import Usd -from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE 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 @@ -107,15 +105,8 @@ def _build_newton_builder_from_mapping( up_axis: str = "Z", load_visual_shapes: bool = True, global_paths: tuple[str, ...] = (), - env_template: str = DEFAULT_ENV_TEMPLATE, -) -> tuple[ModelBuilder, object, dict, list, dict[str, ModelBuilder], bool]: - """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, and whether replication already named each entity for the env it - landed in. - """ +) -> tuple[ModelBuilder, object, dict, list, dict[str, ModelBuilder], list[tuple[str, int]]]: + """Build a Newton model builder from clone mapping inputs and retain its source builders.""" if positions is None: positions = torch.zeros((mapping.size(1), 3), device=mapping.device, dtype=torch.float32) if quaternions is None: @@ -173,18 +164,18 @@ def _build_newton_builder_from_mapping( 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, labels_are_per_env = replicate_builder_mapping( + local_site_map, world_xforms, fabric_body_bindings = replicate_builder_mapping( *replicate_args, + destinations, + env_ids, source_site_indices=source_sites, env_root_sites=root_sites, - env_ids=env_ids, - env_template=env_template, 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, labels_are_per_env + return builder, stage_info, site_index_map, world_xforms, source_builders, fabric_body_bindings def _renderer_wants_visual_shapes() -> bool: @@ -211,7 +202,6 @@ def __init__( self, stage: Usd.Stage, global_paths: tuple[str, ...] = (), - env_template: str = DEFAULT_ENV_TEMPLATE, device: str = "cpu", up_axis: str = "Z", load_visual_shapes: bool | None = None, @@ -231,7 +221,6 @@ def __init__( :class:`NewtonManager`. """ self.stage = stage - self.env_template = env_template self._global_paths = global_paths self.device = device self.up_axis = up_axis @@ -316,28 +305,19 @@ def _merged_mapping(self) -> _MappingBatch: 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, - labels_are_per_env, - ) = _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, - env_template=self.env_template, - load_visual_shapes=self.load_visual_shapes, - global_paths=self._global_paths, - ) - fabric_body_bindings = rename_builder_labels( - builder, sources, destinations, env_ids, mapping, skip_entity_labels=labels_are_per_env + builder, stage_info, site_index_map, world_xforms, source_builders, fabric_body_bindings = ( + _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, + global_paths=self._global_paths, + ) ) if self.commit_to_manager: NewtonManager._cl_site_index_map = site_index_map diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 693d4b404b05..fefe8d9f275f 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -499,9 +499,9 @@ class NewtonManager(PhysicsManager): # CL: Cloning / Replication logic # TODO: These attributes support cloning-specific logic and should be moved into a cloner class # Pending site requests from sensors. - # Key: (body_pattern, per_world, xform_floats, destination_template), Value: (label, wp.transform) - # identical keys reuse the same site. - _cl_pending_sites: dict[tuple[str | None, bool, tuple[float, ...], str | None], tuple[str, wp.transform]] = {} + # Key: (body_pattern, per_world, xform_floats), Value: (label, wp.transform) + # identical (body_pattern, per_world, transform) reuses the same site. + _cl_pending_sites: dict[tuple[str | None, bool, tuple[float, ...]], tuple[str, wp.transform]] = {} # Maps each site label to its resolved global or local site entry. _GlobalSite = tuple[int, None] @@ -1203,21 +1203,14 @@ def _prepare_builder_for_finalize(cls, builder: ModelBuilder) -> None: """ @classmethod - def cl_register_site( - cls, - body_pattern: str | None, - xform: wp.transform, - *, - per_world: bool = False, - destination_template: str | None = None, - ) -> str: + def cl_register_site(cls, body_pattern: str | None, xform: wp.transform, *, per_world: bool = False) -> str: """Register a site request for injection into prototypes before replication. Sensors call this during ``__init__``. Sites are injected into prototype builders by :meth:`_cl_inject_sites` (called from ``newton_replicate``) before ``add_builder``, so they replicate correctly per-world. - Identical ``(body_pattern, per_world, transform, destination_template)`` registrations share sites. + Identical ``(body_pattern, per_world, transform)`` registrations share sites. The *body_pattern* is matched against prototype-local body labels (e.g. ``"Robot/link.*"``) when replication is active, or against the @@ -1232,19 +1225,14 @@ def cl_register_site( xform: Site transform relative to body. per_world: When ``True``, ``body_pattern`` must be ``None`` and one bodyless site is created in each cloned world's frame. - destination_template: Destination template of the clone-plan row that requested a - ``per_world`` site, so its label names the environment it lands in. A site - registered without one reads the same in every environment. Returns: Assigned site label suffix. """ if per_world and body_pattern is not None: raise ValueError("per_world site registration requires body_pattern=None.") - if destination_template is not None and not per_world: - raise ValueError("destination_template applies to per_world site registration.") xform_key = tuple(xform) - key = (body_pattern, per_world, xform_key, destination_template) + key = (body_pattern, per_world, xform_key) if key in cls._cl_pending_sites: return cls._cl_pending_sites[key][0] label = f"ft_{len(cls._cl_pending_sites)}" @@ -1282,7 +1270,7 @@ def _cl_inject_sites( cls, main_builder: ModelBuilder, source_builders: dict[str, ModelBuilder], - ) -> tuple[dict[str, int], dict[int, dict[str, list[int]]], dict[str, tuple[wp.transform, str | None]]]: + ) -> tuple[dict[str, int], dict[int, dict[str, list[int]]], dict[str, wp.transform]]: """Inject registered sites into source builders before replication. Non-global sites are matched against source builder body labels using @@ -1303,17 +1291,16 @@ def _cl_inject_sites( Tuple of ``(global_site_indices, source_site_indices, env_root_sites)`` where *global_site_indices* maps ``{label: main_builder_shape_idx}``, *source_site_indices* maps ``{id(source_builder): {label: [source_local_shape_idx, ...]}}``, - and *env_root_sites* maps ``{label: (env_root_relative_transform, destination_template)}``, - where *destination_template* names the environment the site lands in, or ``None``. + and *env_root_sites* maps ``{label: env_root_relative_transform}``. """ global_site_indices: dict[str, int] = {} source_site_indices: dict[int, dict[str, list[int]]] = {} - env_root_sites: dict[str, tuple[wp.transform, str | None]] = {} + env_root_sites: dict[str, wp.transform] = {} - for (body_pattern, per_world, _xform_key, template), (label, xform) in cls._cl_pending_sites.items(): + for (body_pattern, per_world, _xform_key), (label, xform) in cls._cl_pending_sites.items(): if per_world: - env_root_sites[label] = (xform, template) + env_root_sites[label] = xform continue if body_pattern is None: site_idx = main_builder.add_site(body=-1, xform=xform, label=label) @@ -1361,7 +1348,7 @@ def _cl_inject_sites_fallback(cls) -> None: builder = cls._builder body_labels = list(builder.body_label) - for (body_pattern, per_world, _xform_key, _template), (label, xform) in cls._cl_pending_sites.items(): + for (body_pattern, per_world, _xform_key), (label, xform) in cls._cl_pending_sites.items(): if per_world: site_idx = builder.add_site(body=-1, xform=xform, label=label) cls._cl_site_index_map[label] = (None, [[site_idx]]) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py b/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py index cef64e4309d7..aa518cffe5ae 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py @@ -20,7 +20,6 @@ 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.visualization_deformables import add_shadow_deformables_to_builder @@ -148,8 +147,7 @@ def build_visualization_builder_from_stage_envs( schema_resolvers, ignore_paths=source_deformable_ignore_paths or None, ) - replicate_builder_mapping(builder, sources, mapping, positions, quaternions, source_builders) - rename_builder_labels(builder, sources, destinations, env_ids, mapping) + replicate_builder_mapping(builder, sources, mapping, positions, quaternions, source_builders, destinations, env_ids) shadow_entities, registry_groups = add_shadow_deformables_to_builder( builder, stage, env_paths, device=device, entries=deformable_entries, clone_plan=clone_plan ) diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py index c5685bdc8cc3..04a519db26eb 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py @@ -104,10 +104,7 @@ def _register_sites_for_expr(self, prim_expr: str) -> list[str]: for destination_template in plan.destinations: matched = cloner.path.match(prim_expr, destination_template) if matched is not None and not matched.suffix: - site = NewtonManager.cl_register_site( - None, wp.transform(), per_world=True, destination_template=destination_template - ) - return [site] + return [NewtonManager.cl_register_site(None, wp.transform(), per_world=True)] try: body_expr, fixed_pos, fixed_quat = self._resolve_rigid_body_ancestor_expr() diff --git a/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py index 9f0f17ca6c5a..89e5d528889c 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py @@ -177,17 +177,6 @@ def _scatter_xform_scales( site_xform_scale[indices[i]] = new_scales[i] -_SiteSpec = tuple[ - tuple[str, ...] | None, # body label patterns, or None for a bodyless site - wp.transform, # site transform [m], body-local when body patterns are set, else env-root-local - tuple[float, float, float], # scale - bool, # bodyless site: one per world rather than one globally - tuple[int, ...] | None, # env ids the site covers, or None for all of them - str | None, # destination template naming the env it lands in, for a per-world site -] -"""One resolved site request.""" - - class NewtonSiteFrameView(BaseFrameView): """Batched Newton site view for non-physics frames. @@ -244,11 +233,9 @@ def __init__( if model is not None: self._initialize_from_specs(model) else: - for body_patterns, xform, scale, per_world, _env_ids, template in self._site_specs: + for body_patterns, xform, scale, per_world, _env_ids in self._site_specs: if body_patterns is None: - self._site_labels.append( - NewtonManager.cl_register_site(None, xform, per_world=per_world, destination_template=template) - ) + self._site_labels.append(NewtonManager.cl_register_site(None, xform, per_world=per_world)) self._site_label_scales.append(scale) else: for body_pattern in body_patterns: @@ -258,14 +245,18 @@ def __init__( self._on_physics_ready, PhysicsEvent.PHYSICS_READY, name=f"site_view_{self._prim_path}" ) - def _resolve_site_specs(self, stage, validate_xform_ops: bool) -> list[_SiteSpec]: + def _resolve_site_specs( + self, stage, validate_xform_ops: bool + ) -> list[tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None]]: """Resolve source prims into Newton site registration specs.""" plan = sim_utils.SimulationContext.instance().get_clone_plan() model = NewtonManager.get_model() body_labels = list(model.body_label) if model is not None else () shape_labels = list(model.shape_label) if model is not None else () use_clone_body_pattern = model is None - specs: list[_SiteSpec] = [] + specs: list[ + tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None] + ] = [] for path_expr in self._prim_paths: if resolve_matching_names(path_expr, body_labels, raise_when_no_match=False)[1]: @@ -322,7 +313,7 @@ def _resolve_source_prim( env_ids: tuple[int, ...] | None, use_clone_body_pattern: bool, stage, - ) -> _SiteSpec: + ) -> tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None]: """Resolve one source prim into body patterns, local frame, and xform scale.""" prim_path = prim.GetPath().pathString if prim.HasAPI(UsdPhysics.RigidBodyAPI) or prim.HasAPI(UsdPhysics.ArticulationRootAPI): @@ -361,8 +352,7 @@ def _resolve_source_prim( raise RuntimeError( f"FrameView destination root '{destination_root}' does not end with '{suffix}'." ) - root = destination_root[: -len(suffix)] - return (root,), wp.transform(pos, quat), scale, False, env_ids, None + return (destination_root[: -len(suffix)],), wp.transform(pos, quat), scale, False, env_ids body_patterns = [] for env_id in env_ids: destination_root = destination_template.format(env_id) @@ -371,7 +361,7 @@ def _resolve_source_prim( f"FrameView destination root '{destination_root}' does not end with '{suffix}'." ) body_patterns.append(destination_root[: -len(suffix)]) - return tuple(body_patterns), wp.transform(pos, quat), scale, False, env_ids, None + return tuple(body_patterns), wp.transform(pos, quat), scale, False, env_ids else: raise RuntimeError(f"FrameView source body '{body_path}' is not under '{source_root}'.") if use_clone_body_pattern: @@ -380,7 +370,7 @@ def _resolve_source_prim( body_patterns = tuple(destination_template.format(env_id) + suffix for env_id in env_ids) else: body_patterns = (body_path,) - return body_patterns, wp.transform(pos, quat), scale, False, env_ids, None + return body_patterns, wp.transform(pos, quat), scale, False, env_ids body_prim = body_prim.GetParent() ref_path = source_root @@ -391,7 +381,7 @@ def _resolve_source_prim( ref_path = source_root[: -len(source_suffix)] if source_suffix else source_root ref_prim = stage.GetPrimAtPath(ref_path) if ref_path is not None else None pos, quat = sim_utils.resolve_prim_pose(prim, ref_prim if ref_prim and ref_prim.IsValid() else None) - return None, wp.transform(pos, quat), scale, source_root is not None, env_ids, destination_template + return None, wp.transform(pos, quat), scale, source_root is not None, env_ids def _on_physics_ready(self, _event) -> None: """Callback invoked when the Newton model becomes available.""" @@ -431,7 +421,7 @@ def _initialize_from_specs(self, model) -> None: site_locals: list[list[float]] = [] site_scales: list[tuple[float, float, float]] = [] - for body_patterns, xform, scale, per_world, env_ids, _template in self._site_specs: + for body_patterns, xform, scale, per_world, env_ids in self._site_specs: if body_patterns is None: if per_world: if NewtonManager._world_xforms is None: diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index 60bda125b765..f95d1483a235 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -12,14 +12,9 @@ import torch import warp as wp from isaaclab_newton.cloner import newton_clone_utils as newton_clone_utils_module -from isaaclab_newton.cloner.newton_clone_utils import ( - _BUILTIN_LABEL_TYPES, - rename_builder_labels, - replicate_builder_mapping, -) +from isaaclab_newton.cloner.newton_clone_utils import rename_builder_labels, replicate_builder_mapping from isaaclab_newton.physics import visualization_builder as visualization_builder_module from isaaclab_newton.physics import visualization_deformables as visualization_deformables_module -from newton.solvers import SolverMuJoCo from pxr import Usd, UsdGeom @@ -38,9 +33,8 @@ _VIS_BUILTIN_LABEL_ATTRS = tuple(attr for attr in _VIS_LABEL_SUFFIXES if attr != "equality_constraint_label") _VIS_EQ_FREQ = "mujoco:equality_constraint" -_TENDON_FREQ = "mujoco:tendon" -_SRC = "/Sources/protoA" -_DST = "/World/envs/env_{}" +_SRC = "/World/envs/env_0/protoA" +_DST = "/World/envs/env_{}/protoA" class _FakeVisualizationModelBuilder: @@ -125,35 +119,10 @@ def _record_world_slice(self, label_start, label_end, geometry_start, geometry_e self.world_slices[self._current_world].append((label_start, label_end, geometry_start, geometry_end)) -def _inject_builtins(builder: newton.ModelBuilder, types: tuple[str, ...], src_path: str, worlds: list[int]) -> None: - for kind in types: - for world in worlds: - if kind == "equality_constraint": - builder.add_custom_values( - **{ - "mujoco:equality_constraint_label": f"{src_path}/{kind}_{world}", - "mujoco:equality_constraint_world": world, - } - ) - else: - getattr(builder, f"{kind}_label").append(f"{src_path}/{kind}_{world}") - getattr(builder, f"{kind}_world").append(world) - - -def _inject_tendons(builder: newton.ModelBuilder, src_path: str, worlds: list[int]) -> None: - labels = builder.custom_attributes["mujoco:tendon_label"].values = [] - world_ids = builder.custom_attributes["mujoco:tendon_world"].values = [] - for world in worlds: - labels.append(f"{src_path}/Tendon_{world}") - world_ids.append(world) - builder._custom_frequency_counts[_TENDON_FREQ] = len(worlds) - - def _make_builder(worlds: list[int]) -> newton.ModelBuilder: builder = newton.ModelBuilder() - SolverMuJoCo.register_custom_attributes(builder) - _inject_builtins(builder, _BUILTIN_LABEL_TYPES, _SRC, worlds) - _inject_tendons(builder, _SRC, worlds) + builder.shape_label.extend(f"{_SRC}/shape_{world}" for world in worlds) + builder.shape_world.extend(worlds) return builder @@ -178,73 +147,10 @@ def _populate_custom_frequency(builder, freq_name, string_columns, worlds): builder._custom_frequency_counts[f"syn:{freq_name}"] = len(worlds) -class TestRenameBuilderLabels(unittest.TestCase): - def setUp(self): - self.worlds = [0, 1, 2] - self.env_ids = torch.tensor(self.worlds, dtype=torch.int32) - self.mapping = torch.ones(1, len(self.worlds), dtype=torch.bool) - - def _rename(self, builder): - rename_builder_labels(builder, [_SRC], [_DST], self.env_ids, self.mapping) - - def _assert_builtins(self, builder, types=_BUILTIN_LABEL_TYPES): - for kind in types: - if kind == "equality_constraint": - labels = builder.custom_attributes["mujoco:equality_constraint_label"].values - worlds = builder.custom_attributes["mujoco:equality_constraint_world"].values - else: - labels = getattr(builder, f"{kind}_label") - worlds = getattr(builder, f"{kind}_world") - self.assertEqual( - labels, - [f"{_DST.format(int(w))}/{kind}_{int(w)}" for w in worlds], - ) - - def test_builtin_and_tendon_labels_rewritten_per_world(self): - builder = _make_builder(self.worlds) - self._rename(builder) - self._assert_builtins(builder) - tendon_worlds = builder.custom_attributes["mujoco:tendon_world"].values - self.assertEqual( - builder.custom_attributes["mujoco:tendon_label"].values, - [f"{_DST.format(int(w))}/Tendon_{int(w)}" for w in tendon_worlds], - ) - - def test_source_root_boundary_cases(self): - builder = _make_builder(self.worlds) - builder.body_label.append(_SRC) - builder.body_world.append(self.worlds[0]) - self._rename(builder) - self.assertEqual(builder.body_label[-1], _DST.format(self.worlds[0])) - - builder = _make_builder(self.worlds) - rename_builder_labels(builder, [f"{_SRC}/"], [_DST], self.env_ids, self.mapping) - self._assert_builtins(builder) - - def test_unmatched_rows_left_untouched(self): - builder = _make_builder(self.worlds) - builder.body_label.append(f"{_SRC}/body_99") - builder.body_world.append(99) - builder.custom_attributes["mujoco:tendon_label"].values.append("named_tendon") - builder.custom_attributes["mujoco:tendon_world"].values.append(self.worlds[0]) - self._rename(builder) - self.assertEqual(builder.body_label[-1], f"{_SRC}/body_99") - self.assertEqual(builder.custom_attributes["mujoco:tendon_label"].values[-1], "named_tendon") - - def test_sparse_env_ids(self): - for worlds in ([10, 20, 30], [0, 1_000_000, 2_147_000_000]): - builder = newton.ModelBuilder() - SolverMuJoCo.register_custom_attributes(builder) - _inject_builtins(builder, ("body",), _SRC, worlds) - env_ids = torch.tensor(worlds, dtype=torch.int32) - rename_builder_labels(builder, [_SRC], [_DST], env_ids, torch.ones(1, len(worlds), dtype=torch.bool)) - self._assert_builtins(builder, ("body",)) - - class TestRenameCustomAttributes(unittest.TestCase): def setUp(self): self.worlds = [0, 1] - self.env_ids = torch.tensor(self.worlds, dtype=torch.int32) + self.env_ids = torch.tensor([10, 20], dtype=torch.int32) self.mapping = torch.ones(1, len(self.worlds), dtype=torch.bool) def test_custom_string_columns_follow_frequency_worlds(self): @@ -260,16 +166,9 @@ def test_custom_string_columns_follow_frequency_worlds(self): for column in columns: self.assertEqual( builder.custom_attributes[f"syn:{column}"].values, - [f"{_DST.format(int(w))}/{column}_{int(w)}" for w in worlds], + [f"{_DST.format(int(self.env_ids[w]))}/{column}_{int(w)}" for w in worlds], ) - def test_empty_custom_string_column_passes_through(self): - builder = newton.ModelBuilder() - _add_custom_frequency(builder, "freqA", ["freqA_label"]) - rename_builder_labels(builder, [_SRC], [_DST], self.env_ids, self.mapping) - _populate_custom_frequency(builder, "freqA", ["freqA_label"], self.worlds) - self.assertEqual(len(builder.custom_attributes["syn:freqA_label"].values), len(self.worlds)) - def test_custom_string_columns_ignore_unset_world_rows(self): builder = newton.ModelBuilder() _add_custom_frequency(builder, "freqA", ["freqA_label"]) @@ -281,7 +180,7 @@ def test_custom_string_columns_ignore_unset_world_rows(self): self.assertEqual( builder.custom_attributes["syn:freqA_label"].values, - ["unassigned", f"{_DST.format(self.worlds[0])}/freqA_label_{self.worlds[0]}"], + ["unassigned", f"{_DST.format(int(self.env_ids[0]))}/freqA_label_{self.worlds[0]}"], ) def test_shape_material_paths_follow_shape_worlds(self): @@ -300,7 +199,9 @@ def test_shape_material_paths_follow_shape_worlds(self): rename_builder_labels(builder, [_SRC], [_DST], self.env_ids, self.mapping) - self.assertEqual(paths, {index: f"{_DST.format(index)}/Looks/material" for index in range(len(self.worlds))}) + self.assertEqual( + paths, {index: f"{_DST.format(int(self.env_ids[index]))}/Looks/material" for index in self.worlds} + ) def test_other_shape_attributes_without_world_references_pass_through(self): builder = _make_builder(self.worlds) @@ -321,26 +222,6 @@ def test_other_shape_attributes_without_world_references_pass_through(self): self.assertEqual(notes, {index: f"{_SRC}/note" for index in range(len(self.worlds))}) -class TestRenameMultiSource(unittest.TestCase): - def test_prefix_overlap_does_not_cross_contaminate(self): - sources = ["/Sources/protoA", "/Sources/protoAB"] - builder = newton.ModelBuilder() - SolverMuJoCo.register_custom_attributes(builder) - builder.body_label.extend([f"{sources[0]}/body", f"{sources[1]}/body"] * 2) - builder.body_world.extend([0, 0, 1, 1]) - rename_builder_labels( - builder, - sources, - ["/World/envs/env_{}", "/World/envs/env_{}"], - torch.tensor([0, 1], dtype=torch.int32), - torch.tensor([[1, 1], [1, 1]], dtype=torch.bool), - ) - self.assertEqual( - builder.body_label, - ["/World/envs/env_0/body", "/World/envs/env_0/body", "/World/envs/env_1/body", "/World/envs/env_1/body"], - ) - - class TestReplicateBuilderMapping(unittest.TestCase): @staticmethod def _source_builder(root_path: str): @@ -349,6 +230,7 @@ def _source_builder(root_path: str): return builder def test_source_local_sites_batched_with_correct_indices(self): + source_path, destination = "/World/envs/env_0", "/World/envs/env_{}" source = newton.ModelBuilder() source.add_body(xform=wp.transform((2.0, 0.0, 0.0), wp.quat_identity())) site_idx = source.add_site(body=0, xform=wp.transform(), label="ee") @@ -366,11 +248,13 @@ def test_source_local_sites_batched_with_correct_indices(self): with mock.patch.object(builder, "replicate", wraps=builder.replicate) as replicate: local_site_map, _, _ = replicate_builder_mapping( builder, - (_SRC,), + (source_path,), torch.ones((1, 3), dtype=torch.bool), positions, quaternions, - {_SRC: source}, + {source_path: source}, + destinations=(destination,), + env_ids=torch.arange(3), source_site_indices={id(source): {"ee": [site_idx]}}, ) @@ -379,10 +263,11 @@ def test_source_local_sites_batched_with_correct_indices(self): local_site_map["ee"], [[base_shape + world * stride + site_idx] for world in range(3)], ) - for world_indices in local_site_map["ee"]: - self.assertEqual(builder.shape_label[world_indices[0]], "ee") + for world, world_indices in enumerate(local_site_map["ee"]): + self.assertEqual(builder.shape_label[world_indices[0]], f"/World/envs/env_{world}/ee") def test_env_root_sites_batched_at_correct_world_positions(self): + source_path, destination = "/World/envs/env_0", "/World/envs/env_{}" source = newton.ModelBuilder() source.add_body(xform=wp.transform((2.0, 0.0, 0.0), wp.quat_identity())) @@ -395,13 +280,14 @@ def test_env_root_sites_batched_at_correct_world_positions(self): with mock.patch.object(builder, "replicate", wraps=builder.replicate) as replicate: local_site_map, _, _ = replicate_builder_mapping( builder, - (_SRC,), + (source_path,), torch.ones((1, 3), dtype=torch.bool), positions, quaternions, - {_SRC: source}, - env_root_sites={"origin": (env_root_offset, None)}, + {source_path: source}, + destinations=(destination,), env_ids=torch.arange(3, dtype=torch.long), + env_root_sites={"origin": env_root_offset}, ) replicate.assert_called_once() @@ -415,11 +301,12 @@ def test_env_root_sites_batched_at_correct_world_positions(self): site_pos = builder.shape_transform[world_indices[0]].p self.assertAlmostEqual(float(site_pos[0]), float(positions[world][0]) + 0.1, places=5) self.assertAlmostEqual(float(site_pos[1]), 0.0, places=5) - self.assertEqual(builder.shape_label[world_indices[0]], "origin") + self.assertEqual(builder.shape_label[world_indices[0]], f"/World/envs/env_{world}/origin") def test_inactive_source_rows_are_ignored(self): - sources = ("/Sources/inactive", "/Sources/active") + sources = ("/World/envs/env_0/inactive", "/World/envs/env_0/active") source_builders = {source: self._source_builder(source) for source in sources} + source_builders[sources[0]].body_label.append("/outside/the/plan") builder = _FakeVisualizationModelBuilder() replicate_builder_mapping( @@ -429,9 +316,11 @@ def test_inactive_source_rows_are_ignored(self): torch.tensor([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), torch.tensor([[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0]]), source_builders, + destinations=("/World/envs/env_{}/inactive", "/World/envs/env_{}/active"), + env_ids=torch.arange(2), ) - self.assertEqual(builder.geometry_sources_for_world(0), ["/Sources/active"]) + self.assertEqual(builder.geometry_sources_for_world(0), ["/World/envs/env_0/active"]) self.assertEqual(builder.geometry_sources_for_world(1), []) @@ -556,185 +445,68 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) ) -class TestEnvRootSiteLabels(unittest.TestCase): - """A per-world site names the environment it lands in, rather than repeating one name.""" - - _DST = "/World/envs/env_{}/Robot" - _WORLDS = 4 - - def _site_labels(self, sources, mapping): - """Replicate one env-root site across worlds and return its label in each, in world order.""" - builder = newton.ModelBuilder() - env_ids = torch.arange(self._WORLDS, dtype=torch.long) - replicate_builder_mapping( - builder, - sources, - mapping, - torch.zeros((self._WORLDS, 3)), - torch.tensor([[0.0, 0.0, 0.0, 1.0]] * self._WORLDS), - {source: newton.ModelBuilder() for source in sources}, - env_root_sites={"ft_0": (wp.transform(), self._DST)}, - env_ids=env_ids, - ) - rename_builder_labels(builder, sources, [self._DST] * len(sources), env_ids, mapping) - return [label for label in builder.shape_label if label.endswith("ft_0")] - - def _expected(self): - return [self._DST.format(env_id) + "/ft_0" for env_id in range(self._WORLDS)] - - def test_one_row_covering_every_world_names_each_environment(self): - mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) - self.assertEqual(self._site_labels(("/World/envs/env_0/Robot",), mapping), self._expected()) - - def test_rows_partitioning_the_worlds_name_each_environment(self): - """Prototype variants split the envs between them, so no single row covers every world.""" - sources = ("/World/envs/env_0/Robot", "/World/envs/env_2/Robot") - mapping = torch.tensor([[True, True, False, False], [False, False, True, True]]) - self.assertEqual(self._site_labels(sources, mapping), self._expected()) - - -class TestReplicationNamesItsCopies: - """Replication naming each copy must give exactly what rewriting afterwards gives.""" - - _SRC, _DST, _WORLDS = "/World/envs/env_0/Robot", "/World/envs/env_{}/Robot", 4 +class TestReplicationNamesItsCopies(unittest.TestCase): + _SRC = "/World/envs/env_0/Robot" _ENV = "/World/envs/env_{}" - @classmethod - def _prototype(cls) -> newton.ModelBuilder: + def test_batched_prefixes_name_each_world_and_preserve_the_prototype(self): source = newton.ModelBuilder() - body = source.add_link(xform=wp.transform(), label=cls._SRC) - source.add_shape_box(body=body, label=f"{cls._SRC}/base") - child = source.add_link(xform=wp.transform(), label=f"{cls._SRC}/link") - source.add_joint_revolute(parent=body, child=child, axis=(0.0, 0.0, 1.0), label=f"{cls._SRC}/hinge") - source.add_articulation([0], label=cls._SRC) - return source - - def _run(self, *, delegate: bool, env_root_site: bool = False): - builder = newton.ModelBuilder() - env_ids = torch.arange(self._WORLDS, dtype=torch.long) - mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) - positions = torch.zeros((self._WORLDS, 3), dtype=torch.float32) - quaternions = torch.zeros((self._WORLDS, 4), dtype=torch.float32) - quaternions[:, 3] = 1.0 - extra = {"env_ids": env_ids, "env_template": self._ENV} if delegate else {} - sites = {"ft_0": (wp.transform(), self._DST)} if env_root_site else {} - _, _, named = replicate_builder_mapping( - builder, - [self._SRC], - mapping, - positions, - quaternions, - {self._SRC: self._prototype()}, - env_root_sites=sites, - **extra, - ) - rename_builder_labels(builder, [self._SRC], [self._DST], env_ids, mapping, skip_entity_labels=named) - return builder, named - - @staticmethod - def _labels(builder) -> dict[str, list[str]]: - return { - name: list(getattr(builder, name)) + body = source.add_body(xform=wp.transform(), label=self._SRC) + source.add_shape_box(body=body, label=f"{self._SRC}/shape") + child = source.add_link(xform=wp.transform(), label=f"{self._SRC}/link") + source.add_joint_revolute(parent=body, child=child, axis=(0.0, 0.0, 1.0), label=f"{self._SRC}/hinge") + original = { + name: list(getattr(source, name)) for name in ("body_label", "joint_label", "shape_label", "articulation_label") } - - def test_every_label_matches_the_rewritten_path(self): - delegated, named = self._run(delegate=True) - rewritten, _ = self._run(delegate=False) - - assert named is True - assert self._labels(delegated) == self._labels(rewritten) - - def test_an_env_root_site_matches_too(self): - delegated, named = self._run(delegate=True, env_root_site=True) - rewritten, _ = self._run(delegate=False, env_root_site=True) - - assert named is True - assert self._labels(delegated) == self._labels(rewritten) - - def test_each_copy_names_its_own_environment(self): - builder, _ = self._run(delegate=True) - - assert builder.shape_label == [f"{self._DST.format(env_id)}/base" for env_id in range(self._WORLDS)] - - def test_a_prototype_outside_the_environments_is_not_delegated(self): - """A prototype that is not an instance of the env template has no env to prefix with.""" - builder = newton.ModelBuilder() - env_ids = torch.arange(self._WORLDS, dtype=torch.long) - mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) - positions = torch.zeros((self._WORLDS, 3), dtype=torch.float32) - quaternions = torch.zeros((self._WORLDS, 4), dtype=torch.float32) - quaternions[:, 3] = 1.0 - source = newton.ModelBuilder() - source.add_link(xform=wp.transform(), label="/Sources/protoA") - - _, _, named = replicate_builder_mapping( - builder, - ["/Sources/protoA"], - mapping, - positions, - quaternions, - {"/Sources/protoA": source}, - env_ids=env_ids, - env_template="/World/envs/env_{}", - ) - - assert named is False - - def test_a_label_outside_the_prototype_is_not_delegated(self): - """Replication prefixes every label, so one that is not under the split cannot be spelled.""" - source = self._prototype() - source.add_link(xform=wp.transform(), label="/elsewhere/beacon") - builder = newton.ModelBuilder() - env_ids = torch.arange(self._WORLDS, dtype=torch.long) - mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) - positions = torch.zeros((self._WORLDS, 3), dtype=torch.float32) - quaternions = torch.zeros((self._WORLDS, 4), dtype=torch.float32) + env_ids = torch.tensor([10, 20], dtype=torch.long) + mapping = torch.ones(1, len(env_ids), dtype=torch.bool) + positions = torch.zeros((len(env_ids), 3), dtype=torch.float32) + quaternions = torch.zeros((len(env_ids), 4), dtype=torch.float32) quaternions[:, 3] = 1.0 - _, _, named = replicate_builder_mapping( + replicate_builder_mapping( builder, [self._SRC], mapping, positions, quaternions, {self._SRC: source}, + destinations=["/World/envs/env_{}/Robot"], env_ids=env_ids, - env_template=self._ENV, ) - - assert named is False - assert "/elsewhere/beacon" in builder.body_label - - def test_a_generated_sibling_of_the_root_is_rebased(self): - """add_body derives "_free_joint", which the split above the leaf still covers.""" + for name, source_labels in original.items(): + expected = [ + label.replace(self._SRC, f"{self._ENV.format(i)}/Robot", 1) for i in env_ids for label in source_labels + ] + self.assertEqual(getattr(builder, name), expected) + self.assertEqual(getattr(source, name), source_labels) + + def test_hook_labels_are_rewritten_after_the_slow_path(self): source = newton.ModelBuilder() - source.add_body(xform=wp.transform(), label=self._SRC) - + source.add_body(label=f"{self._SRC}/base") builder = newton.ModelBuilder() - env_ids = torch.arange(self._WORLDS, dtype=torch.long) - mapping = torch.ones(1, self._WORLDS, dtype=torch.bool) - positions = torch.zeros((self._WORLDS, 3), dtype=torch.float32) - quaternions = torch.zeros((self._WORLDS, 4), dtype=torch.float32) - quaternions[:, 3] = 1.0 - _, _, named = replicate_builder_mapping( + env_ids = torch.tensor([10, 20]) + mapping = torch.ones((1, 2), dtype=torch.bool) + + def hook(builder, *_): + builder.add_body(label=f"{self._SRC}/hook") + + replicate_builder_mapping( builder, - [self._SRC], + (self._SRC,), mapping, - positions, - quaternions, + torch.zeros((2, 3)), + torch.tensor([[0.0, 0.0, 0.0, 1.0]] * 2), {self._SRC: source}, + destinations=("/World/envs/env_{}/Robot",), env_ids=env_ids, - env_template=self._ENV, + per_world_builder_hooks=(hook,), + ) + self.assertEqual( + builder.body_label, + [f"/World/envs/env_{env_id}/Robot/{label}" for env_id in env_ids for label in ("base", "hook")], ) - - assert named is True - assert builder.joint_label == [f"{self._DST.format(env_id)}_free_joint" for env_id in range(self._WORLDS)] - - def test_without_a_plan_the_caller_still_rewrites(self): - _, named = self._run(delegate=False) - - assert named is False if __name__ == "__main__": diff --git a/source/isaaclab_newton/test/physics/test_vbd_core.py b/source/isaaclab_newton/test/physics/test_vbd_core.py index 166380f59b4f..4e0db788a4d4 100644 --- a/source/isaaclab_newton/test/physics/test_vbd_core.py +++ b/source/isaaclab_newton/test/physics/test_vbd_core.py @@ -132,7 +132,7 @@ def create_builder(cls, *, up_axis): def replicate(*args, **kwargs): replicate_calls.append(kwargs) - return {}, [object() for _ in env_paths], False + return {}, [object() for _ in env_paths], [] monkeypatch.setattr(newton_module, "get_current_stage", lambda: stage) monkeypatch.setattr(pxr, "UsdGeom", usd_geom) diff --git a/source/isaaclab_newton/test/sensors/test_site_injection.py b/source/isaaclab_newton/test/sensors/test_site_injection.py index 31456e039918..0b9fec3c8adc 100644 --- a/source/isaaclab_newton/test/sensors/test_site_injection.py +++ b/source/isaaclab_newton/test/sensors/test_site_injection.py @@ -86,7 +86,7 @@ def setup_method(self): def test_global_site_entry_is_int_none_tuple(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {(None, False, tuple(xform), None): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {(None, False, tuple(xform)): ("ft_0", xform)} NewtonManager._cl_inject_sites_fallback() entry = NewtonManager._cl_site_index_map["ft_0"] @@ -96,7 +96,7 @@ def test_global_site_entry_is_int_none_tuple(self): def test_global_site_pending_cleared(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {(None, False, tuple(xform), None): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {(None, False, tuple(xform)): ("ft_0", xform)} NewtonManager._cl_inject_sites_fallback() assert len(NewtonManager._cl_pending_sites) == 0 @@ -111,7 +111,7 @@ def setup_method(self): def test_single_body_entry_shape(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {("Robot/base", False, tuple(xform), None): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {("Robot/base", False, tuple(xform)): ("ft_0", xform)} NewtonManager._cl_inject_sites_fallback() entry = NewtonManager._cl_site_index_map["ft_0"] @@ -132,7 +132,7 @@ def setup_method(self): def test_wildcard_entry_shape(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {("Robot/.*_foot", False, tuple(xform), None): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {("Robot/.*_foot", False, tuple(xform)): ("ft_0", xform)} NewtonManager._cl_inject_sites_fallback() entry = NewtonManager._cl_site_index_map["ft_0"] @@ -143,7 +143,7 @@ def test_wildcard_entry_shape(self): def test_no_match_raises(self): xform = wp.transform() - NewtonManager._cl_pending_sites = {("Robot/nonexistent", False, tuple(xform), None): ("ft_0", xform)} + NewtonManager._cl_pending_sites = {("Robot/nonexistent", False, tuple(xform)): ("ft_0", xform)} with pytest.raises(ValueError): NewtonManager._cl_inject_sites_fallback() @@ -180,30 +180,9 @@ def test_inject_sites_returns_world_sites(self): assert global_sites == {} assert proto_sites == {} - assert world_sites[label] == (xform, None) + assert world_sites[label] == xform assert NewtonManager._cl_pending_sites == {} - def test_a_world_site_carries_the_template_naming_its_environment(self): - """So replication can label it with the env it lands in rather than one shared name.""" - xform = wp.transform((1.0, 2.0, 3.0), wp.quat_identity()) - template = "/World/envs/env_{}/Robot" - label = NewtonManager.cl_register_site(None, xform, per_world=True, destination_template=template) - _, _, world_sites = NewtonManager._cl_inject_sites(MockBuilder([]), {}) - - assert world_sites[label] == (xform, template) - - def test_world_sites_with_different_templates_get_different_labels(self): - xform = wp.transform() - label_0 = NewtonManager.cl_register_site(None, xform, per_world=True, destination_template="/World/env_{}/A") - label_1 = NewtonManager.cl_register_site(None, xform, per_world=True, destination_template="/World/env_{}/B") - - assert label_0 != label_1 - - def test_destination_template_requires_a_per_world_site(self): - xform = wp.transform() - with pytest.raises(ValueError): - NewtonManager.cl_register_site(None, xform, destination_template="/World/envs/env_{}/Robot") - # --------------------------------------------------------------------------- # FrameTransformer._validate_site_map diff --git a/source/isaaclab_ov/changelog.d/brewster-replication-names-copies.skip b/source/isaaclab_ov/changelog.d/brewster-replication-names-copies.skip deleted file mode 100644 index 39fd30abae89..000000000000 --- a/source/isaaclab_ov/changelog.d/brewster-replication-names-copies.skip +++ /dev/null @@ -1 +0,0 @@ -Signature-only: the replication context accepts the clone plan's env_template alongside global_paths, and ignores it. diff --git a/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py b/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py index c80ee07bab36..1a6f43bd2396 100644 --- a/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py +++ b/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py @@ -28,7 +28,6 @@ from pxr import Gf, Sdf, Usd, UsdGeom from isaaclab import cloner -from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from isaaclab_ov._clone import CloneTransform, clone_transforms_from_positions @@ -81,7 +80,7 @@ class OvPhysxReplicateContext: replicate_priority = 0 - def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = (), env_template: str = DEFAULT_ENV_TEMPLATE): + def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): """Initialize the context. Args: diff --git a/tools/wheel_builder/uv-overrides.txt b/tools/wheel_builder/uv-overrides.txt index 95a0eec25afa..bbf4df976ab0 100644 --- a/tools/wheel_builder/uv-overrides.txt +++ b/tools/wheel_builder/uv-overrides.txt @@ -1,7 +1,7 @@ numpy>=2 mujoco~=3.11.0 mujoco-warp~=3.11.0 -newton[sim]==1.5.1 +newton[sim] @ git+https://github.com/newton-physics/newton.git@24bd863528d6b91137408930d0fbe8fa216ad962 newton-usd-schemas>=0.4.1 torch==2.11.0 torchvision==0.26.0 diff --git a/uv.lock b/uv.lock index ed3d493bfcc9..acf8e8ccab6d 100644 --- a/uv.lock +++ b/uv.lock @@ -2042,7 +2042,7 @@ requires-dist = [ { name = "meshio", specifier = ">=5.3.5" }, { name = "moviepy", marker = "extra == 'video'", specifier = ">=1.0.3,<2.0.0.dev0" }, { name = "myst-parser", marker = "extra == 'test'" }, - { name = "newton", extras = ["sim"], specifier = ">=1.2.0" }, + { name = "newton", extras = ["sim"], git = "https://github.com/newton-physics/newton.git?rev=24bd863528d6b91137408930d0fbe8fa216ad962" }, { name = "newton-usd-schemas", specifier = ">=0.2.0" }, { name = "numba", specifier = ">=0.63.1" }, { name = "numpy", specifier = ">=2" }, From 844f4c4db0b1f6fb829a2e91980293183260d1d3 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 2 Sep 2026 02:39:44 -0700 Subject: [PATCH 5/9] Mark core dependency metadata release-neutral --- .../isaaclab/changelog.d/brewster-replication-names-copies.skip | 1 + 1 file changed, 1 insertion(+) create mode 100644 source/isaaclab/changelog.d/brewster-replication-names-copies.skip diff --git a/source/isaaclab/changelog.d/brewster-replication-names-copies.skip b/source/isaaclab/changelog.d/brewster-replication-names-copies.skip new file mode 100644 index 000000000000..29811b819395 --- /dev/null +++ b/source/isaaclab/changelog.d/brewster-replication-names-copies.skip @@ -0,0 +1 @@ +Dependency pin coverage is documented by the Newton package fragment. From cb0cbe647c9dd7cdd6ee783ac0927174f3911dfe Mon Sep 17 00:00:00 2001 From: camevor Date: Wed, 2 Sep 2026 12:41:11 +0200 Subject: [PATCH 6/9] Name root joints; rename fragments --- ...ies.skip => replication-names-copies.skip} | 0 ...opies.rst => replication-names-copies.rst} | 0 .../changelog.d/root-joint-names.rst | 5 +++ .../cloner/newton_clone_utils.py | 15 ++++++- .../test/cloner/test_rename_builder_labels.py | 41 +++++++++++++++++++ 5 files changed, 60 insertions(+), 1 deletion(-) rename source/isaaclab/changelog.d/{brewster-replication-names-copies.skip => replication-names-copies.skip} (100%) rename source/isaaclab_newton/changelog.d/{brewster-replication-names-copies.rst => replication-names-copies.rst} (100%) create mode 100644 source/isaaclab_newton/changelog.d/root-joint-names.rst diff --git a/source/isaaclab/changelog.d/brewster-replication-names-copies.skip b/source/isaaclab/changelog.d/replication-names-copies.skip similarity index 100% rename from source/isaaclab/changelog.d/brewster-replication-names-copies.skip rename to source/isaaclab/changelog.d/replication-names-copies.skip diff --git a/source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst b/source/isaaclab_newton/changelog.d/replication-names-copies.rst similarity index 100% rename from source/isaaclab_newton/changelog.d/brewster-replication-names-copies.rst rename to source/isaaclab_newton/changelog.d/replication-names-copies.rst diff --git a/source/isaaclab_newton/changelog.d/root-joint-names.rst b/source/isaaclab_newton/changelog.d/root-joint-names.rst new file mode 100644 index 000000000000..5f532697b71c --- /dev/null +++ b/source/isaaclab_newton/changelog.d/root-joint-names.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed importer-generated floating-base root joints to use ``{body}_free_joint`` instead of + generated ``joint_`` labels, giving replicated environments stable body-derived joint paths. diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index e306b0325d2e..d43177e828e0 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -11,7 +11,7 @@ import numpy as np import torch import warp as wp -from newton import GeoType, ModelBuilder, ShapeFlags +from newton import GeoType, JointType, ModelBuilder, ShapeFlags from pxr import Usd, UsdGeom, UsdPhysics @@ -148,9 +148,22 @@ def _build_source_builder( replace_newton_builder_shape_colors(builder, stage) if load_visual_shapes: import_builder_visual_material_paths(builder, stage) + _name_root_joints_after_their_body(builder) return builder +def _name_root_joints_after_their_body(builder: ModelBuilder) -> None: + """Name importer-generated free root joints after their child bodies, in place.""" + for index, label in enumerate(builder.joint_label): + if not isinstance(label, str) or not label.startswith("joint_") or not label[6:].isdigit(): + continue + if builder.joint_type[index] != JointType.FREE or builder.joint_parent[index] != -1: + continue + body_label = builder.body_label[builder.joint_child[index]] + if isinstance(body_label, str) and body_label.startswith("/"): + builder.joint_label[index] = f"{body_label}_free_joint" + + def _quat_multiply(a: np.ndarray, b: np.ndarray) -> np.ndarray: """Hamilton product of xyzw quaternion arrays, broadcast over the leading axes.""" ax, ay, az, aw = a[..., 0], a[..., 1], a[..., 2], a[..., 3] diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index f95d1483a235..c06a761f24b9 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -509,5 +509,46 @@ def hook(builder, *_): ) +class TestRootJointNaming(unittest.TestCase): + """The importer leaves a floating base's root joint unnamed; every other entity is named.""" + + _SOURCE = "/World/envs/env_0/Robot" + _BODY = "/World/envs/env_0/Robot/pelvis" + + @staticmethod + def _builder_with_free_root(body_label: str) -> newton.ModelBuilder: + builder = newton.ModelBuilder() + body = builder.add_link(xform=wp.transform(), label=body_label) + builder.add_joint_free(child=body) + return builder + + def test_a_generated_root_joint_name_becomes_its_body_path(self): + builder = self._builder_with_free_root(self._BODY) + self.assertFalse(builder.joint_label[0].startswith("/")) + + newton_clone_utils_module._name_root_joints_after_their_body(builder) + + self.assertEqual(builder.joint_label[0], f"{self._BODY}_free_joint") + + def test_other_joint_labels_are_left_alone(self): + named = self._builder_with_free_root(self._BODY) + named.joint_label[0] = "authored" + + non_free = newton.ModelBuilder() + parent = non_free.add_link(xform=wp.transform(), label=self._BODY) + child = non_free.add_link(xform=wp.transform(), label=f"{self._BODY}/link") + non_free.add_joint_revolute(parent=parent, child=child, axis=(0.0, 0.0, 1.0)) + + non_root = newton.ModelBuilder() + parent = non_root.add_link(xform=wp.transform(), label=self._BODY) + child = non_root.add_link(xform=wp.transform(), label=f"{self._BODY}/link") + non_root.add_joint_free(parent=parent, child=child) + + for builder in (named, non_free, non_root): + original = list(builder.joint_label) + newton_clone_utils_module._name_root_joints_after_their_body(builder) + self.assertEqual(builder.joint_label, original) + + if __name__ == "__main__": unittest.main() From be85711d3f36fe9a4651b9ad6f3cb1eb86218239 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 3 Sep 2026 03:13:53 -0700 Subject: [PATCH 7/9] Repoint packages in expanded prebundles --- .../pr-7453-expanded-prebundles.rst | 4 ++++ .../isaaclab/isaaclab/cli/commands/install.py | 14 +++++++++----- .../test/cli/test_install_commands.py | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 source/isaaclab/changelog.d/pr-7453-expanded-prebundles.rst diff --git a/source/isaaclab/changelog.d/pr-7453-expanded-prebundles.rst b/source/isaaclab/changelog.d/pr-7453-expanded-prebundles.rst new file mode 100644 index 000000000000..0d21d986a7ad --- /dev/null +++ b/source/isaaclab/changelog.d/pr-7453-expanded-prebundles.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed Docker installs leaving dangling package links in expanded Isaac Sim prebundles. diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index 457f7b62cc04..b30bcfeaec5a 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -1062,10 +1062,14 @@ def _repoint_prebundle_packages() -> None: print_debug("No pip_prebundle directories found under Isaac Sim.") return + # Extras are expanded as wheel trees nested below pip_prebundle. + package_roots = prebundle_dirs | { + path for prebundle_dir in prebundle_dirs for path in prebundle_dir.glob("*[[]*[]]/*") if path.is_dir() + } repointed = 0 - for prebundle_dir in prebundle_dirs: + for package_root in package_roots: for pkg_name in _PREBUNDLE_REPOINT_PACKAGES: - prebundled = prebundle_dir / pkg_name + prebundled = package_root / pkg_name venv_pkg = site_packages / pkg_name if not venv_pkg.exists(): @@ -1118,9 +1122,9 @@ def _repoint_prebundle_packages() -> None: # env package into the prebundle, which is a real directory by design. if use_symlinks and (site_packages / "torch").exists(): shadowing = [ - prebundle_dir / "torch" - for prebundle_dir in prebundle_dirs - if (prebundle_dir / "torch").is_dir() and not (prebundle_dir / "torch").is_symlink() + package_root / "torch" + for package_root in package_roots + if (package_root / "torch").is_dir() and not (package_root / "torch").is_symlink() ] if shadowing: raise RuntimeError( diff --git a/source/isaaclab/test/cli/test_install_commands.py b/source/isaaclab/test/cli/test_install_commands.py index 43581295240c..c0c0d09b7403 100644 --- a/source/isaaclab/test/cli/test_install_commands.py +++ b/source/isaaclab/test/cli/test_install_commands.py @@ -930,6 +930,25 @@ def test_repoints_across_multiple_prebundle_dirs(self, tmp_path): for pb in (pb1, pb2): assert (pb / "torch").is_symlink(), f"torch in {pb} should be repointed" + def test_repoints_package_inside_expanded_extra_bundle(self, tmp_path): + """Expanded extras bundles must not retain file links into a replaced package.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["newton"]) + shared_init = prebundle / "newton" / "legacy" / "__init__.py" + shared_init.parent.mkdir() + shared_init.write_text("") + bundled_newton = prebundle / "newton[sim]" / "newton-wheel" / "newton" + bundled_init = bundled_newton / "legacy" / "__init__.py" + bundled_init.parent.mkdir(parents=True) + bundled_init.symlink_to(shared_init) + site_pkgs = _make_site_packages(tmp_path / "env", ["newton"]) + + with self._patch(isaacsim_path, site_pkgs, str(tmp_path / "env" / "bin" / "python")): + _repoint_prebundle_packages() + + assert (prebundle / "newton").resolve() == (site_pkgs / "newton").resolve() + assert bundled_newton.is_symlink() + assert bundled_newton.resolve() == (site_pkgs / "newton").resolve() + # ---- Windows: copy instead of symlink ----------------------------------- def test_copies_package_on_windows_instead_of_symlinking(self, tmp_path): From 0debe7b490d3f62aa2c27e33a40387761284fedc Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 3 Sep 2026 17:28:32 -0700 Subject: [PATCH 8/9] Fix Newton and kitless CI regressions --- .github/actions/run-tests/run_tests.sh | 5 ++++- docker/test/test_dockerfile_nonroot.py | 9 +++++++++ .../test/physics/test_newton_manager_abstraction.py | 10 +++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/actions/run-tests/run_tests.sh b/.github/actions/run-tests/run_tests.sh index d831101c9239..e2097cc4266e 100755 --- a/.github/actions/run-tests/run_tests.sh +++ b/.github/actions/run-tests/run_tests.sh @@ -321,7 +321,10 @@ run_tests() { cd /workspace/isaaclab mkdir -p tests rm _isaac_sim || true - ln -s /isaac-sim _isaac_sim + # Kitless cache mounts create /isaac-sim directories too; python.sh is the runtime boundary. + if [ -f /isaac-sim/python.sh ]; then + ln -s /isaac-sim _isaac_sim + fi if [ -n \"\${WARP_CACHE_PATH:-}\" ]; then ./isaaclab.sh -p tools/verify_warp_cache.py fi diff --git a/docker/test/test_dockerfile_nonroot.py b/docker/test/test_dockerfile_nonroot.py index 98b2c7d7495f..606e88bdcd8d 100644 --- a/docker/test/test_dockerfile_nonroot.py +++ b/docker/test/test_dockerfile_nonroot.py @@ -169,6 +169,15 @@ def test_kitless_dockerfile_installs_newton_rl_ov_and_visualizers_without_isaac_ ) +def test_container_test_runner_only_links_an_actual_isaac_sim_runtime(): + """Cache mount points under /isaac-sim must not masquerade as an Isaac Sim installation.""" + runner_text = (REPO_ROOT / ".github/actions/run-tests/run_tests.sh").read_text(encoding="utf-8") + guarded_link = re.compile(r"if \[ -f /isaac-sim/python\.sh \]; then\s+ln -s /isaac-sim _isaac_sim\s+fi") + + assert guarded_link.search(runner_text) + assert runner_text.count("ln -s /isaac-sim _isaac_sim") == 1 + + # --------------------------------------------------------------------------- # # Volume mount-point writability # diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 87d679e3deb1..8bd012e2901a 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -516,7 +516,8 @@ def test_mpm_solver_cfg_forwards_every_solver_field(field_name, value): @pytest.mark.parametrize("field_name, value", _KAMINO_PADMM_FIELD_VALUES) def test_kamino_solver_cfg_forwards_padmm_fields(field_name, value): """Every tunable P-ADMM cfg field round-trips into ``PADMMSolverConfig``.""" - solver_cfg = KaminoPADMMSolverCfg(dynamics_solver_cfg=KaminoPADMMCfg(**{field_name: value})) + sparse_kwargs = {"sparse_jacobian": True, "sparse_dynamics": True} if field_name == "penalty_update_method" else {} + solver_cfg = KaminoPADMMSolverCfg(**sparse_kwargs, dynamics_solver_cfg=KaminoPADMMCfg(**{field_name: value})) newton_cfg = solver_cfg.to_solver_config() assert hasattr(newton_cfg.padmm, field_name), ( f"{field_name!r} disappeared from PADMMSolverConfig — KaminoPADMMCfg needs to drop or rename it." @@ -524,6 +525,13 @@ def test_kamino_solver_cfg_forwards_padmm_fields(field_name, value): assert getattr(newton_cfg.padmm, field_name) == value +def test_kamino_padmm_rejects_adaptive_penalties_with_dense_dynamics(): + """Kamino's adaptive P-ADMM penalties require the sparse solver path.""" + solver_cfg = KaminoPADMMSolverCfg(dynamics_solver_cfg=KaminoPADMMCfg(penalty_update_method="balanced")) + with pytest.raises(ValueError, match="sparse_dynamics=True"): + solver_cfg.to_solver_config() + + @pytest.mark.parametrize("field_name, value", _KAMINO_DVI_FIELD_VALUES) def test_kamino_solver_cfg_forwards_dvi_fields(field_name, value): """Every tunable DVI cfg field round-trips into ``DVISolverConfig``.""" From 4daa8d839ff01b1e6e6a1f4b3d40fff88556626b Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Thu, 3 Sep 2026 19:53:45 -0700 Subject: [PATCH 9/9] Align runtime link contract with executable guard --- docker/test/test_dockerfile_nonroot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/test/test_dockerfile_nonroot.py b/docker/test/test_dockerfile_nonroot.py index 606e88bdcd8d..7cc54ab3289c 100644 --- a/docker/test/test_dockerfile_nonroot.py +++ b/docker/test/test_dockerfile_nonroot.py @@ -172,7 +172,7 @@ def test_kitless_dockerfile_installs_newton_rl_ov_and_visualizers_without_isaac_ def test_container_test_runner_only_links_an_actual_isaac_sim_runtime(): """Cache mount points under /isaac-sim must not masquerade as an Isaac Sim installation.""" runner_text = (REPO_ROOT / ".github/actions/run-tests/run_tests.sh").read_text(encoding="utf-8") - guarded_link = re.compile(r"if \[ -f /isaac-sim/python\.sh \]; then\s+ln -s /isaac-sim _isaac_sim\s+fi") + guarded_link = re.compile(r"if \[ -x /isaac-sim/python\.sh \]; then\s+ln -s /isaac-sim _isaac_sim;?\s+fi") assert guarded_link.search(runner_text) assert runner_text.count("ln -s /isaac-sim _isaac_sim") == 1