Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions scripts/tools/blender_obj.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__":
Expand Down
21 changes: 13 additions & 8 deletions scripts/tools/process_meshes_to_obj.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down
50 changes: 50 additions & 0 deletions scripts/tools/test/test_blender_obj.py
Original file line number Diff line number Diff line change
@@ -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
)
37 changes: 37 additions & 0 deletions scripts/tools/test/test_process_meshes_to_obj.py
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Removed stray debug output from action IO descriptor export.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* Fixed ``dump_yaml()`` to preserve existing ``.yml`` and case-insensitive YAML file extensions instead of appending
``.yaml``.
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion source/isaaclab/isaaclab/managers/action_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
16 changes: 7 additions & 9 deletions source/isaaclab/isaaclab/sim/schemas/schemas_actuators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions source/isaaclab/isaaclab/utils/buffers/circular_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion source/isaaclab/isaaclab/utils/io/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 33 additions & 2 deletions source/isaaclab/test/actuators/test_newton_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
Expand Down Expand Up @@ -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
33 changes: 5 additions & 28 deletions source/isaaclab/test/performance/test_robot_load_performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
8 changes: 8 additions & 0 deletions source/isaaclab/test/utils/test_circular_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions source/isaaclab/test/utils/test_yaml_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Loading