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/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_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 + ) 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") 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/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/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/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/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(): 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/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/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/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"] 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) 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}