Exercise: Transforming the Prim Hierarchy#

In this exercise, we will create our first transformation step to turn our converted output into a 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 reference 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.