Exercise: Transforming the Prim Hierarchy#

In this exercise, we will create our first transformation step to turn our converted output into an asset that is easy to reference and to fix the defaultPrim validation error. Let’s look at the tree view of a USD layer produced by obj2usd.

Notice that there are multiple prims under the stage pseudo-root (or root). While our converter output faithfully represents the flat hierarchy from OBJ, it creates two usability issues in OpenUSD:

  • There is no defaultPrim metadata to reference this stage without specifying a target prim.

  • Even with defaultPrim set, there’s no easy way to reference the entire asset since all the prims that make up the asset don’t share a common ancestor prim.

This is why we should add a transformation step to make converted OBJs easier to use out-of-box in OpenUSD. Let’s choose to make this a transformation that always runs as part of our converter because it’s critical to provide good UX for end users.

  1. First, open obj2usd.py

  2. Let’s add a new function to handle this transformation. Copy and paste this code between extract() and transform():

 1def set_default_prim(stage: Usd.Stage):
 2    """Set a default prim to make this stage referenceable
 3
 4    OBJ has no notion of a scene graph hierarchy or a scene root.
 5    This is a mandatory chaser to move all prims under a default prim
 6    to make this asset referenceable.
 7    Args:
 8        stage (Usd.Stage): The stage to modify
 9    """
10
11    # Get the prim in the root namespace that we want to reparent under the default prim.
12    root_prims = stage.GetPseudoRoot().GetChildren()
13    world_prim = UsdGeom.Xform.Define(stage, "/World").GetPrim()
14    stage.SetDefaultPrim(world_prim)
15    editor = Usd.NamespaceEditor(stage)
16    for prim in root_prims:
17        editor.ReparentPrim(prim, world_prim)
18        editor.ApplyEdits()

This function creates a new UsdGeom.Xform prim called “/World” and sets it as the defaultPrim. It then parents all of the other prims in the root namespace to “/World” using Usd.NamespaceEditor so that they all share a common ancestor and can be referenced together.

This won’t do anything until we call the new function in transform().

  1. Copy and paste this code into transform():

1set_default_prim(stage)
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 set_default_prim(stage: Usd.Stage):
 90    """Set a default prim to make this stage referenceable
 91
 92    OBJ has no notion of a scene graph hierarchy or a scene root.
 93    This is a mandatory chaser to move all prims under a default prim
 94    to make this asset referenceable.
 95    Args:
 96        stage (Usd.Stage): The stage to modify
 97    """
 98
 99    # Get the prim in the root namespace that we want to reparent under the default prim.
100    root_prims = stage.GetPseudoRoot().GetChildren()
101    world_prim = UsdGeom.Xform.Define(stage, "/World").GetPrim()
102    stage.SetDefaultPrim(world_prim)
103    editor = Usd.NamespaceEditor(stage)
104    for prim in root_prims:
105        editor.ReparentPrim(prim, world_prim)
106        editor.ApplyEdits()
107
108
109def transform(stage: Usd.Stage, args: argparse.Namespace):
110    logger.info("Executing transformation phase...")
111    set_default_prim(stage)
112
113
114def main(args: argparse.Namespace):
115    # Extract the .obj
116    stage: Usd.Stage = extract(args.input, args.output)
117    # Transformations to be applied to the scene hierarchy
118    transform(stage, args)
119    # Save the Stage after editing
120    stage.Save()
121
122# ^^^^^^^^^^^^^^^^^^^^
123# ADD CODE ABOVE HERE
124
125
126if __name__ == "__main__":
127    logging.basicConfig(level=logging.DEBUG)
128    parser = argparse.ArgumentParser(
129        "obj2usd", description="An OBJ to USD converter script."
130    )
131    parser.add_argument("input", help="Input OBJ file", type=Path)
132    parser.add_argument("-o", "--output", help="Specify an output USD file", type=Path)
133    export_opts = parser.add_argument_group("Export Options")
134    export_opts.add_argument(
135        "-u",
136        "--up-axis",
137        help="Specify the up axis for the exported USD stage.",
138        type=UpAxis,
139        choices=list(UpAxis),
140        default=UpAxis.Y,
141    )
142
143    args = parser.parse_args()
144    if args.output is None:
145        args.output = args.input.parent / f"{args.input.stem}.usda"
146
147    logger.info(f"Converting {args.input}...")
148    main(args)
149    logger.info(f"Converted results output as: {args.output}.")
150    logger.info(f"Done.")
  1. Save the file and 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
  1. Open the output USD stage with usdview to see the result:

Windows:

.\scripts\usdview.bat .\data_exchange\shapes.usda

Linux:

./scripts/usdview.sh ./data_exchange/shapes.usda

You should now see in the usdview tree view that only “World” is parented under root (pseudo-root) and all of the mesh and material prims are parented under “World”.

  1. Run usdchecker to validate that defaultPrim metadata is now set:

Windows:

.\scripts\usdchecker.bat .\data_exchange\shapes.usda

Linux:

./scripts/usdchecker.sh ./data_exchange/shapes.usda

Congratulations! No more errors reported. You’ve now created a fully compliant OpenUSD asset.