From d3816a93bddf57feb9e381b2e383c6d991c247cc Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:32:45 -0700 Subject: [PATCH 1/6] Fix CircularBuffer.buffer access before first append Backport #7131 to release/3.0.0. (cherry picked from commit 7c05e96caedda5be1b1673e0dc784bcdb97fdb1f) --- ...rek-fix-circular-buffer-uninitialized-buffer.fixed.rst | 2 ++ source/isaaclab/isaaclab/utils/buffers/circular_buffer.py | 5 +++++ source/isaaclab/test/utils/test_circular_buffer.py | 8 ++++++++ 3 files changed, 15 insertions(+) create mode 100644 source/isaaclab/changelog.d/sylvesterkaczmarek-fix-circular-buffer-uninitialized-buffer.fixed.rst diff --git a/source/isaaclab/changelog.d/sylvesterkaczmarek-fix-circular-buffer-uninitialized-buffer.fixed.rst b/source/isaaclab/changelog.d/sylvesterkaczmarek-fix-circular-buffer-uninitialized-buffer.fixed.rst new file mode 100644 index 000000000000..abd7fe5e32d2 --- /dev/null +++ b/source/isaaclab/changelog.d/sylvesterkaczmarek-fix-circular-buffer-uninitialized-buffer.fixed.rst @@ -0,0 +1,2 @@ +* Fixed ``CircularBuffer.buffer`` raising an implementation-level PyTorch error when accessed before the first + ``append()`` by reporting a clear ``RuntimeError`` instead. diff --git a/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py b/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py index 985a9d5ed2ab..2368336ed686 100644 --- a/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py +++ b/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py @@ -108,7 +108,12 @@ def buffer(self) -> torch.Tensor: Returns: Complete circular buffer with most recent entry at the end and oldest entry at the beginning of dimension 1. The shape is [batch_size, max_length, data.shape[1:]]. + + Raises: + RuntimeError: If no data has been appended yet. """ + if self._buffer is None: + raise RuntimeError("The buffer is empty. Please append data before retrieving.") if self._stack_dim_internal is None: return torch.transpose(self._buffer, dim0=0, dim1=1) return torch.movedim(self._buffer, source=self._stack_dim_internal, destination=1) diff --git a/source/isaaclab/test/utils/test_circular_buffer.py b/source/isaaclab/test/utils/test_circular_buffer.py index 5b51f9fa5f9b..41f59ca04b2f 100644 --- a/source/isaaclab/test/utils/test_circular_buffer.py +++ b/source/isaaclab/test/utils/test_circular_buffer.py @@ -113,6 +113,9 @@ def test_empty_buffer_access(circular_buffer): with pytest.raises(RuntimeError): circular_buffer[torch.tensor([0, 0, 0], device=circular_buffer.device)] + with pytest.raises(RuntimeError, match="append data"): + _ = circular_buffer.buffer + def test_invalid_batch_size(circular_buffer): """Test appending data with an invalid batch size.""" @@ -148,6 +151,11 @@ def test_return_buffer_prop(circular_buffer): """Test retrieving the whole buffer for correct size and contents. Returning the whole buffer should have the shape [batch_size,max_len,data.shape[1:]] """ + data = torch.tensor([[1, 2], [3, 4], [5, 6]], device=circular_buffer.device) + circular_buffer.append(data) + expected = data.unsqueeze(1).repeat(1, circular_buffer.max_length, 1) + torch.testing.assert_close(circular_buffer.buffer, expected) + num_overflow = 2 for i in range(circular_buffer.max_length + num_overflow): data = torch.tensor([[i]], device=circular_buffer.device).repeat(3, 2) From ba7d0f679f457c57a6cf854b90451a15efb2ea42 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:35:26 -0400 Subject: [PATCH 2/6] Preserve Blender conversion paths and failures (#7280) # Description Fixes Blender subprocess invocation in `scripts/tools/process_meshes_to_obj.py`. The helper previously constructed the command as one formatted string and then called `.split(" ")`. Any valid Blender, input, output, or script path containing whitespace was therefore split into multiple command-line arguments. The subprocess was also launched without `check=True`, so a failed Blender conversion could be silently treated as success by the wrapper. The helper now builds the subprocess argument list directly, preserves each path as one argument, requests `check=True`, and raises a clear error when Blender cannot be found on `PATH`. ## Type of change - Bug fix ## Validation - Added a unit regression test with Blender, input, and output paths containing spaces. - The test verifies each path remains one subprocess argument and `check=True` is used. - Added coverage for the missing-Blender error path. - No Blender installation is required by the tests. - No package changelog fragment is required because this changes a repository script, not a `source//` package. ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have added regression tests - [x] No new dependencies --------- Co-authored-by: Kelly Guo (cherry picked from commit b9074a3bcf3ddc8e90c432b11b064c2f0d859306) --- scripts/tools/process_meshes_to_obj.py | 21 +++++++---- .../tools/test/test_process_meshes_to_obj.py | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 scripts/tools/test/test_process_meshes_to_obj.py diff --git a/scripts/tools/process_meshes_to_obj.py b/scripts/tools/process_meshes_to_obj.py index 2e5371225bae..74e128e29d03 100644 --- a/scripts/tools/process_meshes_to_obj.py +++ b/scripts/tools/process_meshes_to_obj.py @@ -34,22 +34,27 @@ def parse_cli_args(): return args_cli -def run_blender_convert2obj(in_file: str, out_file: str): - """Calls the python script using `subprocess` to perform processing of mesh file. +def run_blender_convert2obj(in_file: str, out_file: str) -> None: + """Call the Blender script to convert a mesh file to OBJ. Args: in_file: Input mesh file. out_file: Output obj file. + + Raises: + FileNotFoundError: If the Blender executable cannot be found on ``PATH``. + subprocess.CalledProcessError: If Blender exits with a non-zero status. """ + if BLENDER_EXE_PATH is None: + raise FileNotFoundError("Unable to find the Blender executable on PATH.") + # resolve for python file tools_dirname = os.path.dirname(os.path.abspath(__file__)) script_file = os.path.join(tools_dirname, "blender_obj.py") - # complete command - command_exe = f"{BLENDER_EXE_PATH} --background --python {script_file} -- -i {in_file} -o {out_file}" - # break command into list - command_exe_list = command_exe.split(" ") - # run command - subprocess.run(command_exe_list) + # build the argument list directly so paths containing whitespace remain single arguments + command_exe = [BLENDER_EXE_PATH, "--background", "--python", script_file, "--", "-i", in_file, "-o", out_file] + # run command and surface conversion failures to the caller + subprocess.run(command_exe, check=True) def convert_meshes(source_folders: list[str], destination_folders: list[str]): diff --git a/scripts/tools/test/test_process_meshes_to_obj.py b/scripts/tools/test/test_process_meshes_to_obj.py new file mode 100644 index 000000000000..da08cfde2e63 --- /dev/null +++ b/scripts/tools/test/test_process_meshes_to_obj.py @@ -0,0 +1,37 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from scripts.tools import process_meshes_to_obj + +pytestmark = pytest.mark.unit + + +def test_blender_command_preserves_paths_with_spaces(monkeypatch): + blender_path = "/path to/blender" + input_path = "/path to/input mesh.stl" + output_path = "/path to/output mesh.obj" + run_mock = Mock() + monkeypatch.setattr(process_meshes_to_obj, "BLENDER_EXE_PATH", blender_path) + monkeypatch.setattr(process_meshes_to_obj.subprocess, "run", run_mock) + + process_meshes_to_obj.run_blender_convert2obj(input_path, output_path) + + script_path = str(Path(process_meshes_to_obj.__file__).with_name("blender_obj.py")) + run_mock.assert_called_once_with( + [blender_path, "--background", "--python", script_path, "--", "-i", input_path, "-o", output_path], + check=True, + ) + + +def test_blender_conversion_requires_executable(monkeypatch): + monkeypatch.setattr(process_meshes_to_obj, "BLENDER_EXE_PATH", None) + + with pytest.raises(FileNotFoundError, match="Blender executable"): + process_meshes_to_obj.run_blender_convert2obj("input.stl", "output.obj") From 4a7de2203ba505793ac4a5ceebdfcb9f19886eff Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:35:43 -0400 Subject: [PATCH 3/6] Fix Blender OBJ output path handling (#7281) # Description Fixes two path-handling bugs in `scripts/tools/blender_obj.py`. When the output is a basename such as `robot.obj`, `os.path.dirname()` returns an empty string. The converter previously tried to create that empty path with `os.makedirs("")`, so writing directly to the current working directory failed. The optional USD export also used `out_file.replace("obj", "usd")`, which replaces every matching substring in the full path. A valid path such as `objects/robot.obj` could therefore be changed to `usdects/robot.usd`. The converter now creates a parent directory only when one is present and derives the USD path by replacing only the file extension. ## Type of change - Bug fix ## Validation - Added a unit regression test for an OBJ basename in the current working directory. - Added a regression test verifying `objects/robot.obj` maps to `objects/robot.usd` without changing directory names. - Blender is mocked, so the tests do not require a Blender installation. - Added the required `isaaclab` `.skip` changelog marker because managed package tests are touched. ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have added regression tests - [x] No new dependencies --------- Co-authored-by: Kelly Guo (cherry picked from commit b18ab03452ed44dc30bd95fac87ec3f8ec2dce9b) --- scripts/tools/blender_obj.py | 9 ++--- scripts/tools/test/test_blender_obj.py | 50 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 scripts/tools/test/test_blender_obj.py diff --git a/scripts/tools/blender_obj.py b/scripts/tools/blender_obj.py index c03a525fae49..b0461eea6646 100644 --- a/scripts/tools/blender_obj.py +++ b/scripts/tools/blender_obj.py @@ -67,8 +67,9 @@ def convert_to_obj(in_file: str, out_file: str, save_usd: bool = False): if not out_file.endswith(".obj"): out_file += ".obj" # create directory if it doesn't exist for destination file - if not os.path.exists(os.path.dirname(out_file)): - os.makedirs(os.path.dirname(out_file), exist_ok=True) + out_dir = os.path.dirname(out_file) + if out_dir and not os.path.exists(out_dir): + os.makedirs(out_dir, exist_ok=True) # reset scene to empty bpy.ops.wm.read_factory_settings(use_empty=True) # load object into scene @@ -86,8 +87,8 @@ def convert_to_obj(in_file: str, out_file: str, save_usd: bool = False): ) # save it as usd as well if save_usd: - out_file = out_file.replace("obj", "usd") - bpy.ops.wm.usd_export(filepath=out_file, check_existing=False) + usd_file = os.path.splitext(out_file)[0] + ".usd" + bpy.ops.wm.usd_export(filepath=usd_file, check_existing=False) if __name__ == "__main__": diff --git a/scripts/tools/test/test_blender_obj.py b/scripts/tools/test/test_blender_obj.py new file mode 100644 index 000000000000..fbac86c978e5 --- /dev/null +++ b/scripts/tools/test/test_blender_obj.py @@ -0,0 +1,50 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import importlib +import sys +from unittest.mock import Mock + +import pytest + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def blender_obj_module(monkeypatch): + fake_bpy = Mock() + monkeypatch.setitem(sys.modules, "bpy", fake_bpy) + sys.modules.pop("scripts.tools.blender_obj", None) + return importlib.import_module("scripts.tools.blender_obj"), fake_bpy + + +def test_convert_to_obj_supports_current_directory_output(blender_obj_module, tmp_path): + blender_obj, fake_bpy = blender_obj_module + input_path = tmp_path / "input.stl" + input_path.touch() + + blender_obj.convert_to_obj(str(input_path), "robot.obj") + + fake_bpy.ops.export_scene.obj.assert_called_once_with( + filepath="robot.obj", + check_existing=False, + axis_forward="Y", + axis_up="Z", + global_scale=1, + path_mode="RELATIVE", + ) + + +def test_save_usd_replaces_only_output_extension(blender_obj_module, tmp_path): + blender_obj, fake_bpy = blender_obj_module + input_path = tmp_path / "input.stl" + input_path.touch() + output_path = tmp_path / "objects" / "robot.obj" + + blender_obj.convert_to_obj(str(input_path), str(output_path), save_usd=True) + + fake_bpy.ops.wm.usd_export.assert_called_once_with( + filepath=str(output_path.with_suffix(".usd")), check_existing=False + ) From bccfb2ef206b4c58d2527960a9d4a4afbd41f8b4 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:54:35 -0400 Subject: [PATCH 4/6] Preserve .yml filenames in dump_yaml (#7275) # Description Fixes `dump_yaml()` changing valid `.yml` filenames. The helper currently checks only whether the filename ends with the literal string `yaml`. Passing a standard YAML filename such as `config.yml` therefore writes `config.yml.yaml` instead of the requested path. The extension check now accepts both `.yaml` and `.yml` case-insensitively. Filenames without a YAML extension continue to receive the default `.yaml` suffix. ## Type of change - Bug fix ## Validation - Extended the existing YAML I/O unit tests. - Added a regression test verifying `config.yml` is written unchanged and `config.yml.yaml` is not created. - Existing coverage still verifies extensionless filenames receive `.yaml`. - Added the required `isaaclab` changelog fragment. ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have added a regression test - [x] I have added the required changelog fragment - [x] No new dependencies (cherry picked from commit 7c69c9b99fb0f1ef728bdc2c6abe515d1058a872) --- ...lvesterkaczmarek-dump-yaml-yml-extension.fixed.rst | 2 ++ source/isaaclab/isaaclab/utils/io/yaml.py | 2 +- source/isaaclab/test/utils/test_yaml_io.py | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/sylvesterkaczmarek-dump-yaml-yml-extension.fixed.rst diff --git a/source/isaaclab/changelog.d/sylvesterkaczmarek-dump-yaml-yml-extension.fixed.rst b/source/isaaclab/changelog.d/sylvesterkaczmarek-dump-yaml-yml-extension.fixed.rst new file mode 100644 index 000000000000..23ddcc764c4c --- /dev/null +++ b/source/isaaclab/changelog.d/sylvesterkaczmarek-dump-yaml-yml-extension.fixed.rst @@ -0,0 +1,2 @@ +* Fixed ``dump_yaml()`` to preserve existing ``.yml`` and case-insensitive YAML file extensions instead of appending + ``.yaml``. diff --git a/source/isaaclab/isaaclab/utils/io/yaml.py b/source/isaaclab/isaaclab/utils/io/yaml.py index 82214c468fad..e0c3405503c2 100644 --- a/source/isaaclab/isaaclab/utils/io/yaml.py +++ b/source/isaaclab/isaaclab/utils/io/yaml.py @@ -43,7 +43,7 @@ def dump_yaml(filename: str, data: dict | object, sort_keys: bool = False): sort_keys: Whether to sort the keys in the output file. Defaults to False. """ # check ending - if not filename.endswith("yaml"): + if not filename.lower().endswith((".yaml", ".yml")): filename += ".yaml" # create directory directory = os.path.dirname(filename) diff --git a/source/isaaclab/test/utils/test_yaml_io.py b/source/isaaclab/test/utils/test_yaml_io.py index 6e76aef2416f..185d367d1867 100644 --- a/source/isaaclab/test/utils/test_yaml_io.py +++ b/source/isaaclab/test/utils/test_yaml_io.py @@ -25,3 +25,14 @@ def test_dump_yaml_adds_extension_in_current_working_directory(monkeypatch, tmp_ dump_yaml("config", {"value": 42}) assert (tmp_path / "config.yaml").is_file() + + +@pytest.mark.parametrize("filename", ["config.yml", "config.YML"]) +def test_dump_yaml_preserves_yml_extension(monkeypatch, tmp_path, filename): + monkeypatch.chdir(tmp_path) + + dump_yaml(filename, {"value": 42}) + + assert (tmp_path / filename).is_file() + assert not (tmp_path / f"{filename}.yaml").exists() + assert load_yaml(filename) == {"value": 42} From 036ef87220a7056defa76364cdc913f06d0c38da Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:10:21 -0400 Subject: [PATCH 5/6] Remove stray ActionManager descriptor output (#7264) # Description Removes stray debug output from `ActionManager.get_IO_descriptors`. Successful descriptor export currently executes `print(item["export"])` for every action term, so normal export writes unrelated `True` / `False` lines to stdout. The export flag is already consumed immediately afterwards and does not need to be printed. This removes only that debug print. The existing error message for descriptors that actually fail to build is unchanged. ## Type of change - Bug fix ## Validation - Added a `pytest.mark.unit` regression test using a valid action descriptor. - The test captures stdout and verifies successful descriptor export is silent. - The exported descriptor content is still checked. - Added the required `isaaclab` changelog fragment. ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have added a regression test - [x] I have added the required changelog fragment - [x] No new dependencies --------- Co-authored-by: Kelly Guo (cherry picked from commit 07d2c319566c07c4eb8e9312e6b5f6fa51b73fe7) --- .../sylvesterkaczmarek-action-descriptor-stdout.fixed.rst | 1 + source/isaaclab/isaaclab/managers/action_manager.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/sylvesterkaczmarek-action-descriptor-stdout.fixed.rst diff --git a/source/isaaclab/changelog.d/sylvesterkaczmarek-action-descriptor-stdout.fixed.rst b/source/isaaclab/changelog.d/sylvesterkaczmarek-action-descriptor-stdout.fixed.rst new file mode 100644 index 000000000000..be83f85d428b --- /dev/null +++ b/source/isaaclab/changelog.d/sylvesterkaczmarek-action-descriptor-stdout.fixed.rst @@ -0,0 +1 @@ +* Removed stray debug output from action IO descriptor export. diff --git a/source/isaaclab/isaaclab/managers/action_manager.py b/source/isaaclab/isaaclab/managers/action_manager.py index f118c209be46..0a7bf089b8f3 100644 --- a/source/isaaclab/isaaclab/managers/action_manager.py +++ b/source/isaaclab/isaaclab/managers/action_manager.py @@ -310,7 +310,6 @@ def _collect_io_descriptors(self) -> list[dict[str, Any]]: for item in data: name = item.pop("name") formatted_item = {"name": name, "extras": item.pop("extras")} - print(item["export"]) if not item.pop("export"): continue for k, v in item.items(): From 43df6ad086b7d51373e7c8df048c8bab6cae559e Mon Sep 17 00:00:00 2001 From: ooctipus Date: Wed, 30 Sep 2026 22:25:23 -0700 Subject: [PATCH 6/6] Author Newton actuators on every spawned articulation (#8224) # Description Fixes Newton-native actuators driving only the first asset of a multi-asset scene. **Symptom.** In `examples/visual_color_randomization.py` with `physics=newton_mjwarp`, only the stock ANYmal (the first asset) holds its default pose. The other four styles have no joint control and collapse to the floor within about 50 steps. **Root cause.** `define_actuator_properties` authored `NewtonActuator` USD prims on only the *first* prim matching the articulation path (`find_first_matching_prim`). `MultiAssetSpawner` spawns a distinct articulation per asset at `env_/Robot` before cloning, so the other assets had no actuator prims. Newton's `ModelBuilder` only sees actuators authored on the prim it imports, so those assets imported with no actuators and their joints received no torque. With a single asset, only `env_0` exists at spawn time, so "first match" happened to be enough. **Fix.** Author the prims on every matching articulation, and resolve the matches on the stage that was passed in rather than the current stage. For a single asset there is one match, so behavior is unchanged. Visual materials are not involved. I isolated this on pristine `develop` with five *identical* plain assets through the multi-asset spawner, which reproduces the collapse exactly, while a single asset controls all envs. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Verification New test `test_define_actuator_properties_authors_every_spawned_articulation`: fails without the change, passes with it. `source/isaaclab/test/actuators/test_newton_adapter.py`: 12 passed. ruff 0.14.10 check and format clean. Real demo, `examples/visual_color_randomization.py`, 10 envs, `newton_mjwarp`, no visualizer, before and after on `develop`: | | before | after | |---|---|---| | `NewtonActuator` prims on stage | `env_0` only | one per asset source (`env_0, 2, 4, 6, 8`) | | native actuator indices in the Newton model | 24 (worlds 0 and 1) | 120 (all 10 worlds) | | envs with nonzero applied torque | 2 of 10 | 10 of 10 | | root height at step 100 | 0.43 (2 envs), 0.07 (8 envs) | 0.43 (all 10) | The number of controlled envs equaled the first asset's block size in every case I tried (2 of 10, 3 of 15, 1 of 7). ## Not covered I checked the PhysX and OVPhysX tests that mention these helpers; they patch their own backend's `find_first_matching_prim` and do not depend on this function. I did not run those GPU/runtime suites. ## Checklist - [ ] I have read and understood the contribution guidelines - [ ] I have run the `pre-commit` checks with `uv run isaaclab --format` (I ran ruff 0.14.10 check and format, the same hook, on the changed files only) - [x] I have made corresponding changes to the documentation (docstring updated; no user-facing docs affected) - [ ] My changes generate no new warnings - [x] I have validated the changed behavior with focused coverage - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there (cherry picked from commit c1242edf1fbc04236b8cdc134d6ba02e7aa9bd8e) --- ...fix-newton-actuators-multi-asset.fixed.rst | 4 +++ .../isaaclab/sim/schemas/schemas_actuators.py | 16 ++++----- .../test/actuators/test_newton_adapter.py | 35 +++++++++++++++++-- .../test_robot_load_performance.py | 33 +++-------------- 4 files changed, 49 insertions(+), 39 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-newton-actuators-multi-asset.fixed.rst diff --git a/source/isaaclab/changelog.d/fix-newton-actuators-multi-asset.fixed.rst b/source/isaaclab/changelog.d/fix-newton-actuators-multi-asset.fixed.rst new file mode 100644 index 000000000000..c2a86103e533 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-newton-actuators-multi-asset.fixed.rst @@ -0,0 +1,4 @@ +* Fixed Newton-native actuators driving only the first asset of a multi-asset scene. ``NewtonActuator`` prims + were authored on the first prim matching the articulation path, so every other spawned asset + imported into the Newton model without actuators and its joints received no torque. They are now authored on + every matching articulation. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py b/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py index e7dcf379e595..67fbef60c571 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py @@ -169,8 +169,8 @@ def define_actuator_properties( Args: prim_path: Root prim path of the articulation (e.g. - ``"/World/Env_0/Robot"``). May contain a regex pattern; the - first matching prim is used. + ``"/World/Env_0/Robot"``). May contain a regex pattern; every matching prim is + authored, because a multi-asset spawner creates a distinct articulation per match. actuator_cfgs: Mapping of group name to :class:`~isaaclab.actuators.ActuatorBaseCfg`. stage: USD stage to author on. When ``None``, the current stage @@ -186,18 +186,16 @@ def define_actuator_properties( if sim_cfg is None or not sim_cfg.use_newton_actuators: return - from ..utils.queries import find_first_matching_prim # noqa: PLC0415 + from ..utils.queries import find_matching_prims # noqa: PLC0415 from ..utils.stage import get_current_stage # noqa: PLC0415 if stage is None: stage = get_current_stage() - first_prim = find_first_matching_prim(prim_path) - if first_prim is None: - return - articulation_prim_path = str(first_prim.GetPath()) - - author_actuator_prims(stage, articulation_prim_path, actuator_cfgs) + # Each match is a separately spawned articulation. A multi-asset spawner creates one per asset + # before cloning, and the Newton builder only sees actuators authored on the prim it imports. + for prim in find_matching_prims(prim_path, stage): + author_actuator_prims(stage, str(prim.GetPath()), actuator_cfgs) def author_actuator_prims( diff --git a/source/isaaclab/test/actuators/test_newton_adapter.py b/source/isaaclab/test/actuators/test_newton_adapter.py index 52841965c6ce..21fac108251a 100644 --- a/source/isaaclab/test/actuators/test_newton_adapter.py +++ b/source/isaaclab/test/actuators/test_newton_adapter.py @@ -6,6 +6,7 @@ """Tests for the PhysX-side Newton actuator adapter.""" from pathlib import Path +from types import SimpleNamespace import numpy as np import pytest @@ -16,8 +17,12 @@ from isaaclab.actuators import ActuatorBaseCfg, DCMotor, DCMotorCfg, DelayedPDActuatorCfg, RemotizedPDActuatorCfg from isaaclab.actuators.newton import NewtonActuatorAdapter -from isaaclab.sim import SimulationCfg -from isaaclab.sim.schemas.schemas_actuators import author_actuator_prims, resave_checkpoint_with_metadata +from isaaclab.sim import SimulationCfg, SimulationContext +from isaaclab.sim.schemas.schemas_actuators import ( + author_actuator_prims, + define_actuator_properties, + resave_checkpoint_with_metadata, +) from isaaclab.utils import configclass _JOINT_NAMES = ["pd_a", "pd_b", "dc_a", "dc_b", "remote_a", "remote_b"] @@ -280,3 +285,29 @@ def test_schema_authoring_accepts_supported_public_actuator_alias(): author_actuator_prims(stage, "/World/Robot", {"public_alias": cfg}) assert stage.GetPrimAtPath("/World/Robot/public_alias_pd_a_actuator").IsValid() + + +def test_define_actuator_properties_authors_every_spawned_articulation(monkeypatch): + """A multi-asset spawner creates one articulation per match, and each needs its own actuator prims.""" + stage = Usd.Stage.CreateInMemory() + robot_paths = [f"/World/envs/env_{index}/Robot" for index in range(3)] + for robot_path in robot_paths: + UsdGeom.Xform.Define(stage, robot_path) + body = UsdGeom.Xform.Define(stage, f"{robot_path}/body") + UsdPhysics.RigidBodyAPI.Apply(body.GetPrim()) + joint = UsdPhysics.RevoluteJoint.Define(stage, f"{robot_path}/pd_a") + joint.CreateBody1Rel().SetTargets([body.GetPath()]) + monkeypatch.setattr( + SimulationContext, + "instance", + staticmethod(lambda: SimpleNamespace(cfg=SimpleNamespace(use_newton_actuators=True))), + ) + cfg = DelayedPDActuatorCfg( + joint_names_expr=["pd_a"], stiffness=11.0, damping=1.5, actuator_effort_limit=21.0, max_delay=2 + ) + + define_actuator_properties(r"/World/envs/env_.*/Robot", {"pd": cfg}, stage=stage) + + for robot_path in robot_paths: + actuators = [prim for prim in stage.Traverse() if prim.GetTypeName() == "NewtonActuator"] + assert any(str(prim.GetPath()).startswith(robot_path + "/") for prim in actuators), robot_path diff --git a/source/isaaclab/test/performance/test_robot_load_performance.py b/source/isaaclab/test/performance/test_robot_load_performance.py index 18da105f91fb..517434c561b9 100644 --- a/source/isaaclab/test/performance/test_robot_load_performance.py +++ b/source/isaaclab/test/performance/test_robot_load_performance.py @@ -13,11 +13,8 @@ launch_test_simulation() -import numpy as np import pytest -from isaaclab_physx.cloner import physx_replicate -import isaaclab.sim.utils as sim_utils from isaaclab import cloner from isaaclab.assets import Articulation from isaaclab.sim import build_simulation_context @@ -47,33 +44,13 @@ def test_robot_load_performance(test_config, device): """Test robot load time.""" with build_simulation_context(device=device) as sim: sim._app_control_on_stop_handle = None - stage = sim_utils.get_current_stage() - - # Generate grid positions for environments - positions, _ = cloner.grid_transforms(NUM_ENVS, SPACING) - - # Create environment prims using USD replicate - env_paths = [f"/World/Robots_{i}" for i in range(NUM_ENVS)] - stage.DefinePrim(env_paths[0], "Xform") - cloner.usd_replicate( - stage=stage, - sources=[env_paths[0]], - destinations=["/World/Robots_{}"], - env_ids=np.arange(NUM_ENVS, dtype=np.int64), - positions=positions, - ) - - # Replicate physics - mapping is (num_sources, num_envs) bool mask - physx_replicate( - stage=stage, - sources=[env_paths[0]], - destinations=["/World/Robots_{}"], - env_ids=np.arange(NUM_ENVS, dtype=np.int64), - mapping=np.ones((1, NUM_ENVS), dtype=np.bool_), # 1 source -> all envs - ) with Timer(f"{test_config['name']} load time for device {device}") as timer: - robot = Articulation(replace(test_config["robot_cfg"], prim_path="/World/Robots_[^/]*/Robot")) # noqa: F841 + cfg = replace(test_config["robot_cfg"], prim_path="{ENV_REGEX_NS}/Robot") + plan = cloner.clone_plan_from_env_0(cloner.CloneCfg(), (cfg,), NUM_ENVS, SPACING) + robot = Articulation(cfg) + cloner.replicate(plan) sim.reset() elapsed_time = timer.time_elapsed + assert robot.num_instances == NUM_ENVS assert elapsed_time <= test_config["expected_load_time"]