Exercise: Asset Validation and Testing#
OpenUSD is incredibly powerful and flexible. As you develop your data exchange implementation, you can test it against different test assets and also use validators like usdchecker to make sure that your implementation is authoring valid and compliant OpenUSD data. In this exercise, we will use usdchecker to find any issues with an asset output by our obj2usd
converter and improve our code accordingly.
Let’s get a fresh conversion from
obj2usd
to validate. Execute the script by running the following in the terminal.
Windows:
python .\data_exchange\obj2usd.py .\data_exchange\shapes.obj
Linux:
python ./data_exchange/obj2usd.py ./data_exchange/shapes.obj
Now, let’s run usdchecker to see what issues we find with our asset.
Windows:
.\scripts\usdchecker.bat .\data_exchange\shapes.usda
Linux:
./scripts/usdchecker.sh ./data_exchange/shapes.usda
usdchecker reports three errors for our asset, all related to stage metadata:
Our asset is missing metadata specifying the upAxis
and units type for linear units. We should fix these now in our converter, as this was an oversight in the extraction phase. The missing defaultPrim
metadata is better handled during the transformation phase, but it’s good that usdchecker is flagging it for us.
Open
obj2usd.py
.Let’s set the
upAxis
andmetersPerUnit
stage metadata to fix the errors we encountered. Copy and paste this code inextract()
after we definestage
.
1UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y)
2# Assume linear units as meters.
3UsdGeom.SetStageMetersPerUnit(stage, UsdGeom.LinearUnits.meters)
The OBJ specification states that the Y axis is the upAxis
in OBJ files, so we can map that directly to OpenUSD. For linear units, OBJ is unitless. For our converter, we’ll choose meters as a sensible default. Note that the API for this geometric stage metadata is found in UsdGeom
, not Usd.Stage
where you might first think to look for it.
Click to reveal our Python code up to this point.
1import argparse
2import logging
3import math
4from enum import Enum
5from pathlib import Path
6
7import assimp_py
8from pxr import Gf, Sdf, Tf, Usd, UsdGeom, UsdShade
9
10logger = logging.getLogger("obj2usd")
11
12
13class UpAxis(Enum):
14 Y = UsdGeom.Tokens.y
15 Z = UsdGeom.Tokens.z
16
17 def __str__(self):
18 return self.value
19
20# ADD CODE BELOW HERE
21# vvvvvvvvvvvvvvvvvvv
22
23def extract(input_file: Path, output_file: Path) -> Usd.Stage:
24 logger.info("Executing extraction phase...")
25 process_flags = 0
26 # Load the obj using Assimp
27 scene = assimp_py.ImportFile(str(input_file), process_flags)
28 # Define the stage where the output will go
29 stage: Usd.Stage = Usd.Stage.CreateNew(str(output_file))
30 UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y)
31 # Assume linear units as meters.
32 UsdGeom.SetStageMetersPerUnit(stage, UsdGeom.LinearUnits.meters)
33
34 for mesh in scene.meshes:
35 # Replace any invalid characters with underscores.
36 sanitized_mesh_name = Tf.MakeValidIdentifier(mesh.name)
37 usd_mesh = UsdGeom.Mesh.Define(stage, f"/{sanitized_mesh_name}")
38 # You can use the Vt APIs here instead of Python lists.
39 # Especially keep this in mind for C++ implementations.
40 face_vertex_counts = []
41 face_vertex_indices = []
42 for indices in mesh.indices:
43 # Convert the indices to a flat list
44 face_vertex_indices.extend(indices)
45 # Append the number of vertices for each face
46 face_vertex_counts.append(len(indices))
47
48 usd_mesh.CreatePointsAttr(mesh.vertices)
49 usd_mesh.CreateFaceVertexCountsAttr().Set(face_vertex_counts)
50 usd_mesh.CreateFaceVertexIndicesAttr().Set(face_vertex_indices)
51 # Treat the mesh as a polygonal mesh and not a subdivision surface.
52 # Respect the normals or lack of normals from OBJ.
53 usd_mesh.CreateSubdivisionSchemeAttr(UsdGeom.Tokens.none)
54 if mesh.normals:
55 usd_mesh.CreateNormalsAttr(mesh.normals)
56
57 # Get the mesh's material by index
58 # scene.materials is a dictionary consisting of assimp material properties
59 mtl = scene.materials[mesh.material_index]
60 if not mtl:
61 continue
62 sanitized_mat_name = Tf.MakeValidIdentifier(mtl["NAME"])
63 material_path = Sdf.Path(f"/{sanitized_mat_name}")
64 # Create the material prim
65 material: UsdShade.Material = UsdShade.Material.Define(stage, material_path)
66 # Create a UsdPreviewSurface Shader prim.
67 shader: UsdShade.Shader = UsdShade.Shader.Define(stage, material_path.AppendChild("Shader"))
68 shader.CreateIdAttr("UsdPreviewSurface")
69 # Connect shader surface output as an output for the material graph.
70 material.CreateSurfaceOutput().ConnectToSource(shader.ConnectableAPI(), UsdShade.Tokens.surface)
71 # Get colors
72 diffuse_color = mtl["COLOR_DIFFUSE"]
73 emissive_color = mtl["COLOR_EMISSIVE"]
74 specular_color = mtl["COLOR_SPECULAR"]
75 # Convert specular shininess to roughness.
76 roughness = 1 - math.sqrt(mtl["SHININESS"] / 1000.0)
77
78 shader.CreateInput("useSpecularWorkflow", Sdf.ValueTypeNames.Int).Set(1)
79 shader.CreateInput("diffuseColor", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(diffuse_color))
80 shader.CreateInput("emissiveColor", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(emissive_color))
81 shader.CreateInput("specularColor", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(specular_color))
82 shader.CreateInput("roughness", Sdf.ValueTypeNames.Float).Set(roughness)
83 binding_api = UsdShade.MaterialBindingAPI.Apply(usd_mesh.GetPrim())
84 binding_api.Bind(material)
85
86 return stage
87
88
89def transform(stage: Usd.Stage, args: argparse.Namespace):
90 logger.info("Executing transformation phase...")
91
92
93def main(args: argparse.Namespace):
94 # Extract the .obj
95 stage: Usd.Stage = extract(args.input, args.output)
96 # Transformations to be applied to the scene hierarchy
97 transform(stage, args)
98 # Save the Stage after editing
99 stage.Save()
100
101# ^^^^^^^^^^^^^^^^^^^^
102# ADD CODE ABOVE HERE
103
104
105if __name__ == "__main__":
106 logging.basicConfig(level=logging.DEBUG)
107 parser = argparse.ArgumentParser(
108 "obj2usd", description="An OBJ to USD converter script."
109 )
110 parser.add_argument("input", help="Input OBJ file", type=Path)
111 parser.add_argument("-o", "--output", help="Specify an output USD file", type=Path)
112 export_opts = parser.add_argument_group("Export Options")
113 export_opts.add_argument(
114 "-u",
115 "--up-axis",
116 help="Specify the up axis for the exported USD stage.",
117 type=UpAxis,
118 choices=list(UpAxis),
119 default=UpAxis.Y,
120 )
121
122 args = parser.parse_args()
123 if args.output is None:
124 args.output = args.input.parent / f"{args.input.stem}.usda"
125
126 logger.info(f"Converting {args.input}...")
127 main(args)
128 logger.info(f"Converted results output as: {args.output}.")
129 logger.info(f"Done.")
Run the script again to generate a fixed asset.
Windows:
python .\data_exchange\obj2usd.py .\data_exchange\shapes.obj
Linux:
python ./data_exchange/obj2usd.py ./data_exchange/shapes.obj
Let’s see what usdchecker reports now. Run usdchecker.
Windows:
.\scripts\usdchecker.bat .\data_exchange\shapes.usda
Linux:
./scripts/usdchecker.sh ./data_exchange/shapes.usda
We are down to one error.
We successfully fixed both the upAxis
and metersPerUnit
errors in our asset by updating our obj2usd
code. We’ll fix the defaultPrim
issue in the next exercise when we dive into the transformation phase.