diff --git a/tests/data/assets/box_specular_workflow.mtl b/tests/data/assets/box_specular_workflow.mtl new file mode 100644 index 0000000..ddc9bab --- /dev/null +++ b/tests/data/assets/box_specular_workflow.mtl @@ -0,0 +1,9 @@ +newmtl specular_workflow_mat +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Ks 0.500000 0.200000 0.100000 +Ke 0.000000 0.000000 0.000000 +Ni 1.450000 +d 1.000000 +illum 2 +map_Kd ./grid.png diff --git a/tests/data/assets/box_specular_workflow.obj b/tests/data/assets/box_specular_workflow.obj new file mode 100644 index 0000000..5cbb25b --- /dev/null +++ b/tests/data/assets/box_specular_workflow.obj @@ -0,0 +1,38 @@ +mtllib box_specular_workflow.mtl +o Cube +v 0.500000 0.500000 -0.500000 +v 0.500000 -0.500000 -0.500000 +v 0.500000 0.500000 0.500000 +v 0.500000 -0.500000 0.500000 +v -0.500000 0.500000 -0.500000 +v -0.500000 -0.500000 -0.500000 +v -0.500000 0.500000 0.500000 +v -0.500000 -0.500000 0.500000 +vn -0.0000 1.0000 -0.0000 +vn -0.0000 -0.0000 1.0000 +vn -1.0000 -0.0000 -0.0000 +vn -0.0000 -1.0000 -0.0000 +vn 1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 -1.0000 +vt 0.625000 0.500000 +vt 0.875000 0.500000 +vt 0.875000 0.750000 +vt 0.625000 0.750000 +vt 0.375000 0.750000 +vt 0.625000 1.000000 +vt 0.375000 1.000000 +vt 0.375000 0.000000 +vt 0.625000 0.000000 +vt 0.625000 0.250000 +vt 0.375000 0.250000 +vt 0.125000 0.500000 +vt 0.375000 0.500000 +vt 0.125000 0.750000 +s 0 +usemtl specular_workflow_mat +f 1/1/1 5/2/1 7/3/1 3/4/1 +f 4/5/2 3/4/2 7/6/2 8/7/2 +f 8/8/3 7/9/3 5/10/3 6/11/3 +f 6/12/4 2/13/4 4/5/4 8/14/4 +f 2/13/5 1/1/5 3/4/5 4/5/5 +f 6/11/6 5/10/6 1/1/6 2/13/6 diff --git a/tests/data/assets/box_specular_workflow_with_texture.mtl b/tests/data/assets/box_specular_workflow_with_texture.mtl new file mode 100644 index 0000000..cefe818 --- /dev/null +++ b/tests/data/assets/box_specular_workflow_with_texture.mtl @@ -0,0 +1,10 @@ +newmtl specular_workflow_with_texture_mat +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Kd 0.400000 0.400000 0.400000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000000 0.000000 0.000000 +Ni 1.450000 +d 1.000000 +illum 2 +map_Ks ./specular.png diff --git a/tests/data/assets/box_specular_workflow_with_texture.obj b/tests/data/assets/box_specular_workflow_with_texture.obj new file mode 100644 index 0000000..7e8504e --- /dev/null +++ b/tests/data/assets/box_specular_workflow_with_texture.obj @@ -0,0 +1,38 @@ +mtllib box_specular_workflow_with_texture.mtl +o Cube +v 0.500000 0.500000 -0.500000 +v 0.500000 -0.500000 -0.500000 +v 0.500000 0.500000 0.500000 +v 0.500000 -0.500000 0.500000 +v -0.500000 0.500000 -0.500000 +v -0.500000 -0.500000 -0.500000 +v -0.500000 0.500000 0.500000 +v -0.500000 -0.500000 0.500000 +vn -0.0000 1.0000 -0.0000 +vn -0.0000 -0.0000 1.0000 +vn -1.0000 -0.0000 -0.0000 +vn -0.0000 -1.0000 -0.0000 +vn 1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 -1.0000 +vt 0.625000 0.500000 +vt 0.875000 0.500000 +vt 0.875000 0.750000 +vt 0.625000 0.750000 +vt 0.375000 0.750000 +vt 0.625000 1.000000 +vt 0.375000 1.000000 +vt 0.375000 0.000000 +vt 0.625000 0.000000 +vt 0.625000 0.250000 +vt 0.375000 0.250000 +vt 0.125000 0.500000 +vt 0.375000 0.500000 +vt 0.125000 0.750000 +s 0 +usemtl specular_workflow_with_texture_mat +f 1/1/1 5/2/1 7/3/1 3/4/1 +f 4/5/2 3/4/2 7/6/2 8/7/2 +f 8/8/3 7/9/3 5/10/3 6/11/3 +f 6/12/4 2/13/4 4/5/4 8/14/4 +f 2/13/5 1/1/5 3/4/5 4/5/5 +f 6/11/6 5/10/6 1/1/6 2/13/6 diff --git a/tests/data/assets/box_with_texture.mtl b/tests/data/assets/box_with_texture.mtl new file mode 100644 index 0000000..990dc8a --- /dev/null +++ b/tests/data/assets/box_with_texture.mtl @@ -0,0 +1,12 @@ +newmtl texture_mat +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Ks 0.000000 0.000000 0.000000 +Ke 0.000000 0.000000 0.000000 +Ni 1.450000 +d 1.000000 +illum 2 +map_Kd ./grid.png +norm ./normal.png +map_Pr ./roughness.png +map_Pm ./metallic.png diff --git a/tests/data/assets/box_with_texture.obj b/tests/data/assets/box_with_texture.obj new file mode 100644 index 0000000..934fd9f --- /dev/null +++ b/tests/data/assets/box_with_texture.obj @@ -0,0 +1,38 @@ +mtllib box_with_texture.mtl +o Cube +v 0.500000 0.500000 -0.500000 +v 0.500000 -0.500000 -0.500000 +v 0.500000 0.500000 0.500000 +v 0.500000 -0.500000 0.500000 +v -0.500000 0.500000 -0.500000 +v -0.500000 -0.500000 -0.500000 +v -0.500000 0.500000 0.500000 +v -0.500000 -0.500000 0.500000 +vn -0.0000 1.0000 -0.0000 +vn -0.0000 -0.0000 1.0000 +vn -1.0000 -0.0000 -0.0000 +vn -0.0000 -1.0000 -0.0000 +vn 1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 -1.0000 +vt 0.625000 0.500000 +vt 0.875000 0.500000 +vt 0.875000 0.750000 +vt 0.625000 0.750000 +vt 0.375000 0.750000 +vt 0.625000 1.000000 +vt 0.375000 1.000000 +vt 0.375000 0.000000 +vt 0.625000 0.000000 +vt 0.625000 0.250000 +vt 0.375000 0.250000 +vt 0.125000 0.500000 +vt 0.375000 0.500000 +vt 0.125000 0.750000 +s 0 +usemtl texture_mat +f 1/1/1 5/2/1 7/3/1 3/4/1 +f 4/5/2 3/4/2 7/6/2 8/7/2 +f 8/8/3 7/9/3 5/10/3 6/11/3 +f 6/12/4 2/13/4 4/5/4 8/14/4 +f 2/13/5 1/1/5 3/4/5 4/5/5 +f 6/11/6 5/10/6 1/1/6 2/13/6 diff --git a/tests/data/assets/box_with_texture_opacity.mtl b/tests/data/assets/box_with_texture_opacity.mtl new file mode 100644 index 0000000..8debd58 --- /dev/null +++ b/tests/data/assets/box_with_texture_opacity.mtl @@ -0,0 +1,10 @@ +newmtl texture_opacity_mat +Ns 250.000000 +Ka 1.000000 1.000000 1.000000 +Ks 0.000000 0.000000 0.000000 +Ke 0.000000 0.000000 0.000000 +Ni 1.000000 +d 1.000000 +illum 2 +map_Kd ./grid.png +map_d ./opacity.png diff --git a/tests/data/assets/box_with_texture_opacity.obj b/tests/data/assets/box_with_texture_opacity.obj new file mode 100644 index 0000000..b1528c6 --- /dev/null +++ b/tests/data/assets/box_with_texture_opacity.obj @@ -0,0 +1,38 @@ +mtllib box_with_texture_opacity.mtl +o Cube +v 0.500000 0.500000 -0.500000 +v 0.500000 -0.500000 -0.500000 +v 0.500000 0.500000 0.500000 +v 0.500000 -0.500000 0.500000 +v -0.500000 0.500000 -0.500000 +v -0.500000 -0.500000 -0.500000 +v -0.500000 0.500000 0.500000 +v -0.500000 -0.500000 0.500000 +vn -0.0000 1.0000 -0.0000 +vn -0.0000 -0.0000 1.0000 +vn -1.0000 -0.0000 -0.0000 +vn -0.0000 -1.0000 -0.0000 +vn 1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 -1.0000 +vt 0.625000 0.500000 +vt 0.875000 0.500000 +vt 0.875000 0.750000 +vt 0.625000 0.750000 +vt 0.375000 0.750000 +vt 0.625000 1.000000 +vt 0.375000 1.000000 +vt 0.375000 0.000000 +vt 0.625000 0.000000 +vt 0.625000 0.250000 +vt 0.375000 0.250000 +vt 0.125000 0.500000 +vt 0.375000 0.500000 +vt 0.125000 0.750000 +s 0 +usemtl texture_opacity_mat +f 1/1/1 5/2/1 7/3/1 3/4/1 +f 4/5/2 3/4/2 7/6/2 8/7/2 +f 8/8/3 7/9/3 5/10/3 6/11/3 +f 6/12/4 2/13/4 4/5/4 8/14/4 +f 2/13/5 1/1/5 3/4/5 4/5/5 +f 6/11/6 5/10/6 1/1/6 2/13/6 diff --git a/tests/data/assets/metallic.png b/tests/data/assets/metallic.png new file mode 100644 index 0000000..fb59433 Binary files /dev/null and b/tests/data/assets/metallic.png differ diff --git a/tests/data/assets/normal.png b/tests/data/assets/normal.png new file mode 100644 index 0000000..ab56f10 Binary files /dev/null and b/tests/data/assets/normal.png differ diff --git a/tests/data/assets/opacity.png b/tests/data/assets/opacity.png new file mode 100644 index 0000000..09a5800 Binary files /dev/null and b/tests/data/assets/opacity.png differ diff --git a/tests/data/assets/roughness.png b/tests/data/assets/roughness.png new file mode 100644 index 0000000..6a1e62c Binary files /dev/null and b/tests/data/assets/roughness.png differ diff --git a/tests/data/assets/specular.png b/tests/data/assets/specular.png new file mode 100644 index 0000000..feb17a9 Binary files /dev/null and b/tests/data/assets/specular.png differ diff --git a/tests/data/assets/two_boxes.mtl b/tests/data/assets/two_boxes.mtl index 3024c78..94451ea 100644 --- a/tests/data/assets/two_boxes.mtl +++ b/tests/data/assets/two_boxes.mtl @@ -2,7 +2,7 @@ newmtl green_mat Ns 250.000000 Ka 1.000000 1.000000 1.000000 Kd 0.000000 1.000000 0.000000 -Ks 0.500000 0.500000 0.500000 +Ks 0.000000 0.000000 0.000000 Ke 0.000000 0.000000 0.000000 Ni 1.500000 d 1.000000 @@ -12,8 +12,10 @@ newmtl red_mat Ns 250.000000 Ka 1.000000 1.000000 1.000000 Kd 1.000000 0.000000 0.000000 -Ks 0.500000 0.500000 0.500000 +Ks 0.000000 0.000000 0.000000 Ke 0.000000 0.000000 0.000000 Ni 1.450000 d 1.000000 +Pr 0.3 +Pm 0.05 illum 2 diff --git a/tests/data/material_color.urdf b/tests/data/material_color.urdf new file mode 100644 index 0000000..9383c71 --- /dev/null +++ b/tests/data/material_color.urdf @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/material_mesh_color.urdf b/tests/data/material_mesh_color.urdf new file mode 100644 index 0000000..b4c8d1a --- /dev/null +++ b/tests/data/material_mesh_color.urdf @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/material_mesh_override.urdf b/tests/data/material_mesh_override.urdf new file mode 100644 index 0000000..23a4c5e --- /dev/null +++ b/tests/data/material_mesh_override.urdf @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/material_mesh_texture.urdf b/tests/data/material_mesh_texture.urdf new file mode 100644 index 0000000..af565dc --- /dev/null +++ b/tests/data/material_mesh_texture.urdf @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/material_texture.urdf b/tests/data/material_texture.urdf new file mode 100644 index 0000000..76d5bdd --- /dev/null +++ b/tests/data/material_texture.urdf @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/material_texture_name_duplication.urdf b/tests/data/material_texture_name_duplication.urdf new file mode 100644 index 0000000..e129061 --- /dev/null +++ b/tests/data/material_texture_name_duplication.urdf @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/test_displayname.urdf b/tests/data/test_displayname.urdf index 0fe257b..4ab4f1b 100644 --- a/tests/data/test_displayname.urdf +++ b/tests/data/test_displayname.urdf @@ -1,6 +1,9 @@ + + + @@ -21,6 +24,7 @@ + diff --git a/tests/data/verifying_elements.urdf b/tests/data/verifying_elements.urdf index 00f0603..0a692fa 100644 --- a/tests/data/verifying_elements.urdf +++ b/tests/data/verifying_elements.urdf @@ -9,7 +9,7 @@ - + diff --git a/tests/testCli.py b/tests/testCli.py index 1dc52a0..be222fd 100644 --- a/tests/testCli.py +++ b/tests/testCli.py @@ -30,13 +30,22 @@ def test_run(self): self.assertTrue((pathlib.Path(self.tmpDir()) / "simple-primitives.usda").exists()) def test_no_layer_structure(self): - model = "tests/data/simple_box.urdf" + model = "tests/data/material_mesh_texture.urdf" robot_name = pathlib.Path(model).stem + + # This is the process to check whether an existing folder will be removed. + textures_dir = pathlib.Path(self.tmpDir()) / "Textures" + if not textures_dir.exists(): + textures_dir.mkdir(parents=True, exist_ok=True) + shutil.copy("tests/data/assets/grid.png", textures_dir / "foo.png") + with patch("sys.argv", ["urdf_usd_converter", model, self.tmpDir(), "--no-layer-structure"]): self.assertEqual(run(), 0, f"Failed to convert {model}") self.assertFalse((pathlib.Path(self.tmpDir()) / "Payload").exists()) self.assertFalse((pathlib.Path(self.tmpDir()) / f"{robot_name}.usda").exists()) self.assertTrue((pathlib.Path(self.tmpDir()) / f"{robot_name}.usdc").exists()) + self.assertTrue((textures_dir / "grid.png").exists()) + self.assertFalse((textures_dir / "foo.png").exists()) def test_no_physics_scene(self): model = "tests/data/simple_box.urdf" @@ -211,7 +220,10 @@ def test_conversion_warning_multiple_ros_packages_invalid(self): patch("sys.argv", ["urdf_usd_converter", robot, str(output_dir), "--package", package_1]), usdex.test.ScopedDiagnosticChecker( self, - [(Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Failed to convert mesh:.*")], + [ + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Failed to convert mesh:.*"), + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Textures are not projection mapped for Cube, Sphere, and Cylinder:.*"), + ], level=usdex.core.DiagnosticsLevel.eWarning, ), ): diff --git a/tests/testConverter.py b/tests/testConverter.py index b4e5adc..33ad574 100644 --- a/tests/testConverter.py +++ b/tests/testConverter.py @@ -4,7 +4,7 @@ import shutil import usdex.test -from pxr import Tf, Usd, UsdGeom +from pxr import Tf, Usd, UsdGeom, UsdShade import urdf_usd_converter from tests.util.ConverterTestCase import ConverterTestCase @@ -124,7 +124,15 @@ def test_ros_packages(self): {"name": "test_texture_package", "path": test_texture_package_dir}, ] converter = urdf_usd_converter.Converter(ros_packages=packages) - asset_path = converter.convert(input_path, output_dir) + with usdex.test.ScopedDiagnosticChecker( + self, + [ + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Textures are not projection mapped for Cube, Sphere, and Cylinder:.*"), + ], + level=usdex.core.DiagnosticsLevel.eWarning, + ): + asset_path = converter.convert(input_path, output_dir) + self.assertIsNotNone(asset_path) self.assertTrue(pathlib.Path(asset_path.path).exists()) @@ -152,7 +160,24 @@ def test_ros_packages(self): self.assertTrue(stl_mesh_prim.HasAuthoredReferences()) # Check material texture. - # TODO: Here we need to make sure that the reference to the usd file is correct after the texture is loaded. + material_scope_prim = default_prim.GetChild("Materials") + self.assertTrue(material_scope_prim.IsValid()) + self.assertTrue(material_scope_prim.IsA(UsdGeom.Scope)) + + texture_material_prim = material_scope_prim.GetChild("texture_material") + self.assertTrue(texture_material_prim.IsValid()) + self.assertTrue(texture_material_prim.IsA(UsdShade.Material)) + + texture_material = UsdShade.Material(texture_material_prim) + self.assertTrue(texture_material) + self.assertTrue(texture_material.GetPrim().HasAuthoredReferences()) + + texture_path = self.get_material_texture_path(texture_material, "diffuseColor") + self.assertEqual(texture_path, pathlib.Path("./Textures/grid.png")) + diffuse_color = self.get_material_diffuse_color(texture_material) + self.assertEqual(diffuse_color, None) + opacity = self.get_material_opacity(texture_material) + self.assertAlmostEqual(opacity, 1.0, places=6) def test_asset_identifer(self): model = pathlib.Path("tests/data/prismatic_joints.urdf") diff --git a/tests/testDisplayName.py b/tests/testDisplayName.py index 14db52a..5125d74 100644 --- a/tests/testDisplayName.py +++ b/tests/testDisplayName.py @@ -3,7 +3,7 @@ import pathlib import usdex.core -from pxr import Usd, UsdGeom, UsdPhysics +from pxr import Usd, UsdGeom, UsdPhysics, UsdShade import urdf_usd_converter from tests.util.ConverterTestCase import ConverterTestCase @@ -74,3 +74,12 @@ def test_display_name(self): self.assertTrue(joint_root_prim.IsValid()) self.assertTrue(joint_root_prim.IsA(UsdPhysics.FixedJoint)) self.assertEqual(usdex.core.getDisplayName(joint_root_prim), "joint:root") + + # Check for materials. + material_scope_prim = default_prim.GetChild("Materials") + self.assertTrue(material_scope_prim.IsValid()) + + material_red_prim = material_scope_prim.GetChild("tn__materialred_rL") + self.assertTrue(material_red_prim.IsValid()) + self.assertTrue(material_red_prim.IsA(UsdShade.Material)) + self.assertEqual(usdex.core.getDisplayName(material_red_prim), "material:red") diff --git a/tests/testMaterial.py b/tests/testMaterial.py new file mode 100644 index 0000000..6407066 --- /dev/null +++ b/tests/testMaterial.py @@ -0,0 +1,552 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 The Newton Developers +# SPDX-License-Identifier: Apache-2.0 +import pathlib +import shutil + +import usdex.test +from pxr import Gf, Tf, Usd, UsdGeom, UsdShade + +import urdf_usd_converter +from tests.util.ConverterTestCase import ConverterTestCase + + +class TestMaterial(ConverterTestCase): + def test_material_color(self): + input_path = "tests/data/material_color.urdf" + output_dir = self.tmpDir() + + converter = urdf_usd_converter.Converter() + asset_path = converter.convert(input_path, output_dir) + + self.assertIsNotNone(asset_path) + self.assertTrue(pathlib.Path(asset_path.path).exists()) + + stage: Usd.Stage = Usd.Stage.Open(asset_path.path) + self.assertIsValidUsd(stage) + + # Check materials. + default_prim = stage.GetDefaultPrim() + material_scope_prim = default_prim.GetChild("Materials") + self.assertTrue(material_scope_prim.IsValid()) + self.assertTrue(material_scope_prim.IsA(UsdGeom.Scope)) + + red_material_prim = material_scope_prim.GetChild("red") + self.assertTrue(red_material_prim.IsValid()) + self.assertTrue(red_material_prim.IsA(UsdShade.Material)) + + red_material = UsdShade.Material(red_material_prim) + self.assertTrue(red_material) + self.assertTrue(red_material.GetPrim().HasAuthoredReferences()) + + diffuse_color = self.get_material_diffuse_color(red_material) + self.assertEqual(diffuse_color, Gf.Vec3f(1, 0, 0)) + opacity = self.get_material_opacity(red_material) + self.assertEqual(opacity, 1.0) + + green_material_prim = material_scope_prim.GetChild("green") + self.assertTrue(green_material_prim.IsValid()) + self.assertTrue(green_material_prim.IsA(UsdShade.Material)) + + green_material = UsdShade.Material(green_material_prim) + self.assertTrue(green_material) + self.assertTrue(green_material.GetPrim().HasAuthoredReferences()) + + diffuse_color = self.get_material_diffuse_color(green_material) + self.assertEqual(diffuse_color, Gf.Vec3f(0, 1, 0)) + opacity = self.get_material_opacity(green_material) + self.assertEqual(opacity, 1.0) + + opacity_half_material_prim = material_scope_prim.GetChild("opacity_half") + self.assertTrue(opacity_half_material_prim.IsValid()) + self.assertTrue(opacity_half_material_prim.IsA(UsdShade.Material)) + + opacity_half_material = UsdShade.Material(opacity_half_material_prim) + self.assertTrue(opacity_half_material) + self.assertTrue(opacity_half_material.GetPrim().HasAuthoredReferences()) + + # Diffuse Color is stored in Linear format, so it is converted from Linear to sRGB. + diffuse_color = self.get_material_diffuse_color(opacity_half_material) + diffuse_color = usdex.core.linearToSrgb(diffuse_color) + self.assertTrue(Gf.IsClose(diffuse_color, Gf.Vec3f(0.2, 0.5, 1), 1e-6)) + opacity = self.get_material_opacity(opacity_half_material) + self.assertEqual(opacity, 0.5) + + # Check the material bindings. + geometry_scope_prim = default_prim.GetChild("Geometry") + self.assertTrue(geometry_scope_prim.IsValid()) + self.assertTrue(geometry_scope_prim.IsA(UsdGeom.Scope)) + + link_box_red_prim = geometry_scope_prim.GetChild("link_box_red") + self.assertTrue(link_box_red_prim.IsValid()) + self.assertTrue(link_box_red_prim.IsA(UsdGeom.Xform)) + + box_prim = link_box_red_prim.GetChild("box") + self.assertTrue(box_prim.IsValid()) + self.assertTrue(box_prim.IsA(UsdGeom.Cube)) + self.check_material_binding(box_prim, red_material) + + link_box_green_prim = link_box_red_prim.GetChild("link_box_green") + self.assertTrue(link_box_green_prim.IsValid()) + self.assertTrue(link_box_green_prim.IsA(UsdGeom.Xform)) + + box_prim = link_box_green_prim.GetChild("box") + self.assertTrue(box_prim.IsValid()) + self.assertTrue(box_prim.IsA(UsdGeom.Cube)) + self.check_material_binding(box_prim, green_material) + + link_box_opacity_half_prim = link_box_green_prim.GetChild("link_box_opacity_half") + self.assertTrue(link_box_opacity_half_prim.IsValid()) + self.assertTrue(link_box_opacity_half_prim.IsA(UsdGeom.Xform)) + + box_prim = link_box_opacity_half_prim.GetChild("box") + self.assertTrue(box_prim.IsValid()) + self.assertTrue(box_prim.IsA(UsdGeom.Cube)) + self.check_material_binding(box_prim, opacity_half_material) + + def test_material_texture(self): + input_path = "tests/data/material_texture.urdf" + output_dir = self.tmpDir() + + converter = urdf_usd_converter.Converter() + + # A warning will appear when performing texture mapping on cubes, spheres, and cylinders. + with usdex.test.ScopedDiagnosticChecker( + self, + [ + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Textures are not projection mapped for Cube, Sphere, and Cylinder:.*"), + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Textures are not projection mapped for Cube, Sphere, and Cylinder:.*"), + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Textures are not projection mapped for Cube, Sphere, and Cylinder:.*"), + ], + level=usdex.core.DiagnosticsLevel.eWarning, + ): + asset_path = converter.convert(input_path, output_dir) + + self.assertIsNotNone(asset_path) + self.assertTrue(pathlib.Path(asset_path.path).exists()) + + stage: Usd.Stage = Usd.Stage.Open(asset_path.path) + self.assertIsValidUsd(stage) + + # Check texture. + output_texture_path = pathlib.Path(output_dir) / "Payload" / "Textures" / "grid.png" + self.assertTrue(output_texture_path.exists()) + + # Check materials. + default_prim = stage.GetDefaultPrim() + material_scope_prim = default_prim.GetChild("Materials") + self.assertTrue(material_scope_prim.IsValid()) + self.assertTrue(material_scope_prim.IsA(UsdGeom.Scope)) + + texture_material_prim = material_scope_prim.GetChild("texture_material") + self.assertTrue(texture_material_prim.IsValid()) + self.assertTrue(texture_material_prim.IsA(UsdShade.Material)) + + texture_material = UsdShade.Material(texture_material_prim) + self.assertTrue(texture_material) + self.assertTrue(texture_material.GetPrim().HasAuthoredReferences()) + + diffuse_color = self.get_material_diffuse_color(texture_material) + self.assertEqual(diffuse_color, None) + opacity = self.get_material_opacity(texture_material) + self.assertEqual(opacity, 1.0) + diffuse_color_texture_path = self.get_material_texture_path(texture_material, "diffuseColor") + self.assertEqual(diffuse_color_texture_path, pathlib.Path("./Textures/grid.png")) + + color_texture_material_prim = material_scope_prim.GetChild("color_texture_material") + self.assertTrue(color_texture_material_prim.IsValid()) + self.assertTrue(color_texture_material_prim.IsA(UsdShade.Material)) + + color_texture_material = UsdShade.Material(color_texture_material_prim) + self.assertTrue(color_texture_material) + self.assertTrue(color_texture_material.GetPrim().HasAuthoredReferences()) + + opacity = self.get_material_opacity(color_texture_material) + self.assertEqual(opacity, 1.0) + + # This texture is multiplied, so the color value is obtained from the fallback. + diffuse_color = self.get_material_diffuse_color_texture_fallback(color_texture_material) + diffuse_color = Gf.Vec3f(*diffuse_color[:3]) + diffuse_color = usdex.core.linearToSrgb(diffuse_color) + self.assertTrue(Gf.IsClose(diffuse_color, Gf.Vec3f(0.5, 0.2, 0.5), 1e-6)) + + geometry_scope_prim = default_prim.GetChild("Geometry") + self.assertTrue(geometry_scope_prim.IsValid()) + self.assertTrue(geometry_scope_prim.IsA(UsdGeom.Scope)) + + link_box_prim = geometry_scope_prim.GetChild("link_box") + self.assertTrue(link_box_prim.IsValid()) + self.assertTrue(link_box_prim.IsA(UsdGeom.Xform)) + + box_prim = link_box_prim.GetChild("box") + self.assertTrue(box_prim.IsValid()) + self.assertTrue(box_prim.IsA(UsdGeom.Cube)) + self.check_material_binding(box_prim, texture_material) + + link_sphere_prim = link_box_prim.GetChild("link_sphere") + self.assertTrue(link_sphere_prim.IsValid()) + self.assertTrue(link_sphere_prim.IsA(UsdGeom.Xform)) + + sphere_prim = link_sphere_prim.GetChild("sphere") + self.assertTrue(sphere_prim.IsValid()) + self.assertTrue(sphere_prim.IsA(UsdGeom.Sphere)) + self.check_material_binding(sphere_prim, texture_material) + + link_cylinder_prim = link_sphere_prim.GetChild("link_cylinder") + self.assertTrue(link_cylinder_prim.IsValid()) + self.assertTrue(link_cylinder_prim.IsA(UsdGeom.Xform)) + + cylinder_prim = link_cylinder_prim.GetChild("cylinder") + self.assertTrue(cylinder_prim.IsValid()) + self.assertTrue(cylinder_prim.IsA(UsdGeom.Cylinder)) + self.check_material_binding(cylinder_prim, texture_material) + + link_obj_texture_prim = link_cylinder_prim.GetChild("link_obj_texture") + self.assertTrue(link_obj_texture_prim.IsValid()) + self.assertTrue(link_obj_texture_prim.IsA(UsdGeom.Xform)) + + obj_prim = link_obj_texture_prim.GetChild("box") + self.assertTrue(obj_prim.IsValid()) + self.assertTrue(obj_prim.IsA(UsdGeom.Mesh)) + self.check_material_binding(obj_prim, texture_material) + + link_obj_color_texture_prim = link_obj_texture_prim.GetChild("link_obj_color_texture") + self.assertTrue(link_obj_color_texture_prim.IsValid()) + self.assertTrue(link_obj_color_texture_prim.IsA(UsdGeom.Xform)) + + obj_prim = link_obj_color_texture_prim.GetChild("box") + self.assertTrue(obj_prim.IsValid()) + self.assertTrue(obj_prim.IsA(UsdGeom.Mesh)) + self.check_material_binding(obj_prim, color_texture_material) + + def test_material_texture_name_duplication_missing_texture(self): + input_path = "tests/data/material_texture_name_duplication.urdf" + output_dir = self.tmpDir() + + converter = urdf_usd_converter.Converter() + with usdex.test.ScopedDiagnosticChecker( + self, + [ + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Texture file not found:.*"), + ], + level=usdex.core.DiagnosticsLevel.eWarning, + ): + asset_path = converter.convert(input_path, output_dir) + + self.assertIsNotNone(asset_path) + self.assertTrue(pathlib.Path(asset_path.path).exists()) + + stage: Usd.Stage = Usd.Stage.Open(asset_path.path) + self.assertIsValidUsd(stage) + + # Check texture. + output_texture_path = pathlib.Path(output_dir) / "Payload" / "Textures" / "grid.png" + self.assertTrue(output_texture_path.exists()) + + # Confirm that this non-existent texture is not being output. + output_texture_path = pathlib.Path(output_dir) / "Payload" / "Textures" / "grid_1.png" + self.assertFalse(output_texture_path.exists()) + + def test_material_texture_name_duplication(self): + """ + Place a structure containing the same-named texture "grid.png" in the temporary directory. + In this case, the converted textures will be placed as "grid.png" and "grid_1.png" in the "Textures" directory. + + [temp] + material_texture_name_duplication.urdf + [assets] + box.obj + grid.png + [textures] + grid.png + """ + temp_path = pathlib.Path(self.tmpDir()) + input_path = temp_path / "material_texture_name_duplication.urdf" + assets_dir = temp_path / "assets" + assets_textures_dir = temp_path / "assets" / "textures" + output_dir = temp_path / "output" + + assets_dir.mkdir(parents=True, exist_ok=True) + assets_textures_dir.mkdir(parents=True, exist_ok=True) + output_dir.mkdir(parents=True, exist_ok=True) + + shutil.copy("tests/data/material_texture_name_duplication.urdf", temp_path) + shutil.copy("tests/data/assets/box.obj", assets_dir) + shutil.copy("tests/data/assets/grid.png", assets_dir) + shutil.copy("tests/data/assets/grid.png", assets_textures_dir) + + converter = urdf_usd_converter.Converter() + asset_path = converter.convert(input_path, output_dir) + + self.assertIsNotNone(asset_path) + self.assertTrue(pathlib.Path(asset_path.path).exists()) + + stage: Usd.Stage = Usd.Stage.Open(asset_path.path) + self.assertIsValidUsd(stage) + + # Check texture. + output_texture_path = pathlib.Path(output_dir) / "Payload" / "Textures" / "grid.png" + self.assertTrue(output_texture_path.exists()) + + output_texture_path = pathlib.Path(output_dir) / "Payload" / "Textures" / "grid_1.png" + self.assertTrue(output_texture_path.exists()) + + def test_material_mesh_color(self): + input_path = "tests/data/material_mesh_color.urdf" + output_dir = self.tmpDir() + + converter = urdf_usd_converter.Converter() + asset_path = converter.convert(input_path, output_dir) + + self.assertIsNotNone(asset_path) + self.assertTrue(pathlib.Path(asset_path.path).exists()) + + stage: Usd.Stage = Usd.Stage.Open(asset_path.path) + self.assertIsValidUsd(stage) + + default_prim = stage.GetDefaultPrim() + geometry_scope_prim = default_prim.GetChild("Geometry") + self.assertTrue(geometry_scope_prim.IsValid()) + self.assertTrue(geometry_scope_prim.IsA(UsdGeom.Scope)) + + link_box_prim = geometry_scope_prim.GetChild("link_box") + self.assertTrue(link_box_prim.IsValid()) + self.assertTrue(link_box_prim.IsA(UsdGeom.Xform)) + + link_obj_prim = link_box_prim.GetChild("link_obj") + self.assertTrue(link_obj_prim.IsValid()) + self.assertTrue(link_obj_prim.IsA(UsdGeom.Xform)) + + two_boxes_prim = link_obj_prim.GetChild("two_boxes") + self.assertTrue(two_boxes_prim.IsValid()) + self.assertTrue(two_boxes_prim.IsA(UsdGeom.Xform)) + self.assertTrue(two_boxes_prim.HasAuthoredReferences()) + + link_obj_specular_workflow_prim = link_obj_prim.GetChild("link_obj_specular_workflow") + self.assertTrue(link_obj_specular_workflow_prim.IsValid()) + self.assertTrue(link_obj_specular_workflow_prim.IsA(UsdGeom.Xform)) + + # Check the materials. + material_scope_prim = default_prim.GetChild("Materials") + self.assertTrue(material_scope_prim.IsValid()) + + green_material_prim = material_scope_prim.GetChild("green_mat") + self.assertTrue(green_material_prim.IsValid()) + self.assertTrue(green_material_prim.IsA(UsdShade.Material)) + green_material = UsdShade.Material(green_material_prim) + self.assertTrue(green_material) + + diffuse_color = self.get_material_diffuse_color(green_material) + diffuse_color = usdex.core.linearToSrgb(diffuse_color) + self.assertTrue(Gf.IsClose(diffuse_color, Gf.Vec3f(0, 1, 0), 1e-6)) + opacity = self.get_material_opacity(green_material) + self.assertEqual(opacity, 1.0) + ior = self.get_material_ior(green_material) + self.assertAlmostEqual(ior, 1.5, places=6) + specular_workflow = self.get_material_specular_workflow(green_material) + self.assertFalse(specular_workflow) + + red_material_prim = material_scope_prim.GetChild("red_mat") + self.assertTrue(red_material_prim.IsValid()) + self.assertTrue(red_material_prim.IsA(UsdShade.Material)) + red_material = UsdShade.Material(red_material_prim) + self.assertTrue(red_material) + specular_workflow = self.get_material_specular_workflow(red_material) + self.assertFalse(specular_workflow) + + diffuse_color = self.get_material_diffuse_color(red_material) + diffuse_color = usdex.core.linearToSrgb(diffuse_color) + self.assertTrue(Gf.IsClose(diffuse_color, Gf.Vec3f(1, 0, 0), 1e-6)) + opacity = self.get_material_opacity(red_material) + self.assertAlmostEqual(opacity, 1.0, places=6) + ior = self.get_material_ior(red_material) + self.assertAlmostEqual(ior, 1.45, places=6) + roughness = self.get_material_roughness(red_material) + self.assertAlmostEqual(roughness, 0.3, places=6) + metallic = self.get_material_metallic(red_material) + self.assertAlmostEqual(metallic, 0.05, places=6) + + box_specular_workflow_prim = material_scope_prim.GetChild("specular_workflow_mat") + self.assertTrue(box_specular_workflow_prim.IsValid()) + self.assertTrue(box_specular_workflow_prim.IsA(UsdShade.Material)) + box_specular_workflow_material = UsdShade.Material(box_specular_workflow_prim) + self.assertTrue(box_specular_workflow_material) + + ior = self.get_material_ior(box_specular_workflow_material) + self.assertAlmostEqual(ior, 1.45, places=6) + specular_workflow = self.get_material_specular_workflow(box_specular_workflow_material) + self.assertTrue(specular_workflow) + specular_color = self.get_material_specular_color(box_specular_workflow_material) + specular_color = usdex.core.linearToSrgb(specular_color) + self.assertTrue(Gf.IsClose(specular_color, Gf.Vec3f(0.5, 0.2, 0.1), 1e-6)) + + mesh_prim = two_boxes_prim.GetChild("Cube_Green") + self.assertTrue(mesh_prim.IsValid()) + self.assertTrue(mesh_prim.IsA(UsdGeom.Mesh)) + self.check_material_binding(mesh_prim, green_material) + + mesh_prim = two_boxes_prim.GetChild("Cube_Red") + self.assertTrue(mesh_prim.IsValid()) + self.assertTrue(mesh_prim.IsA(UsdGeom.Mesh)) + self.check_material_binding(mesh_prim, red_material) + + box_specular_workflow_prim = link_obj_specular_workflow_prim.GetChild("box_specular_workflow") + self.assertTrue(box_specular_workflow_prim.IsValid()) + self.assertTrue(box_specular_workflow_prim.IsA(UsdGeom.Mesh)) + self.assertTrue(box_specular_workflow_prim.HasAuthoredReferences()) + self.check_material_binding(box_specular_workflow_prim, box_specular_workflow_material) + + def test_material_mesh_texture(self): + input_path = "tests/data/material_mesh_texture.urdf" + output_dir = self.tmpDir() + + converter = urdf_usd_converter.Converter() + asset_path = converter.convert(input_path, output_dir) + + self.assertIsNotNone(asset_path) + self.assertTrue(pathlib.Path(asset_path.path).exists()) + + stage: Usd.Stage = Usd.Stage.Open(asset_path.path) + self.assertIsValidUsd(stage) + + # Check texture. + output_texture_path = pathlib.Path(output_dir) / "Payload" / "Textures" / "grid.png" + self.assertTrue(output_texture_path.exists()) + + default_prim = stage.GetDefaultPrim() + geometry_scope_prim = default_prim.GetChild("Geometry") + self.assertTrue(geometry_scope_prim.IsValid()) + self.assertTrue(geometry_scope_prim.IsA(UsdGeom.Scope)) + + link_box_prim = geometry_scope_prim.GetChild("link_box") + self.assertTrue(link_box_prim.IsValid()) + self.assertTrue(link_box_prim.IsA(UsdGeom.Xform)) + + link_obj_prim = link_box_prim.GetChild("link_obj") + self.assertTrue(link_obj_prim.IsValid()) + self.assertTrue(link_obj_prim.IsA(UsdGeom.Xform)) + + box_with_texture_prim = link_obj_prim.GetChild("box_with_texture") + self.assertTrue(box_with_texture_prim.IsValid()) + self.assertTrue(box_with_texture_prim.IsA(UsdGeom.Mesh)) + self.assertTrue(box_with_texture_prim.HasAuthoredReferences()) + + link_obj_opacity_prim = link_obj_prim.GetChild("link_obj_opacity") + self.assertTrue(link_obj_opacity_prim.IsValid()) + self.assertTrue(link_obj_opacity_prim.IsA(UsdGeom.Xform)) + + box_with_texture_opacity_prim = link_obj_opacity_prim.GetChild("box_with_texture_opacity") + self.assertTrue(box_with_texture_opacity_prim.IsValid()) + self.assertTrue(box_with_texture_opacity_prim.IsA(UsdGeom.Mesh)) + self.assertTrue(box_with_texture_opacity_prim.HasAuthoredReferences()) + + link_obj_specular_workflow_with_texture_prim = link_obj_opacity_prim.GetChild("link_obj_specular_workflow_with_texture") + self.assertTrue(link_obj_specular_workflow_with_texture_prim.IsValid()) + self.assertTrue(link_obj_specular_workflow_with_texture_prim.IsA(UsdGeom.Xform)) + + box_specular_workflow_with_texture_prim = link_obj_specular_workflow_with_texture_prim.GetChild("box_specular_workflow_with_texture") + self.assertTrue(box_specular_workflow_with_texture_prim.IsValid()) + self.assertTrue(box_specular_workflow_with_texture_prim.IsA(UsdGeom.Mesh)) + self.assertTrue(box_specular_workflow_with_texture_prim.HasAuthoredReferences()) + + # Check the materials. + material_scope_prim = default_prim.GetChild("Materials") + self.assertTrue(material_scope_prim.IsValid()) + + texture_material_prim = material_scope_prim.GetChild("texture_mat") + self.assertTrue(texture_material_prim.IsValid()) + self.assertTrue(texture_material_prim.IsA(UsdShade.Material)) + texture_material = UsdShade.Material(texture_material_prim) + self.assertTrue(texture_material) + + diffuse_color = self.get_material_diffuse_color(texture_material) + self.assertIsNone(diffuse_color) + opacity = self.get_material_opacity(texture_material) + self.assertAlmostEqual(opacity, 1.0, places=6) + ior = self.get_material_ior(texture_material) + self.assertAlmostEqual(ior, 1.45, places=6) + diffuse_color_texture_path = self.get_material_texture_path(texture_material, "diffuseColor") + self.assertEqual(diffuse_color_texture_path, pathlib.Path("./Textures/grid.png")) + normal_texture_path = self.get_material_texture_path(texture_material, "normal") + self.assertEqual(normal_texture_path, pathlib.Path("./Textures/normal.png")) + roughness_texture_path = self.get_material_texture_path(texture_material, "roughness") + self.assertEqual(roughness_texture_path, pathlib.Path("./Textures/roughness.png")) + metallic_texture_path = self.get_material_texture_path(texture_material, "metallic") + self.assertEqual(metallic_texture_path, pathlib.Path("./Textures/metallic.png")) + + texture_opacity_material_prim = material_scope_prim.GetChild("texture_opacity_mat") + self.assertTrue(texture_opacity_material_prim.IsValid()) + self.assertTrue(texture_opacity_material_prim.IsA(UsdShade.Material)) + texture_opacity_material = UsdShade.Material(texture_opacity_material_prim) + self.assertTrue(texture_opacity_material) + + diffuse_color = self.get_material_diffuse_color(texture_opacity_material) + self.assertIsNone(diffuse_color) + ior = self.get_material_ior(texture_opacity_material) + self.assertAlmostEqual(ior, 1.0, places=6) + opacity_texture_path = self.get_material_texture_path(texture_opacity_material, "opacity") + self.assertEqual(opacity_texture_path, pathlib.Path("./Textures/opacity.png")) + + texture_specular_workflow_material_prim = material_scope_prim.GetChild("specular_workflow_with_texture_mat") + self.assertTrue(texture_specular_workflow_material_prim.IsValid()) + self.assertTrue(texture_specular_workflow_material_prim.IsA(UsdShade.Material)) + texture_specular_workflow_material = UsdShade.Material(texture_specular_workflow_material_prim) + self.assertTrue(texture_specular_workflow_material) + + diffuse_color = self.get_material_diffuse_color(texture_specular_workflow_material) + diffuse_color = usdex.core.linearToSrgb(diffuse_color) + self.assertTrue(Gf.IsClose(diffuse_color, Gf.Vec3f(0.4, 0.4, 0.4), 1e-6)) + ior = self.get_material_ior(texture_specular_workflow_material) + self.assertAlmostEqual(ior, 1.45, places=6) + specular_workflow = self.get_material_specular_workflow(texture_specular_workflow_material) + self.assertTrue(specular_workflow) + specular_texture_path = self.get_material_texture_path(texture_specular_workflow_material, "specularColor") + self.assertEqual(specular_texture_path, pathlib.Path("./Textures/specular.png")) + + self.check_material_binding(box_with_texture_prim, texture_material) + self.check_material_binding(box_with_texture_opacity_prim, texture_opacity_material) + self.check_material_binding(box_specular_workflow_with_texture_prim, texture_specular_workflow_material) + + def test_material_mesh_override(self): + input_path = "tests/data/material_mesh_override.urdf" + output_dir = self.tmpDir() + + converter = urdf_usd_converter.Converter() + asset_path = converter.convert(input_path, output_dir) + + self.assertIsNotNone(asset_path) + self.assertTrue(pathlib.Path(asset_path.path).exists()) + + stage: Usd.Stage = Usd.Stage.Open(asset_path.path) + self.assertIsValidUsd(stage) + + # Check materials. + default_prim = stage.GetDefaultPrim() + material_scope_prim = default_prim.GetChild("Materials") + self.assertTrue(material_scope_prim.IsValid()) + self.assertTrue(material_scope_prim.IsA(UsdGeom.Scope)) + + blue_material_prim = material_scope_prim.GetChild("blue") + self.assertTrue(blue_material_prim.IsValid()) + self.assertTrue(blue_material_prim.IsA(UsdShade.Material)) + + blue_material = UsdShade.Material(blue_material_prim) + self.assertTrue(blue_material) + self.assertTrue(blue_material.GetPrim().HasAuthoredReferences()) + + diffuse_color = self.get_material_diffuse_color(blue_material) + self.assertEqual(diffuse_color, Gf.Vec3f(0, 0, 1)) + opacity = self.get_material_opacity(blue_material) + self.assertEqual(opacity, 1.0) + + default_prim = stage.GetDefaultPrim() + geometry_scope_prim = default_prim.GetChild("Geometry") + self.assertTrue(geometry_scope_prim.IsValid()) + self.assertTrue(geometry_scope_prim.IsA(UsdGeom.Scope)) + + two_boxes_prim = geometry_scope_prim.GetChild("link_box").GetChild("link_obj").GetChild("two_boxes") + self.assertTrue(two_boxes_prim.IsValid()) + self.assertTrue(two_boxes_prim.IsA(UsdGeom.Xform)) + self.assertTrue(two_boxes_prim.HasAuthoredReferences()) + + # Check that the material bind is overwritten with blue_material. + self.check_material_binding(two_boxes_prim, blue_material) diff --git a/tests/testROSPackagesCli.py b/tests/testROSPackagesCli.py index 7a87d04..a126075 100644 --- a/tests/testROSPackagesCli.py +++ b/tests/testROSPackagesCli.py @@ -4,7 +4,8 @@ import shutil from unittest.mock import patch -from pxr import Usd, UsdGeom +import usdex.test +from pxr import Tf, Usd, UsdGeom, UsdShade from tests.util.ConverterTestCase import ConverterTestCase from urdf_usd_converter._impl.cli import run @@ -20,7 +21,16 @@ def test_do_not_specify_ros_package_name(self): input_path = "tests/data/ros_packages.urdf" output_dir = self.tmpDir() - with patch("sys.argv", ["urdf_usd_converter", input_path, output_dir]): + with ( + patch("sys.argv", ["urdf_usd_converter", input_path, output_dir]), + usdex.test.ScopedDiagnosticChecker( + self, + [ + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Textures are not projection mapped for Cube, Sphere, and Cylinder:.*"), + ], + level=usdex.core.DiagnosticsLevel.eWarning, + ), + ): self.assertEqual(run(), 0, f"Failed to convert {input_path}") # Check the USD file after converting ros_packages.urdf. @@ -50,17 +60,26 @@ def test_specify_ros_package_names(self): self.assertTrue(pathlib.Path(temp_stl_file_path).exists()) self.assertTrue(pathlib.Path(temp_texture_file_path).exists()) - with patch( - "sys.argv", - [ - "urdf_usd_converter", - input_path, - output_dir, - "--package", - "test_package=" + test_package_dir, - "--package", - "test_texture_package=" + test_texture_package_dir, - ], + with ( + patch( + "sys.argv", + [ + "urdf_usd_converter", + input_path, + output_dir, + "--package", + "test_package=" + test_package_dir, + "--package", + "test_texture_package=" + test_texture_package_dir, + ], + ), + usdex.test.ScopedDiagnosticChecker( + self, + [ + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Textures are not projection mapped for Cube, Sphere, and Cylinder:.*"), + ], + level=usdex.core.DiagnosticsLevel.eWarning, + ), ): self.assertEqual(run(), 0, f"Failed to convert {input_path}") @@ -99,13 +118,22 @@ def test_do_not_specify_ros_package_with_relative_path(self): shutil.copy("tests/data/assets/box.stl", mesh_dir) shutil.copy("tests/data/assets/grid.png", texture_dir) - with patch( - "sys.argv", - [ - "urdf_usd_converter", - str(input_path), - str(output_dir), - ], + with ( + patch( + "sys.argv", + [ + "urdf_usd_converter", + str(input_path), + str(output_dir), + ], + ), + usdex.test.ScopedDiagnosticChecker( + self, + [ + (Tf.TF_DIAGNOSTIC_WARNING_TYPE, ".*Textures are not projection mapped for Cube, Sphere, and Cylinder:.*"), + ], + level=usdex.core.DiagnosticsLevel.eWarning, + ), ): self.assertEqual(run(), 0, f"Failed to convert {input_path}") @@ -141,4 +169,21 @@ def check_usd_converted_from_urdf(self, usd_path: pathlib.Path): self.assertTrue(stl_mesh_prim.HasAuthoredReferences()) # Check material texture. - # TODO: Here we need to make sure that the reference to the usd file is correct after the texture is loaded. + material_scope_prim = default_prim.GetChild("Materials") + self.assertTrue(material_scope_prim.IsValid()) + self.assertTrue(material_scope_prim.IsA(UsdGeom.Scope)) + + texture_material_prim = material_scope_prim.GetChild("texture_material") + self.assertTrue(texture_material_prim.IsValid()) + self.assertTrue(texture_material_prim.IsA(UsdShade.Material)) + + texture_material = UsdShade.Material(texture_material_prim) + self.assertTrue(texture_material) + self.assertTrue(texture_material.GetPrim().HasAuthoredReferences()) + + texture_path = self.get_material_texture_path(texture_material, "diffuseColor") + self.assertEqual(texture_path, pathlib.Path("./Textures/grid.png")) + diffuse_color = self.get_material_diffuse_color(texture_material) + self.assertEqual(diffuse_color, None) + opacity = self.get_material_opacity(texture_material) + self.assertAlmostEqual(opacity, 1.0, places=6) diff --git a/tests/test_parser.py b/tests/test_parser.py index 22ffee3..565e2bf 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -299,7 +299,7 @@ def test_find_materials(self): self.assertEqual(materials[0].name, "red") self.assertEqual(materials[1].name, "green") self.assertEqual(materials[2].name, "blue") - self.assertEqual(materials[3].name, "black") + self.assertEqual(materials[3].name, "default") self.assertEqual(materials[4].name, "texture") # Find materials by name. @@ -324,10 +324,10 @@ def test_find_materials(self): self.assertIsNone(texture_material.color) self.assertEqual(texture_material.texture.get_with_default("filename"), "assets/grid.png") - black_material = self.parser.find_material_by_name("black") - self.assertTrue(black_material) - self.assertEqual(black_material.name, "black") - self.assertEqual(black_material.color.get_with_default("rgba"), (0.0, 0.0, 0.0, 0.0)) + default_material = self.parser.find_material_by_name("default") + self.assertTrue(default_material) + self.assertEqual(default_material.name, "default") + self.assertEqual(default_material.color.get_with_default("rgba"), (1.0, 1.0, 1.0, 1.0)) # Get non-existent material. non_existent_material = self.parser.find_material_by_name("non_existent_material") @@ -530,13 +530,13 @@ def test_get_materials(self): self.assertEqual(material[2], None) material = materials[3] - self.assertEqual(material[0], "black") - self.assertEqual(material[1], (0.0, 0.0, 0.0, 0.0)) + self.assertEqual(material[0], "default") + self.assertEqual(material[1], (1.0, 1.0, 1.0, 1.0)) self.assertEqual(material[2], None) material = materials[4] self.assertEqual(material[0], "texture") - self.assertEqual(material[1], (0.0, 0.0, 0.0, 0.0)) + self.assertEqual(material[1], (1.0, 1.0, 1.0, 1.0)) self.assertEqual(material[2], "assets/grid.png") material = materials[5] diff --git a/tests/util/ConverterTestCase.py b/tests/util/ConverterTestCase.py index b4760ce..442b760 100644 --- a/tests/util/ConverterTestCase.py +++ b/tests/util/ConverterTestCase.py @@ -1,9 +1,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 The Newton Developers # SPDX-License-Identifier: Apache-2.0 +import pathlib import omni.asset_validator import usdex.test -from pxr import UsdGeom +from pxr import Gf, Usd, UsdGeom, UsdShade class ConverterTestCase(usdex.test.TestCase): @@ -15,3 +16,75 @@ def setUp(self): # All conversion results should be valid atomic assets self.validationEngine.enable_rule(omni.asset_validator.AnchoredAssetPathsChecker) self.validationEngine.enable_rule(omni.asset_validator.SupportedFileTypesChecker) + + def check_material_binding(self, prim: Usd.Prim, material: UsdShade.Material): + material_binding = UsdShade.MaterialBindingAPI(prim) + self.assertTrue(material_binding) + self.assertTrue(material_binding.GetDirectBindingRel()) + self.assertEqual(len(material_binding.GetDirectBindingRel().GetTargets()), 1) + bound_material = material_binding.GetDirectBindingRel().GetTargets()[0] + self.assertEqual(bound_material, material.GetPrim().GetPath()) + + def _get_input_value(self, shader: UsdShade.Shader, input_name: str): + value_attrs = UsdShade.Utils.GetValueProducingAttributes(shader.GetInput(input_name)) + + # If no value is set, returns None. + if not value_attrs or len(value_attrs) == 0: + return None + + return value_attrs[0].Get() + + def _get_material_input_value(self, material: UsdShade.Material, input_name: str): + shader: UsdShade.Shader = usdex.core.computeEffectivePreviewSurfaceShader(material) + return self._get_input_value(shader, input_name) + + def get_material_diffuse_color(self, material: UsdShade.Material) -> Gf.Vec3f | None: + return self._get_material_input_value(material, "diffuseColor") + + def get_material_specular_color(self, material: UsdShade.Material) -> Gf.Vec3f | None: + return self._get_material_input_value(material, "specularColor") + + def get_material_specular_workflow(self, material: UsdShade.Material) -> bool: + return self._get_material_input_value(material, "useSpecularWorkflow") == 1 + + def get_material_opacity(self, material: UsdShade.Material) -> float: + return self._get_material_input_value(material, "opacity") + + def get_material_roughness(self, material: UsdShade.Material) -> float: + return self._get_material_input_value(material, "roughness") + + def get_material_metallic(self, material: UsdShade.Material) -> float: + return self._get_material_input_value(material, "metallic") + + def get_material_ior(self, material: UsdShade.Material) -> float: + return self._get_material_input_value(material, "ior") + + def get_material_texture_path(self, material: UsdShade.Material, texture_type: str = "diffuseColor") -> pathlib.Path: + """ + Get the texture path for the given texture type. + + Args: + material: The material. + texture_type: The texture type. Valid values are "diffuseColor", "normal", "roughness" and "metallic". + + Returns: + The texture path. + """ + shader: UsdShade.Shader = usdex.core.computeEffectivePreviewSurfaceShader(material) + texture_input: UsdShade.Input = shader.GetInput(texture_type) + self.assertTrue(texture_input.HasConnectedSource()) + + connected_source = texture_input.GetConnectedSource() + texture_shader = UsdShade.Shader(connected_source[0].GetPrim()) + texture_file_value = self._get_input_value(texture_shader, "file") + return pathlib.Path(texture_file_value.path) + + def get_material_diffuse_color_texture_fallback(self, material: UsdShade.Material) -> Gf.Vec4f | None: + shader: UsdShade.Shader = usdex.core.computeEffectivePreviewSurfaceShader(material) + diffuse_color_input = shader.GetInput("diffuseColor") + if diffuse_color_input.HasConnectedSource(): + source = diffuse_color_input.GetConnectedSource() + if len(source) > 0 and isinstance(source[0], UsdShade.ConnectableAPI) and source[0].GetPrim().IsA(UsdShade.Shader): + diffuse_texture_shader = UsdShade.Shader(source[0].GetPrim()) + return self._get_input_value(diffuse_texture_shader, "fallback") + return None diff --git a/urdf_usd_converter/_impl/_flatten.py b/urdf_usd_converter/_impl/_flatten.py index 8842df2..97c20fb 100644 --- a/urdf_usd_converter/_impl/_flatten.py +++ b/urdf_usd_converter/_impl/_flatten.py @@ -17,18 +17,18 @@ def export_flattened(asset_stage: Usd.Stage, output_dir: str, asset_dir: str, as asset_identifier = f"{output_path.absolute().as_posix()}/{asset_stem}.{asset_format}" usdex.core.exportLayer(layer, asset_identifier, get_authoring_metadata(), comment) - # fix all PreviewMaterial inputs:file to ./Textures/xxx + # fix all PreviewMaterial material interface asset inputs from abs to rel paths (./Textures/xxx) stage = Usd.Stage.Open(asset_identifier) for prim in stage.Traverse(): - if prim.IsA(UsdShade.Shader): - shader = UsdShade.Shader(prim) - file_input = shader.GetInput("file") - if file_input and file_input.Get() is not None: - file_path = pathlib.Path(file_input.Get().path if hasattr(file_input.Get(), "path") else file_input.Get()) - tmpdir = pathlib.Path(tempfile.gettempdir()) - if file_path.is_relative_to(tmpdir): - new_path = f"./{Tokens.Textures}/{file_path.name}" - file_input.Set(Sdf.AssetPath(new_path)) + if prim.IsA(UsdShade.Material): + material = UsdShade.Material(prim) + for input in material.GetInputs(onlyAuthored=True): + if input.GetTypeName() == Sdf.ValueTypeNames.Asset: + file_path = pathlib.Path(input.Get().path) + tmpdir = pathlib.Path(tempfile.gettempdir()) + if file_path.is_relative_to(tmpdir): + new_path = f"./{Tokens.Textures}/{file_path.name}" + input.Set(Sdf.AssetPath(new_path)) stage.Save() # copy texture to output dir temp_textures_dir = pathlib.Path(asset_dir) / Tokens.Payload / Tokens.Textures diff --git a/urdf_usd_converter/_impl/convert.py b/urdf_usd_converter/_impl/convert.py index fef1b24..ead8aef 100644 --- a/urdf_usd_converter/_impl/convert.py +++ b/urdf_usd_converter/_impl/convert.py @@ -89,6 +89,9 @@ def convert(self, input_file: str, output_dir: str) -> Sdf.AssetPath: link_hierarchy=LinkHierarchy(parser.get_root_element()), mesh_cache=MeshCache(), ros_packages=ros_packages, + resolved_file_paths={}, + material_data_list=[], + mesh_material_references={}, ) # setup the main output layer (which will become an asset interface later) @@ -120,14 +123,17 @@ def convert(self, input_file: str, output_dir: str) -> Sdf.AssetPath: # setup the root layer of the payload data.content[Tokens.Contents] = usdex.core.createAssetPayload(asset_stage) - # author the mesh library + # author the mesh library. + # Here, the material data referenced by each mesh is retrieved and stored in data.material_data_list. convert_meshes(data) - # setup a content layer for referenced meshes - data.content[Tokens.Geometry] = usdex.core.addAssetContent(data.content[Tokens.Contents], Tokens.Geometry, format="usda") - # author the material library and setup the content layer for materials only if there are materials + # Convert the materials. + # Here, all materials referenced by the URDF's global materials and meshes are scanned and stored. convert_materials(data) + # setup a content layer for referenced meshes + data.content[Tokens.Geometry] = usdex.core.addAssetContent(data.content[Tokens.Contents], Tokens.Geometry, format="usda") + # setup a content layer for physics data.content[Tokens.Physics] = usdex.core.addAssetContent(data.content[Tokens.Contents], Tokens.Physics, format="usda") data.content[Tokens.Physics].SetMetadata(UsdPhysics.Tokens.kilogramsPerUnit, 1) diff --git a/urdf_usd_converter/_impl/data.py b/urdf_usd_converter/_impl/data.py index 804bbe3..0ea8601 100644 --- a/urdf_usd_converter/_impl/data.py +++ b/urdf_usd_converter/_impl/data.py @@ -1,11 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 The Newton Developers # SPDX-License-Identifier: Apache-2.0 +import pathlib from dataclasses import dataclass import usdex.core from pxr import Usd from .link_hierarchy import LinkHierarchy +from .material_data import MaterialData from .mesh_cache import MeshCache from .urdf_parser.parser import URDFParser @@ -35,3 +37,6 @@ class ConversionData: link_hierarchy: LinkHierarchy mesh_cache: MeshCache ros_packages: list[dict[str, str]] + resolved_file_paths: dict[str, pathlib.Path] # [mesh_file_name, resolved_file_path] + material_data_list: list[MaterialData] # Store all material parameters. + mesh_material_references: dict[pathlib.Path, dict[str, str]] # [mesh_file_path, [mesh_safe_name, material_name]] diff --git a/urdf_usd_converter/_impl/geometry.py b/urdf_usd_converter/_impl/geometry.py index 22855b1..9d04863 100644 --- a/urdf_usd_converter/_impl/geometry.py +++ b/urdf_usd_converter/_impl/geometry.py @@ -4,6 +4,7 @@ from pxr import Gf, Tf, Usd, UsdGeom, UsdPhysics from .data import ConversionData, Tokens +from .material import bind_material, bind_mesh_material from .urdf_parser.elements import ( ElementBox, ElementCollision, @@ -44,6 +45,14 @@ def convert_geometry(parent: Usd.Prim, name: str, safe_name: str, geometry: Elem # Apply CollisionAPI to collision geometry apply_physics_collision(prim.GetPrim(), data) + # If the visual has a material, bind the material. + # If the Visual element does not have a material, bind a material per mesh. + if isinstance(geometry, ElementVisual): + if geometry.material and geometry.material.name: + bind_material(prim.GetPrim(), None, geometry.material.name, data) + elif isinstance(geometry.geometry.shape, ElementMesh): + bind_mesh_material(prim.GetPrim(), geometry.geometry.shape.filename, data) + return prim diff --git a/urdf_usd_converter/_impl/material.py b/urdf_usd_converter/_impl/material.py index 63d5782..3e68194 100644 --- a/urdf_usd_converter/_impl/material.py +++ b/urdf_usd_converter/_impl/material.py @@ -1,18 +1,408 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 The Newton Developers # SPDX-License-Identifier: Apache-2.0 +import pathlib +import shutil + +import tinyobjloader import usdex.core +from pxr import Gf, Sdf, Tf, Usd, UsdGeom, UsdShade, UsdUtils from .data import ConversionData, Tokens +from .material_cache import MaterialCache +from .material_data import MaterialData +from .ros_package import resolve_ros_package_paths -__all__ = ["convert_materials"] +__all__ = [ + "bind_material", + "bind_mesh_material", + "convert_materials", + "store_mesh_material_reference", + "store_obj_material_data", +] def convert_materials(data: ConversionData): - materials = data.urdf_parser.get_materials() - if not len(materials): + # Acquire the global material data for URDF and the material data for obj/dae files. + material_cache = MaterialCache(data) + if not len(data.material_data_list): return + # Copy the textures to the payload directory. + _copy_textures(material_cache, data) + data.libraries[Tokens.Materials] = usdex.core.addAssetLibrary(data.content[Tokens.Contents], Tokens.Materials, format="usdc") data.references[Tokens.Materials] = {} - # TODO: Implement + materials_scope = data.libraries[Tokens.Materials].GetDefaultPrim() + + # Set the safe names of the material data list. + material_cache.store_safe_names(data) + + # Convert the material data to USD. + for material_data in data.material_data_list: + material_prim = _convert_material( + materials_scope, + material_data.safe_name, + material_data.diffuse_color, + material_data.specular_color, + material_data.opacity, + material_data.roughness, + material_data.metallic, + material_data.ior, + material_data.diffuse_texture_path, + material_data.specular_texture_path, + material_data.normal_texture_path, + material_data.roughness_texture_path, + material_data.metallic_texture_path, + material_data.opacity_texture_path, + material_cache.texture_paths, + data, + ) + data.references[Tokens.Materials][material_data.safe_name] = material_prim + if material_data.name != material_data.safe_name: + usdex.core.setDisplayName(material_prim.GetPrim(), material_data.name) + + robot_name = data.urdf_parser.get_robot_name() + usdex.core.saveStage(data.libraries[Tokens.Materials], comment=f"Material Library for {robot_name}. {data.comment}") + + # setup a content layer for referenced materials + data.content[Tokens.Materials] = usdex.core.addAssetContent(data.content[Tokens.Contents], Tokens.Materials, format="usda") + + +def _copy_textures(material_cache: MaterialCache, data: ConversionData): + """ + Copy the textures to the payload directory. + + Args: + material_cache: The material cache. + data: The conversion data. + """ + if not len(material_cache.texture_paths): + return + + # copy the texture to the payload directory + local_texture_dir = pathlib.Path(data.content[Tokens.Contents].GetRootLayer().identifier).parent / Tokens.Textures + if not local_texture_dir.exists(): + local_texture_dir.mkdir(parents=True) + + for texture_path in material_cache.texture_paths: + # At this stage, the existence has already been checked. + if texture_path.exists(): + unique_file_name = material_cache.texture_paths[texture_path] + + local_texture_path = local_texture_dir / unique_file_name + shutil.copyfile(texture_path, local_texture_path) + Tf.Status(f"Copied texture {texture_path} to {local_texture_path}") + + +def _convert_material( + parent: Usd.Prim, + safe_name: str, + diffuse_color: Gf.Vec3f, + specular_color: Gf.Vec3f, + opacity: float, + roughness: float, + metallic: float, + ior: float, + diffuse_texture_path: pathlib.Path | None, + specular_texture_path: pathlib.Path | None, + normal_texture_path: pathlib.Path | None, + roughness_texture_path: pathlib.Path | None, + metallic_texture_path: pathlib.Path | None, + opacity_texture_path: pathlib.Path | None, + texture_paths: dict[pathlib.Path, str], + data: ConversionData, +) -> UsdShade.Material: + """ + Convert a material to USD. + This is used for both URDF global materials and materials in obj/dae files. + + Args: + parent: The parent prim. + safe_name: The safe name of the material. This is a unique name that does not overlap with other material names. + diffuse_color: The diffuse color of the material. + specular_color: The specular color of the material. + opacity: The opacity of the material. + roughness: The roughness of the material. + metallic: The metallic of the material. + ior: The ior of the material. + diffuse_texture_path: The path to the diffuse texture. + specular_texture_path: The path to the specular texture. + normal_texture_path: The path to the normal texture. + roughness_texture_path: The path to the roughness texture. + metallic_texture_path: The path to the metallic texture. + opacity_texture_path: The path to the opacity texture. + texture_paths: A dictionary of texture paths and unique names. + data: The conversion data. + + Returns: + The material prim. + """ + diffuse_color = usdex.core.sRgbToLinear(diffuse_color) + specular_color = usdex.core.sRgbToLinear(specular_color) + + # Build kwargs for material properties + material_kwargs = { + "color": diffuse_color, + "opacity": opacity, + "roughness": roughness, + "metallic": metallic, + } + + # Define the material. + material_prim = usdex.core.definePreviewMaterial(parent, safe_name, **material_kwargs) + if not material_prim: + Tf.RaiseRuntimeError(f'Failed to convert material "{safe_name}"') + + surface_shader: UsdShade.Shader = usdex.core.computeEffectivePreviewSurfaceShader(material_prim) + if ior != 0.0: + surface_shader.CreateInput("ior", Sdf.ValueTypeNames.Float).Set(ior) + + if diffuse_texture_path: + usdex.core.addDiffuseTextureToPreviewMaterial(material_prim, _get_texture_asset_path(diffuse_texture_path, texture_paths, data)) + + if normal_texture_path: + usdex.core.addNormalTextureToPreviewMaterial(material_prim, _get_texture_asset_path(normal_texture_path, texture_paths, data)) + + if roughness_texture_path: + usdex.core.addRoughnessTextureToPreviewMaterial(material_prim, _get_texture_asset_path(roughness_texture_path, texture_paths, data)) + + if metallic_texture_path: + usdex.core.addMetallicTextureToPreviewMaterial(material_prim, _get_texture_asset_path(metallic_texture_path, texture_paths, data)) + + if opacity_texture_path: + usdex.core.addOpacityTextureToPreviewMaterial(material_prim, _get_texture_asset_path(opacity_texture_path, texture_paths, data)) + + # If the specular color is not black or the specular texture exists, use the specular workflow. + if specular_color != [0, 0, 0] or specular_texture_path: + surface_shader.CreateInput("useSpecularWorkflow", Sdf.ValueTypeNames.Int).Set(1) + surface_shader.CreateInput("specularColor", Sdf.ValueTypeNames.Color3f).Set(specular_color) + if specular_texture_path: + _add_specular_texture_to_preview_material(material_prim, _get_texture_asset_path(specular_texture_path, texture_paths, data)) + + result = usdex.core.addPreviewMaterialInterface(material_prim) + if not result: + Tf.RaiseRuntimeError(f'Failed to add material instance to material prim "{material_prim.GetPath()}"') + + material_prim.GetPrim().SetInstanceable(True) + + return material_prim + + +def _add_specular_texture_to_preview_material(material_prim: UsdShade.Material, specular_texture_path: Sdf.AssetPath): + """ + Add the specular texture to the preview material. + + Args: + material_prim: The material prim. + specular_texture_path: The path to the specular texture. + """ + surface: UsdShade.Shader = usdex.core.computeEffectivePreviewSurfaceShader(material_prim) + + specular_color = Gf.Vec3f(0.0, 0.0, 0.0) + specular_color_input = surface.GetInput("specularColor") + if specular_color_input: + value_attrs = specular_color_input.GetValueProducingAttributes() + if value_attrs and len(value_attrs) > 0: + specular_color = value_attrs[0].Get() + specular_color_input.GetAttr().Clear() + else: + specular_color_input = surface.CreateInput("specularColor", Sdf.ValueTypeNames.Color3f) + fallback = Gf.Vec4f(specular_color[0], specular_color[1], specular_color[2], 1.0) + + # Acquire the texture reader. + texture_reader: UsdShade.Shader = _acquire_texture_reader( + material_prim, "SpecularTexture", specular_texture_path, usdex.core.ColorSpace.eAuto, fallback + ) + + # Connect the PreviewSurface shader "specularColor" to the specular texture shader output + specular_color_input.ConnectToSource(texture_reader.CreateOutput("rgb", Sdf.ValueTypeNames.Float3)) + + +def _acquire_texture_reader( + material_prim: UsdShade.Material, + shader_name: str, + texture_path: pathlib.Path, + color_space: usdex.core.ColorSpace, + fallback: Gf.Vec4f, +) -> UsdShade.Shader: + """ + Acquire the texture reader. + + Args: + material_prim: The material prim. + shader_name: The name of the shader. + texture_path: The path to the texture. + color_space: The color space of the texture. + fallback: The fallback value for the texture. + + Returns: + The texture reader. + """ + shader_path = material_prim.GetPath().AppendChild(shader_name) + tex_shader = UsdShade.Shader.Define(material_prim.GetPrim().GetStage(), shader_path) + tex_shader.SetShaderId("UsdUVTexture") + tex_shader.CreateInput("fallback", Sdf.ValueTypeNames.Float4).Set(fallback) + tex_shader.CreateInput("file", Sdf.ValueTypeNames.Asset).Set(texture_path) + tex_shader.CreateInput("sourceColorSpace", Sdf.ValueTypeNames.Token).Set(usdex.core.getColorSpaceToken(color_space)) + st_input = tex_shader.CreateInput("st", Sdf.ValueTypeNames.Float2) + connected = usdex.core.connectPrimvarShader(st_input, UsdUtils.GetPrimaryUVSetName()) + if not connected: + return UsdShade.Shader() + + return tex_shader + + +def _get_texture_asset_path(texture_path: pathlib.Path, texture_paths: dict[pathlib.Path, str], data: ConversionData) -> Sdf.AssetPath: + """ + Get the asset path for the texture. + + Args: + texture_path: The path to the texture. + texture_paths: A dictionary of texture paths and unique names. + + Returns: + The asset path for the texture. + """ + # The path to the texture to reference. If None, the texture does not exist. + unique_file_name = texture_paths.get(texture_path) + + # If the texture exists, add the texture to the material. + payload_dir = pathlib.Path(data.content[Tokens.Contents].GetRootLayer().identifier).parent + local_texture_dir = payload_dir / Tokens.Textures + local_texture_path = local_texture_dir / unique_file_name + if local_texture_path.exists(): + relative_texture_path = local_texture_path.relative_to(payload_dir) + return Sdf.AssetPath(f"./{relative_texture_path.as_posix()}") + else: + return Sdf.AssetPath("") + + +def store_obj_material_data(mesh_file_path: pathlib.Path, reader: tinyobjloader.ObjReader, data: ConversionData): + """ + Store the material data from the OBJ file. + This is used to temporarily cache material parameters when loading an OBJ mesh. + + Args: + mesh_file_path: The path to the mesh file. + reader: The tinyobjloader reader. + data: The conversion data. + """ + materials = reader.GetMaterials() + for material in materials: + material_data = MaterialData() + material_data.mesh_file_path = mesh_file_path + material_data.name = material.name + material_data.diffuse_color = Gf.Vec3f(material.diffuse[0], material.diffuse[1], material.diffuse[2]) + material_data.specular_color = Gf.Vec3f(material.specular[0], material.specular[1], material.specular[2]) + material_data.opacity = material.dissolve + material_data.ior = material.ior if material.ior else 0.0 + + # The following is the extended specification of obj. + material_data.roughness = material.roughness if material.roughness else 0.5 + material_data.metallic = material.metallic if material.metallic else 0.0 + + material_data.diffuse_texture_path = (mesh_file_path.parent / material.diffuse_texname) if material.diffuse_texname else None + material_data.specular_texture_path = (mesh_file_path.parent / material.specular_texname) if material.specular_texname else None + material_data.normal_texture_path = (mesh_file_path.parent / material.normal_texname) if material.normal_texname else None + material_data.roughness_texture_path = (mesh_file_path.parent / material.roughness_texname) if material.roughness_texname else None + material_data.metallic_texture_path = (mesh_file_path.parent / material.metallic_texname) if material.metallic_texname else None + material_data.opacity_texture_path = (mesh_file_path.parent / material.alpha_texname) if material.alpha_texname else None + + # If the normal texture is not specified, use the bump texture. + if material_data.normal_texture_path is None: + material_data.normal_texture_path = (mesh_file_path.parent / material.bump_texname) if material.bump_texname else None + + data.material_data_list.append(material_data) + + +def store_mesh_material_reference(mesh_file_path: pathlib.Path, mesh_safe_name: str, material_name: str, data: ConversionData): + """ + Store the per-mesh material reference. + + Args: + mesh_file_path: The path to the source file. + mesh_safe_name: The safe name of the mesh. + material_name: The name of the material. + data: The conversion data. + """ + if mesh_file_path not in data.mesh_material_references: + data.mesh_material_references[mesh_file_path] = {} + data.mesh_material_references[mesh_file_path][mesh_safe_name] = material_name + + +def _get_material_by_name(mesh_file_path: pathlib.Path | None, material_name: str, data: ConversionData) -> UsdShade.Material: + """ + Get the material by the mesh path and material name. + + Args: + mesh_file_path: The path to the mesh file. If None, the material is a global material. + material_name: The name of the material. + data: The conversion data. + + Returns: + The material if found, otherwise None. + """ + for material_data in data.material_data_list: + if material_data.name == material_name and material_data.mesh_file_path == mesh_file_path: + return data.references[Tokens.Materials][material_data.safe_name] + return None + + +def bind_material(geom_prim: Usd.Prim, mesh_file_path: pathlib.Path | None, material_name: str, data: ConversionData): + """ + Bind the material to the geometries. + If there are meshes in the Xform, it will traverse the meshes and assign materials to them. + + Args: + geom_prim: The geometry prim. + mesh_file_path: The path to the mesh file. If None, the material is a global material. + material_name: The name of the material. + data: The conversion data. + """ + local_materials = data.content[Tokens.Materials].GetDefaultPrim().GetChild(Tokens.Materials) + + # Get the material by the mesh path and material name. + ref_material = _get_material_by_name(mesh_file_path, material_name, data) + if not ref_material: + Tf.Warn(f"Material '{material_name}' not found in Material Library {data.libraries[Tokens.Materials].GetRootLayer().identifier}") + return + + # If the material does not exist in the Material layer, define the reference. + material_prim = UsdShade.Material(local_materials.GetChild(ref_material.GetPrim().GetName())) + if not material_prim: + material_prim = UsdShade.Material(usdex.core.defineReference(local_materials, ref_material.GetPrim(), ref_material.GetPrim().GetName())) + + # If the geometry is a cube, sphere, or cylinder, check if the material has a texture. + if mesh_file_path is None and (geom_prim.IsA(UsdGeom.Cube) or geom_prim.IsA(UsdGeom.Sphere) or geom_prim.IsA(UsdGeom.Cylinder)): + # Get the texture from the material. + materials = data.urdf_parser.get_materials() + for material in materials: + if material[0] == material_name: + if material[2]: + Tf.Warn(f"Textures are not projection mapped for Cube, Sphere, and Cylinder: {geom_prim.GetPath()}") + break + + # Bind the material to the geometry. + geom_over = data.content[Tokens.Materials].OverridePrim(geom_prim.GetPath()) + usdex.core.bindMaterial(geom_over, material_prim) + + +def bind_mesh_material(geom_prim: Usd.Prim, mesh_file_name: str, data: ConversionData): + """ + Bind the material to the meshes in the geometry. + Each mesh references a mesh within the GeometryLibrary, + and if a material exists for the prim name at that time, it searches for and binds it. + + Args: + geom_prim: The geometry prim. + mesh_file_name: The name of the mesh file. + data: The conversion data. + """ + resolved_file_path = resolve_ros_package_paths(mesh_file_name, data) + for prim in Usd.PrimRange(geom_prim): + if prim.IsA(UsdGeom.Mesh): + mesh_name = prim.GetName() + if resolved_file_path in data.mesh_material_references and mesh_name in data.mesh_material_references[resolved_file_path]: + material_name = data.mesh_material_references[resolved_file_path][mesh_name] + bind_material(prim, resolved_file_path, material_name, data) diff --git a/urdf_usd_converter/_impl/material_cache.py b/urdf_usd_converter/_impl/material_cache.py new file mode 100644 index 0000000..48fca51 --- /dev/null +++ b/urdf_usd_converter/_impl/material_cache.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 The Newton Developers +# SPDX-License-Identifier: Apache-2.0 +import pathlib + +from pxr import Gf, Tf + +from .data import ConversionData, Tokens +from .material_data import MaterialData +from .ros_package import resolve_ros_package_paths + +__all__ = ["MaterialCache"] + + +class MaterialCache: + def __init__(self, data: ConversionData): + # A dictionary of texture paths and unique names. + self.texture_paths: dict[pathlib.Path, str] = {} + + # Store the material data. + self._store_materials(data) + + def store_safe_names(self, data: ConversionData): + """ + Store the safe names of the material data list. + + Args: + material_data_list: The list of material data. + data: The conversion data. + """ + materials_scope = data.libraries[Tokens.Materials].GetDefaultPrim() + material_names = [material_data.name for material_data in data.material_data_list] + safe_names = data.name_cache.getPrimNames(materials_scope, material_names) + + for material_data, safe_name in zip(data.material_data_list, safe_names): + material_data.safe_name = safe_name + + def _store_materials(self, data: ConversionData): + """ + Get the material data from the URDF file and the OBJ/DAE files. + Material data is stored in `data.material_data_list`. + If the material is stored in an obj or dae file, + the material data will already be stored when this method is called. + Here, global materials in URDF are added, and a list of texture paths is created. + + Args: + data: The conversion data. + """ + # Get the material data from the URDF file. + data.material_data_list.extend(self._get_urdf_material_data_list(data)) + + # Get a dictionary of resolved texture paths and unique names. + # It stores all the texture file paths referenced by urdf materials and each mesh. + self.texture_paths = self._get_material_texture_paths(data) + + def _get_material_texture_paths(self, data: ConversionData) -> dict[pathlib.Path, str]: + """ + Create a dictionary of resolved texture paths and unique names. + These include all global materials and the textures of materials referenced by meshes. + + Args: + data: The conversion data. + + Returns: + A dictionary of texture paths and unique names. + """ + # Get the texture paths from the materials. + texture_paths_list: list[pathlib.Path] = [] + for material_data in data.material_data_list: + texture_paths = [ + material_data.diffuse_texture_path, + material_data.specular_texture_path, + material_data.normal_texture_path, + material_data.roughness_texture_path, + material_data.metallic_texture_path, + material_data.opacity_texture_path, + ] + for texture_path in texture_paths: + if texture_path and texture_path not in texture_paths_list: + texture_paths_list.append(texture_path) + if not texture_path.exists(): + Tf.Warn(f"Texture file not found: {texture_path}") + + # Create a list of texture filenames. + names = [texture_path.name for texture_path in texture_paths_list] + + # Rename the list of image filenames to unique names. + unique_file_names = [] + name_counts = {} + for name in names: + if name not in name_counts: + name_counts[name] = 0 + unique_file_names.append(name) + else: + name_counts[name] += 1 + stem = pathlib.Path(name).stem + suffix = pathlib.Path(name).suffix + unique_name = f"{stem}_{name_counts[name]}{suffix}" + unique_file_names.append(unique_name) + + texture_paths = dict(zip(texture_paths_list, unique_file_names)) + + return texture_paths + + def _get_urdf_material_data_list(self, data: ConversionData) -> list[MaterialData]: + """ + Get the material data from the URDF file (Global Materials). + + Args: + data: The conversion data. + + Returns: + A list of material data. + """ + material_data_list = [] + + materials = data.urdf_parser.get_materials() + for material in materials: + material_data = MaterialData() + material_data.name = material[0] + material_data.diffuse_color = Gf.Vec3f(*material[1][:3]) + material_data.opacity = material[1][3] + + # material[2] is the path to the texture file. + if material[2]: + # Resolve the ROS package paths. + # If the path is not a ROS package, it will return the original path. + # It also converts the path to a relative path based on the urdf file. + material_data.diffuse_texture_path = resolve_ros_package_paths(material[2], data) + + material_data_list.append(material_data) + + return material_data_list diff --git a/urdf_usd_converter/_impl/material_data.py b/urdf_usd_converter/_impl/material_data.py new file mode 100644 index 0000000..4986451 --- /dev/null +++ b/urdf_usd_converter/_impl/material_data.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers +# SPDX-License-Identifier: Apache-2.0 +import pathlib + +from pxr import Gf + +__all__ = ["MaterialData"] + + +class MaterialData: + """ + Temporary data when storing material. + """ + + def __init__(self): + # The path to the mesh file. For global materials used in URDF, None is entered. + self.mesh_file_path: pathlib.Path | None = None + + # The name of the material. + self.name: str | None = None + + # The safe name of the material. + # This is a unique name that does not overlap with other material names. + self.safe_name: str | None = None + + # The material properties. + self.diffuse_color: Gf.Vec3f = Gf.Vec3f(1.0, 1.0, 1.0) + self.specular_color: Gf.Vec3f = Gf.Vec3f(0.0, 0.0, 0.0) + self.opacity: float = 1.0 + self.roughness: float = 0.5 + self.metallic: float = 0.0 + self.ior: float = 0.0 + + self.diffuse_texture_path: pathlib.Path | None = None + self.specular_texture_path: pathlib.Path | None = None + self.normal_texture_path: pathlib.Path | None = None + self.roughness_texture_path: pathlib.Path | None = None + self.metallic_texture_path: pathlib.Path | None = None + self.opacity_texture_path: pathlib.Path | None = None diff --git a/urdf_usd_converter/_impl/mesh.py b/urdf_usd_converter/_impl/mesh.py index 88fcb49..819c8a6 100644 --- a/urdf_usd_converter/_impl/mesh.py +++ b/urdf_usd_converter/_impl/mesh.py @@ -9,6 +9,7 @@ from pxr import Gf, Tf, Usd, UsdGeom, Vt from .data import ConversionData, Tokens +from .material import store_mesh_material_reference, store_obj_material_data from .numpy import convert_vec3f_array from .ros_package import resolve_ros_package_paths @@ -94,14 +95,38 @@ def convert_stl(prim: Usd.Prim, input_path: pathlib.Path, data: ConversionData) return usd_mesh -def _convert_single_obj(prim: Usd.Prim, input_path: pathlib.Path, reader: tinyobjloader.ObjReader) -> UsdGeom.Mesh: +def _convert_single_obj( + prim: Usd.Prim, + input_path: pathlib.Path, + reader: tinyobjloader.ObjReader, + data: ConversionData, +) -> UsdGeom.Mesh: """ Convert a single OBJ mesh to a USD mesh. + + Args: + prim: The prim to convert the mesh to. + input_path: The path to the OBJ file. + reader: The tinyobjloader reader. + materials_prims: The dictionary of material names and their prims. + data: The conversion data. + + Returns: + The USD mesh. """ shapes = reader.GetShapes() attrib = reader.GetAttrib() + materials = reader.GetMaterials() + + # This method only deals with a single mesh, so it only considers the first mesh. obj_mesh = shapes[0].mesh + # Material references are identified by the ID assigned to each face of the mesh. + # This will be a common id for each mesh, so we'll take the first one. + material_id = obj_mesh.material_ids[0] + + material_name = materials[material_id].name if material_id >= 0 else None + vertices = attrib.vertices face_vertex_counts = obj_mesh.num_face_vertices face_vertex_indices = obj_mesh.vertex_indices() @@ -133,6 +158,12 @@ def _convert_single_obj(prim: Usd.Prim, input_path: pathlib.Path, reader: tinyob ) if not usd_mesh: Tf.Warn(f'Failed to convert mesh "{prim.GetPath()}" from {input_path}') + + # If the mesh has a material, stores the material name for the mesh. + # Material binding is done on the Geometry layer, so no binding is done at this stage. + if material_name: + store_mesh_material_reference(input_path, usd_mesh.GetPrim().GetName(), material_name, data) + return usd_mesh @@ -142,14 +173,19 @@ def convert_obj(prim: Usd.Prim, input_path: pathlib.Path, data: ConversionData) Tf.Warn(f'Invalid input_path: "{input_path}" could not be parsed. {reader.Error()}') return None + # Store the material data from the OBJ file. + store_obj_material_data(input_path, reader, data) + shapes = reader.GetShapes() if len(shapes) == 0: Tf.Warn(f'Invalid input_path: "{input_path}" contains no meshes') return None elif len(shapes) == 1: - return _convert_single_obj(prim, input_path, reader) + # If there is only one shape, convert the single shape. + return _convert_single_obj(prim, input_path, reader, data) attrib = reader.GetAttrib() + materials = reader.GetMaterials() names = [] for shape in shapes: @@ -160,6 +196,8 @@ def convert_obj(prim: Usd.Prim, input_path: pathlib.Path, data: ConversionData) for shape, name, safe_name in zip(shapes, names, safe_names): obj_mesh = shape.mesh face_vertex_counts = obj_mesh.num_face_vertices + material_ids = obj_mesh.material_ids[0] + material = materials[material_ids] if material_ids >= 0 else None # Get indices directly as arrays vertex_indices_in_shape = np.array(obj_mesh.vertex_indices(), dtype=np.int32) @@ -216,6 +254,11 @@ def convert_obj(prim: Usd.Prim, input_path: pathlib.Path, data: ConversionData) Tf.Warn(f'Failed to convert mesh "{prim.GetPath()}" from {input_path}') return None + # If the mesh has a material, stores the material name for the mesh. + # Material binding is done on the Geometry layer, so no binding is done at this stage. + if material and material.name: + store_mesh_material_reference(input_path, usd_mesh.GetPrim().GetName(), material.name, data) + if name != safe_name: usdex.core.setDisplayName(usd_mesh.GetPrim(), name) diff --git a/urdf_usd_converter/_impl/ros_package.py b/urdf_usd_converter/_impl/ros_package.py index c996156..e772261 100644 --- a/urdf_usd_converter/_impl/ros_package.py +++ b/urdf_usd_converter/_impl/ros_package.py @@ -22,6 +22,9 @@ def resolve_ros_package_paths(uri: str, data: ConversionData) -> pathlib.Path: Returns: The resolved path. """ + if uri in data.resolved_file_paths: + return data.resolved_file_paths[uri] + if "://" in uri and not uri.startswith("package://"): protocol = uri.partition("://")[0] Tf.Warn(f"'{protocol}' is not supported: {uri}") @@ -45,7 +48,8 @@ def resolve_ros_package_paths(uri: str, data: ConversionData) -> pathlib.Path: urdf_dir = data.urdf_parser.input_file.parent # Convert the path to a relative path based on the urdf file. - return resolved_path if resolved_path.is_absolute() else urdf_dir / resolved_path + data.resolved_file_paths[uri] = resolved_path if resolved_path.is_absolute() else urdf_dir / resolved_path + return data.resolved_file_paths[uri] def search_ros_packages(urdf_parser: URDFParser) -> dict[str]: diff --git a/urdf_usd_converter/_impl/urdf_parser/elements.py b/urdf_usd_converter/_impl/urdf_parser/elements.py index 48b9000..26e993e 100644 --- a/urdf_usd_converter/_impl/urdf_parser/elements.py +++ b/urdf_usd_converter/_impl/urdf_parser/elements.py @@ -107,7 +107,7 @@ class ElementColor(ElementBase): available_tag_names: ClassVar[list[str]] = ["color"] _defaults: ClassVar[dict[str, Any]] = { - "rgba": (0.0, 0.0, 0.0, 0.0), + "rgba": (1.0, 1.0, 1.0, 1.0), } def __init__(self): diff --git a/urdf_usd_converter/_impl/urdf_parser/parser.py b/urdf_usd_converter/_impl/urdf_parser/parser.py index 5202d64..85fdd2c 100644 --- a/urdf_usd_converter/_impl/urdf_parser/parser.py +++ b/urdf_usd_converter/_impl/urdf_parser/parser.py @@ -576,7 +576,7 @@ def _store_materials(self): """ # Global material names are unique, so they are stored as is. for material in self.root_element.materials: - color = material.color.get_with_default("rgba") if material.color else (0.0, 0.0, 0.0, 0.0) + color = material.color.get_with_default("rgba") if material.color else (1.0, 1.0, 1.0, 1.0) texture = material.texture.get_with_default("filename") if material.texture else None self.materials.append((material.name, color, texture))