Skip to content

Commit 90ac9a2

Browse files
authored
Setup Converter API and CLI (#22)
1 parent 2076ce3 commit 90ac9a2

File tree

8 files changed

+348
-7
lines changed

8 files changed

+348
-7
lines changed

tests/data/simple_box.urdf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?xml verison="1.0"?>
2+
<robot name="simple_box">
3+
<material name="blue">
4+
<color rgba="0.0 0.0 1.0 1.0"/>
5+
</material>
6+
7+
<link name="link1">
8+
<visual>
9+
<geometry>
10+
<box size="2.0 2.0 2.0"/>
11+
</geometry>
12+
<material name="blue"/>
13+
</visual>
14+
</link>
15+
</robot>

tests/testCli.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,116 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
3+
import pathlib
4+
import shutil
5+
from unittest.mock import patch
6+
7+
import usdex.test
8+
from pxr import Tf
39

410
from tests.util.ConverterTestCase import ConverterTestCase
11+
from urdf_usd_converter._impl.cli import run
512

613

714
class TestCli(ConverterTestCase):
815

916
def test_run(self):
10-
pass # dummy test for now
17+
for robot in pathlib.Path("tests/data").glob("*.urdf"):
18+
robot_name = robot.stem
19+
with patch("sys.argv", ["urdf_usd_converter", str(robot), self.tmpDir()]):
20+
self.assertEqual(run(), 0, f"Failed to convert {robot}")
21+
self.assertTrue((pathlib.Path(self.tmpDir()) / f"{robot_name}.usda").exists())
22+
23+
# TODO: test_no_layer_structure, test_no_physics_scene, test_comment
24+
25+
def test_invalid_input(self):
26+
with (
27+
patch("sys.argv", ["urdf_usd_converter", "tests/data/invalid.xml", self.tmpDir()]),
28+
usdex.test.ScopedDiagnosticChecker(self, [(Tf.TF_DIAGNOSTIC_WARNING_TYPE, "Input file does not exist.*")]),
29+
):
30+
self.assertEqual(run(), 1, "Expected non-zero exit code for invalid input")
31+
32+
def test_invalid_output(self):
33+
# create a file that is not a directory
34+
pathlib.Path("tests/output").mkdir(parents=True, exist_ok=True)
35+
pathlib.Path("tests/output/invalid").touch()
36+
with (
37+
patch("sys.argv", ["urdf_usd_converter", "tests/data/simple_box.urdf", "tests/output/invalid"]),
38+
usdex.test.ScopedDiagnosticChecker(self, [(Tf.TF_DIAGNOSTIC_WARNING_TYPE, "Output path exists but is not a directory.*")]),
39+
):
40+
self.assertEqual(run(), 1, "Expected non-zero exit code for invalid output")
41+
42+
def test_input_path_is_directory(self):
43+
# Create a directory as input_file (should fail)
44+
input_dir = pathlib.Path("tests/data/input_dir")
45+
input_dir.mkdir(parents=True, exist_ok=True)
46+
try:
47+
with (
48+
patch("sys.argv", ["urdf_usd_converter", str(input_dir), self.tmpDir()]),
49+
usdex.test.ScopedDiagnosticChecker(self, [(Tf.TF_DIAGNOSTIC_WARNING_TYPE, "Input path is not a file.*")]),
50+
):
51+
self.assertEqual(run(), 1, "Expected non-zero exit code for input path as directory")
52+
finally:
53+
shutil.rmtree(input_dir)
54+
55+
def test_input_file_not_xml(self):
56+
# Create a non-xml file as input_file (should fail)
57+
not_xml = pathlib.Path("tests/data/not_xml.txt")
58+
not_xml.write_text("dummy content")
59+
try:
60+
with (
61+
patch("sys.argv", ["urdf_usd_converter", str(not_xml), self.tmpDir()]),
62+
usdex.test.ScopedDiagnosticChecker(self, [(Tf.TF_DIAGNOSTIC_WARNING_TYPE, "Only URDF.*are supported as input.*")]),
63+
):
64+
self.assertEqual(run(), 1, "Expected non-zero exit code for non-xml input file")
65+
finally:
66+
not_xml.unlink()
67+
68+
def test_output_dir_cannot_create(self):
69+
# Simulate output_dir.mkdir raising an exception (should fail)
70+
robot = "tests/data/simple_box.urdf"
71+
output_dir = pathlib.Path("tests/output/cannot_create")
72+
with (
73+
patch("pathlib.Path.mkdir", side_effect=OSError("Permission denied")),
74+
patch("sys.argv", ["urdf_usd_converter", robot, str(output_dir)]),
75+
usdex.test.ScopedDiagnosticChecker(self, [(Tf.TF_DIAGNOSTIC_WARNING_TYPE, "Failed to create output directory.*")]),
76+
):
77+
self.assertEqual(run(), 1, "Expected non-zero exit code when output dir cannot be created")
78+
79+
def test_conversion_returns_none(self):
80+
# Test the case where converter.convert() returns None/false value
81+
robot = "tests/data/simple_box.urdf"
82+
with (
83+
patch("urdf_usd_converter.Converter.convert", return_value=None),
84+
patch("sys.argv", ["urdf_usd_converter", robot, self.tmpDir()]),
85+
usdex.test.ScopedDiagnosticChecker(
86+
self,
87+
[(Tf.TF_DIAGNOSTIC_WARNING_TYPE, "Conversion failed for unknown reason.*")],
88+
level=usdex.core.DiagnosticsLevel.eWarning,
89+
),
90+
):
91+
self.assertEqual(run(), 1, "Expected non-zero exit code when conversion returns None")
92+
93+
def test_conversion_exception_non_verbose(self):
94+
# Test exception handling when verbose=False (should not re-raise)
95+
robot = "tests/data/simple_box.urdf"
96+
with (
97+
patch("urdf_usd_converter.Converter.convert", side_effect=RuntimeError("Test conversion error")),
98+
patch("sys.argv", ["urdf_usd_converter", robot, self.tmpDir()]),
99+
usdex.test.ScopedDiagnosticChecker(
100+
self,
101+
[(Tf.TF_DIAGNOSTIC_WARNING_TYPE, "Conversion failed: Test conversion error.*")],
102+
level=usdex.core.DiagnosticsLevel.eWarning,
103+
),
104+
):
105+
self.assertEqual(run(), 1, "Expected non-zero exit code when conversion raises exception")
106+
107+
def test_conversion_exception_verbose(self):
108+
# Test exception handling when verbose=True (should re-raise)
109+
robot = "tests/data/simple_box.urdf"
110+
with (
111+
patch("urdf_usd_converter.Converter.convert", side_effect=RuntimeError("Test conversion error")),
112+
patch("sys.argv", ["urdf_usd_converter", robot, self.tmpDir(), "--verbose"]),
113+
self.assertRaises(RuntimeError),
114+
usdex.test.ScopedDiagnosticChecker(self, [], level=usdex.core.DiagnosticsLevel.eWarning),
115+
):
116+
run()

tests/testConverter.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import pathlib
4+
5+
from tests.util.ConverterTestCase import ConverterTestCase
6+
from urdf_usd_converter._impl.convert import Converter
7+
8+
9+
class TestConverter(ConverterTestCase):
10+
def test_invalid_input(self):
11+
# input_path is a path that does not exist (should fail).
12+
input_path = "tests/data/non_existent.urdf"
13+
output_dir = self.tmpDir()
14+
15+
converter = Converter()
16+
with self.assertRaisesRegex(ValueError, r".*Input file tests/data/non_existent.urdf is not a readable file.*"):
17+
converter.convert(input_path, output_dir)
18+
19+
def test_output_path_is_file(self):
20+
# Specify a file instead of a directory (should fail).
21+
input_path = "tests/data/simple_box.urdf"
22+
output_dir = "tests/data/simple_box.urdf"
23+
24+
converter = Converter()
25+
with self.assertRaisesRegex(ValueError, r".*Output directory tests/data/simple_box.urdf is not a directory.*"):
26+
converter.convert(input_path, output_dir)
27+
28+
def test_output_directory_does_not_exist(self):
29+
# If the output directory does not exist.
30+
input_path = "tests/data/simple_box.urdf"
31+
output_dir = str(pathlib.Path(self.tmpDir()) / "non_existent_directory")
32+
33+
converter = Converter()
34+
asset_path = converter.convert(input_path, output_dir)
35+
self.assertIsNotNone(asset_path)
36+
self.assertTrue(pathlib.Path(asset_path.path).exists())

urdf_usd_converter/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
3+
from ._impl.convert import Converter
34
from ._version import __version__
45

5-
__all__ = ["__version__"]
6+
__all__ = ["Converter", "__version__"]

urdf_usd_converter/__main__.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22
# SPDX-License-Identifier: Apache-2.0
33
import sys
44

5-
6-
def run() -> int:
7-
print("Not implemented yet")
8-
return 0
9-
5+
from ._impl.cli import run
106

117
sys.exit(run())

urdf_usd_converter/_impl/cli.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import argparse
4+
from pathlib import Path
5+
6+
import usdex.core
7+
from pxr import Tf, Usd
8+
9+
from .._version import __version__
10+
from .convert import Converter
11+
12+
13+
def run() -> int:
14+
"""
15+
Main method in the command line interface.
16+
"""
17+
parser = __create_parser()
18+
args = parser.parse_args()
19+
20+
# Argument validation
21+
# Check input_file
22+
if not args.input_file.exists():
23+
Tf.Warn(f"Input file does not exist: {args.input_file}")
24+
return 1
25+
if not args.input_file.is_file():
26+
Tf.Warn(f"Input path is not a file: {args.input_file}")
27+
return 1
28+
if args.input_file.suffix.lower() != ".urdf":
29+
Tf.Warn(f"Only URDF (.urdf) files are supported as input, got: {args.input_file.suffix}")
30+
return 1
31+
# Check output_dir
32+
if args.output_dir.exists() and not args.output_dir.is_dir():
33+
Tf.Warn(f"Output path exists but is not a directory: {args.output_dir}")
34+
return 1
35+
if not args.output_dir.exists():
36+
try:
37+
args.output_dir.mkdir(parents=True, exist_ok=True)
38+
except Exception as e:
39+
Tf.Warn(f"Failed to create output directory: {args.output_dir}, error: {e}")
40+
return 1
41+
42+
usdex.core.activateDiagnosticsDelegate()
43+
usdex.core.setDiagnosticsLevel(usdex.core.DiagnosticsLevel.eStatus if args.verbose else usdex.core.DiagnosticsLevel.eWarning)
44+
Tf.Status("Running urdf_usd_converter")
45+
Tf.Status(f"Version: {__version__}")
46+
Tf.Status(f"USD Version: {Usd.GetVersion()}")
47+
Tf.Status(f"USDEX Version: {usdex.core.version()}")
48+
49+
try:
50+
converter = Converter(layer_structure=not args.no_layer_structure, scene=not args.no_physics_scene, comment=args.comment)
51+
if result := converter.convert(args.input_file, args.output_dir):
52+
Tf.Status(f"Created USD Asset: {result.path}")
53+
return 0
54+
else:
55+
Tf.Warn("Conversion failed for unknown reason. Try running with --verbose for more information.")
56+
return 1
57+
except Exception as e:
58+
if args.verbose:
59+
raise e
60+
else:
61+
Tf.Warn(f"Conversion failed: {e}")
62+
return 1
63+
64+
65+
def __create_parser() -> argparse.ArgumentParser:
66+
parser = argparse.ArgumentParser(
67+
description="Convert URDF files to USD format",
68+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
69+
)
70+
71+
# Required arguments
72+
parser.add_argument(
73+
"input_file",
74+
type=Path,
75+
help="Path to the input URDF file",
76+
)
77+
parser.add_argument(
78+
"output_dir",
79+
type=Path,
80+
help="""
81+
Path to the output USD directory. The primary USD file will be <output_dir>/<robotname>.usda
82+
and it will be an Atomic Component with Asset Interface layer and payloaded contents
83+
(unless --no-layer-structure is used)
84+
""",
85+
)
86+
87+
# Optional arguments
88+
# FUTURE: add arg to flatten hierarchy
89+
parser.add_argument(
90+
"--no-layer-structure",
91+
action="store_true",
92+
default=False,
93+
help="Create a single USDC layer rather than an Atomic Component structure with Asset Interface layer and payloaded contents",
94+
)
95+
parser.add_argument(
96+
"--no-physics-scene",
97+
action="store_true",
98+
default=False,
99+
help="Disable authoring a `UsdPhysics.Scene` prim",
100+
)
101+
parser.add_argument(
102+
"--verbose",
103+
"-v",
104+
action="store_true",
105+
default=False,
106+
help="Enable verbose output",
107+
)
108+
parser.add_argument(
109+
"--comment",
110+
"-c",
111+
default="",
112+
help="Comment to add to the USD file",
113+
)
114+
115+
return parser
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
import pathlib
4+
from dataclasses import dataclass
5+
6+
import usdex.core
7+
from pxr import Sdf, Tf, UsdGeom
8+
9+
from .utils import get_authoring_metadata
10+
11+
__all__ = ["Converter"]
12+
13+
14+
class Converter:
15+
@dataclass
16+
class Params:
17+
layer_structure: bool = True
18+
scene: bool = True
19+
comment: str = ""
20+
21+
def __init__(self, layer_structure: bool = True, scene: bool = True, comment: str = ""):
22+
self.params = self.Params(layer_structure=layer_structure, scene=scene, comment=comment)
23+
24+
def convert(self, input_file: str, output_dir: str) -> Sdf.AssetPath:
25+
"""
26+
Convert a URDF to a USD stage.
27+
28+
Args:
29+
input_file: Path to the input URDF file.
30+
output_dir: Path to the output USD directory.
31+
32+
Returns:
33+
The path to the created USD asset.
34+
35+
Raises:
36+
ValueError: If input_file does not exist or is not a readable file.
37+
ValueError: If input_file cannot be parsed as a valid URDF.
38+
ValueError: If output_dir exists but is not a directory.
39+
"""
40+
input_path = pathlib.Path(input_file)
41+
if not input_path.exists() or not input_path.is_file():
42+
raise ValueError(f"Input file {input_file} is not a readable file")
43+
44+
output_path = pathlib.Path(output_dir)
45+
if output_path.exists() and not output_path.is_dir():
46+
raise ValueError(f"Output directory {output_dir} is not a directory")
47+
48+
if not output_path.exists():
49+
output_path.mkdir(parents=True)
50+
51+
file_name = f"{input_path.stem}.usda"
52+
asset_identifier = str(output_path / file_name)
53+
Tf.Status(f"Converting {input_path} into {output_path}")
54+
asset_stage = usdex.core.createStage(
55+
asset_identifier,
56+
defaultPrimName="Robot", # TODO: use parsed name
57+
upAxis=UsdGeom.Tokens.z,
58+
linearUnits=UsdGeom.LinearUnits.meters,
59+
authoringMetadata=get_authoring_metadata(),
60+
)
61+
# TODO: implement core logic
62+
usdex.core.saveStage(asset_stage, comment=self.params.comment)
63+
return Sdf.AssetPath(asset_identifier)

urdf_usd_converter/_impl/utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
from .._version import __version__
4+
5+
__all__ = ["get_authoring_metadata"]
6+
7+
8+
def get_authoring_metadata() -> str:
9+
return f"URDF USD Converter v{__version__}"

0 commit comments

Comments
 (0)