Minimal Placeable Visual#

Property

Value

Version

1.0.0

Dependency

OpenUSD

Description#

The minimal placeable visual feature comprises a list of requirements that enable the digital representation of a real world object to be visualized in a broad range of applications.

It additionally provides a list of requirements to ensure that the scale, units and placement of the object may be correctly represented, so that the object can be placed and aggregated with other objects in a scene.

../_images/minimal_placeable_visual.gif

Fig 1: Visualization of a simple asset in NVIDIA Omniverse to demonstrate the capabilities of the minimal placeable visual feature.#

For details, see Runtime Testing.

Requirements#

Runtime Testing#

To verify this feature, the following runtime testing requirements should be met.

Runtime Testing Requirements#

Category

Requirement ID

Description

Loading and Display

AA.002

Not strictly required as a part of this feature, but a good baseline test to have in all runtime testing. The asset loads into a runtime environment without errors or warnings related to unsupported schemas or invalid data. Asset is loaded into stage, no framing camera framing is performed

Loading and Display

VG.001, VG.002

The asset’s geometry is visible and can be automatically framed by the viewport’s perspective camera, indicating a valid and computable bounding box. Camera is automatically framed

Coordinate System and Scale

UN.001, UN.006

When referenced into a stage with upAxis set to ‘Z’, the asset appears with the correct “up” direction. “up arrow” gizmo is positioned next to the asset

Coordinate System and Scale

UN.002, UN.007

When referenced into a stage with metersPerUnit set to 1.0, the asset appears at its correct, real-world physical scale (e.g., a 2-meter tall object is 2 units high in the scene). Scale reference asset (human silhouette) is positioned next to the asset

Transformation and Pivot Point

HI.001, HI.003

The asset can be positioned, rotated and scaled by setting the translate, rotate and scale attributes on the root prim. A grid is visible. Asset is translated to 10 units in x via the xformCommonApi. The asset and the stage origin are framed by the camera. Asset is translated to 10 units in y via the xformCommonApi. The asset and the stage origin are framed by the camera. Asset is translated to 10 units in z via the xformCommonApi. The asset and the stage origin are framed by the camera.

Transformation and Pivot Point

VG.025

The transformation gizmo (manipulator) for the asset’s root prim appears at the logical pivot point as defined in the specification (e.g., at the center of the base for an object that sits on the ground). A “Pivot gizmo” prim is positioned at the computed transform of the assets root prim.

Transformation and Pivot Point

VG.025

An asset designed to articulate (e.g., a hinged door) rotates correctly around the specified pivot point of the moving part. Asset rotates in 10 degree steps in x via the xformCommonApi. The asset and the stage origin are framed by the camera.

Geometry and Shading

VG.MESH.001, VG.014

The asset’s surfaces render correctly without unintended holes or gaps. Light spins around the object

Geometry and Shading

VG.028

With back-face culling enabled in the viewport, surfaces are not culled incorrectly, verifying proper normal orientation. Camera spins around the object

Geometry and Shading

VG.027, VG.028, VG.029

Surface shading appears smooth (no faceting) for curved surfaces and shows hard edges where intended, verifying that normals are authored correctly. Camera spins around the object

Composition and Metadata

HI.004

The asset can be successfully referenced into a parent aggregation scene without the need to specify the prim path (default prim).

Python Testing Script#

We supply a simple test script to run most of the tests so you can verify the feature in your own runtime with your own assets. A test asset is available for download here, as well as the resulting USD stage here. Note that selection and hierarchy tests are not included in the test script.

# Example
pip install usd-core
python test-minimal_placeable_visual.py toolbox.usdc minimal_placeable_visual-runtime_test-toolbox
test-minimal_placeable_visual.py#
   1from pxr import Usd, Tf, UsdUtils, UsdGeom, Gf, Sdf, UsdLux, UsdShade
   2
   3from math import atan, radians as rad, floor, ceil
   4import argparse
   5import os
   6import sys
   7from typing import Optional, Tuple
   8
   9import logging
  10logging.basicConfig(level=logging.INFO)
  11
  12logger = logging.getLogger(__name__)
  13
  14
  15class USDLoadingError(Exception):
  16    """
  17    Exception raised when USD asset loading encounters warnings or errors.
  18    
  19    Attributes:
  20        asset_path: Path to the asset that failed to load cleanly
  21        diagnostics: List of diagnostic messages from USD
  22        fatal_error: Optional fatal exception that occurred during loading
  23    """
  24    
  25    def __init__(self, asset_path: str, diagnostics: list = None, fatal_error: Exception = None):
  26        self.asset_path = asset_path
  27        self.diagnostics = diagnostics or []
  28        self.fatal_error = fatal_error
  29        
  30        # Build error message
  31        message_parts = [f"USD asset failed to load cleanly: {asset_path}"]
  32        
  33        if fatal_error:
  34            message_parts.append(f"Fatal error: {fatal_error}")
  35        
  36        if diagnostics:
  37            message_parts.append(f"Diagnostics ({len(diagnostics)} issues):")
  38            for i, item in enumerate(diagnostics, 1):
  39                message_parts.append(f"  {i}. {item.diagnosticCodeString}: {item.commentary}")
  40        
  41        super().__init__("\n".join(message_parts))
  42
  43
  44# =============================================================================
  45# ANIMATION CONFIGURATION
  46# =============================================================================
  47
  48class AnimationConfig:
  49    """Configuration constants for controlling the animation sequence and timing."""
  50    
  51    # Timeline Settings
  52    FRAME_RATE: int = 60                    # Frames per second
  53    TOTAL_DURATION: int = 720               # Total animation length in frames (12 seconds at 60fps)
  54    
  55    # Asset Movement Animation (traversing bounding box corners)
  56    ASSET_MOVEMENT_ENABLED: bool = True
  57    ASSET_MOVEMENT_START: int = 261          # Start after visualizations complete
  58    ASSET_MOVEMENT_END: int = 340
  59    ASSET_MOVEMENT_KEYFRAME_INTERVAL: int = 5  # Frames between keyframes
  60    
  61    # Light Spinning Animation (renderer-specific lighting)
  62    LIGHTS_ENABLED: bool = True
  63    LIGHTS_SPIN_START: int = 181
  64    LIGHTS_SPIN_END: int = 260
  65    
  66    # Renderer-specific light settings
  67    LIGHTS_OMNIVERSE_ENABLED: bool = True
  68    LIGHTS_STORM_ENABLED: bool = False
  69    LIGHTS_OMNIVERSE_INTENSITY: float = 500.0
  70    LIGHTS_STORM_INTENSITY: float = 1.0
  71    LIGHTS_ANGLE: float = 15.0
  72    
  73    # Asset Spinning Animations
  74    ASSET_SPINNING_ENABLED: bool = True
  75    
  76    # First spin (Z-axis)
  77    ASSET_SPIN_Z_START: int = 21
  78    ASSET_SPIN_Z_END: int = 100
  79    ASSET_SPIN_Z_AXIS: str = 'z'
  80    
  81    # Second spin (X-axis)  
  82    ASSET_SPIN_X_START: int = 101
  83    ASSET_SPIN_X_END: int = 180
  84    ASSET_SPIN_X_AXIS: str = 'x'
  85    
  86    # Additional spin phases (can be enabled by changing start/end times)
  87    ASSET_SPIN_Y_START: int = 561
  88    ASSET_SPIN_Y_END: int = 561  # Disabled by default (start == end)
  89    ASSET_SPIN_Y_AXIS: str = 'y'
  90    
  91    # Visualization Settings
  92    ORIGIN_VISUALIZATION_ENABLED: bool = True
  93    ORIGIN_VIZ_SIZE_SCALE: float = 1.0
  94    ORIGIN_VIZ_START_FRAME: int = 1         # Frame when origin visualization starts
  95    ORIGIN_VIZ_END_FRAME: int = 10          # Frame when origin visualization ends
  96    
  97    SIZE_REFERENCE_ENABLED: bool = True
  98    SIZE_REF_GRID_SPACING: float = 0.1      # Grid spacing in meters
  99    SIZE_REF_START_FRAME: int = 11          # Frame when grid visualization starts
 100    SIZE_REF_END_FRAME: int = 20            # Frame when grid visualization ends
 101    
 102    # Camera Settings
 103    CAMERA_ENABLED: bool = True
 104    CAMERA_FOV: float = 45.0
 105    CAMERA_FRAME_FIT: float = 1.5
 106    
 107    # Material Override Settings
 108    MATERIAL_OVERRIDE_ENABLED: bool = True
 109    MATERIAL_DIFFUSE_COLOR: tuple = (0.18, 0.18, 0.18)     # Light gray diffuse
 110    MATERIAL_METALLIC: float = 0.0                       # Non-metallic
 111    MATERIAL_ROUGHNESS: float = 0.4                      # Moderate roughness
 112    
 113    @classmethod
 114    def get_animation_phases(cls) -> list[dict]:
 115        """
 116        Get a list of all animation phases in chronological order.
 117        Useful for debugging and understanding the animation sequence.
 118        """
 119        phases = []
 120        
 121        if cls.ASSET_MOVEMENT_ENABLED:
 122            phases.append({
 123                'name': 'Asset Movement',
 124                'start': cls.ASSET_MOVEMENT_START,
 125                'end': cls.ASSET_MOVEMENT_END,
 126                'description': 'Asset moves through bounding box corners'
 127            })
 128        
 129        if cls.LIGHTS_ENABLED:
 130            # Build description based on enabled renderers
 131            enabled_renderers = []
 132            if cls.LIGHTS_OMNIVERSE_ENABLED:
 133                enabled_renderers.append(f"Omniverse ({cls.LIGHTS_OMNIVERSE_INTENSITY})")
 134            if cls.LIGHTS_STORM_ENABLED:
 135                enabled_renderers.append(f"Storm ({cls.LIGHTS_STORM_INTENSITY})")
 136            
 137            renderer_desc = ", ".join(enabled_renderers) if enabled_renderers else "No renderers"
 138            phases.append({
 139                'name': 'Light Spinning',
 140                'start': cls.LIGHTS_SPIN_START,
 141                'end': cls.LIGHTS_SPIN_END,
 142                'description': f'Renderer-specific lights rotate: {renderer_desc}'
 143            })
 144        
 145        if cls.ASSET_SPINNING_ENABLED:
 146            if cls.ASSET_SPIN_Z_START < cls.ASSET_SPIN_Z_END:
 147                phases.append({
 148                    'name': f'Asset Spin ({cls.ASSET_SPIN_Z_AXIS.upper()}-axis)',
 149                    'start': cls.ASSET_SPIN_Z_START,
 150                    'end': cls.ASSET_SPIN_Z_END,
 151                    'description': f'Asset rotates 360° around {cls.ASSET_SPIN_Z_AXIS.upper()}-axis'
 152                })
 153            
 154            if cls.ASSET_SPIN_X_START < cls.ASSET_SPIN_X_END:
 155                phases.append({
 156                    'name': f'Asset Spin ({cls.ASSET_SPIN_X_AXIS.upper()}-axis)',
 157                    'start': cls.ASSET_SPIN_X_START,
 158                    'end': cls.ASSET_SPIN_X_END,
 159                    'description': f'Asset rotates 360° around {cls.ASSET_SPIN_X_AXIS.upper()}-axis'
 160                })
 161                
 162            if cls.ASSET_SPIN_Y_START < cls.ASSET_SPIN_Y_END:
 163                phases.append({
 164                    'name': f'Asset Spin ({cls.ASSET_SPIN_Y_AXIS.upper()}-axis)',
 165                    'start': cls.ASSET_SPIN_Y_START,
 166                    'end': cls.ASSET_SPIN_Y_END,
 167                    'description': f'Asset rotates 360° around {cls.ASSET_SPIN_Y_AXIS.upper()}-axis'
 168                })
 169        
 170        # Add visualization phases
 171        if cls.ORIGIN_VISUALIZATION_ENABLED:
 172            duration = cls.ORIGIN_VIZ_END_FRAME - cls.ORIGIN_VIZ_START_FRAME + 1
 173            phases.append({
 174                'name': 'Origin Visualization',
 175                'start': cls.ORIGIN_VIZ_START_FRAME,
 176                'end': cls.ORIGIN_VIZ_END_FRAME,
 177                'description': f'Coordinate axes visualization displayed for {duration} frames'
 178            })
 179        
 180        if cls.SIZE_REFERENCE_ENABLED:
 181            duration = cls.SIZE_REF_END_FRAME - cls.SIZE_REF_START_FRAME + 1
 182            phases.append({
 183                'name': 'Size Reference Grid',
 184                'start': cls.SIZE_REF_START_FRAME,
 185                'end': cls.SIZE_REF_END_FRAME,
 186                'description': f'Measurement grid displayed for {duration} frames'
 187            })
 188        
 189        # Add material override info (not time-based but relevant)
 190        if cls.MATERIAL_OVERRIDE_ENABLED:
 191            phases.append({
 192                'name': 'Material Override',
 193                'start': 0,
 194                'end': cls.TOTAL_DURATION,
 195                'description': f'USDPreview Surface applied (strongerThanDescendants)'
 196            })
 197        
 198        # Sort by start time
 199        phases.sort(key=lambda x: x['start'])
 200        return phases
 201    
 202    @classmethod
 203    def validate_config(cls) -> bool:
 204        """Validate that the configuration makes sense."""
 205        phases = cls.get_animation_phases()
 206        
 207        # Check for overlapping phases
 208        for i in range(len(phases) - 1):
 209            current_end = phases[i]['end']
 210            next_start = phases[i + 1]['start']
 211            if current_end > next_start:
 212                logger.warning(f"Animation phases overlap: '{phases[i]['name']}' ends at frame {current_end}, "
 213                             f"but '{phases[i + 1]['name']}' starts at frame {next_start}")
 214        
 215        # Check if any animation goes beyond total duration
 216        for phase in phases:
 217            if phase['end'] > cls.TOTAL_DURATION:
 218                logger.warning(f"Animation phase '{phase['name']}' ends at frame {phase['end']}, "
 219                             f"which is beyond total duration of {cls.TOTAL_DURATION} frames")
 220        
 221        # Validate lighting configuration
 222        if cls.LIGHTS_ENABLED and not (cls.LIGHTS_OMNIVERSE_ENABLED or cls.LIGHTS_STORM_ENABLED):
 223            logger.warning("Lights are enabled but no renderer-specific lights are enabled - scene may be dark")
 224        
 225        return True
 226
 227
 228# =============================================================================
 229# CORE FUNCTIONS
 230# =============================================================================
 231
 232def loads_without_warnings_or_errors(asset_path: str) -> None:
 233    """
 234    Test whether a USD asset loads without emitting warnings or errors.
 235    Uses USD's CoalescingDiagnosticDelegate to capture all diagnostic
 236    messages (warnings and errors) that occur during stage loading.
 237    
 238    Runtime Testing: AA.002 - "The asset loads into a runtime environment without 
 239    errors or warnings related to unsupported schemas or invalid data."
 240    Note: This is not strictly required as part of the Minimal Placeable Visual 
 241    feature, but serves as a good baseline test for all runtime testing.
 242
 243    Note that USDImaging diagnostics will not be captured when USDImaging
 244    is not in use, for example when using USD without a renderer.
 245    
 246    Args:
 247        asset_path (str): Path to the USD asset file to be tested.
 248    
 249    Returns:
 250        None: Function returns successfully if asset loads cleanly.
 251    
 252    Raises:
 253        USDLoadingError: Raised when any warnings, errors, or fatal exceptions 
 254            occur during stage loading. The exception contains detailed information
 255            about all diagnostic messages encountered.
 256        FileNotFoundError: If the asset file does not exist.
 257    
 258    Example:
 259        >>> try:
 260        ...     loads_without_warnings_or_errors("./test_asset.usda")
 261        ...     print("Asset passed validation")
 262        ... except USDLoadingError as e:
 263        ...     print(f"Asset failed validation: {e}")
 264        ... except FileNotFoundError:
 265        ...     print("Asset file not found")
 266    
 267    Note:
 268        This function raises exceptions with detailed diagnostic information
 269        instead of logging and returning boolean values, making it easier to
 270        integrate into automated validation workflows.
 271    """
 272
 273    delegate = UsdUtils.CoalescingDiagnosticDelegate()
 274    
 275    try:
 276        stage = Usd.Stage.Open(asset_path)
 277    except Exception as e:
 278        # Collect any diagnostics that might have been generated before the fatal error
 279        items = delegate.TakeUncoalescedDiagnostics()
 280        raise USDLoadingError(asset_path, diagnostics=items, fatal_error=e)
 281
 282    items = delegate.TakeUncoalescedDiagnostics()
 283
 284    if items:
 285        # Raise exception with all collected diagnostics
 286        raise USDLoadingError(asset_path, diagnostics=items)
 287
 288    logger.info(f"Stage loaded without warnings or errors: {asset_path}")
 289    return None
 290
 291
 292
 293
 294class Camera:
 295    """
 296    A camera helper class for framing and positioning a USD camera to view scene geometry.
 297    
 298    This class automatically positions and orients a camera to frame a given bounding box,
 299    similar to viewport "frame all" functionality in 3D applications.
 300
 301    Logic is copied from usdview's freeCamera module:
 302    https://github.com/PixarAnimationStudios/OpenUSD/blob/dev/pxr/usdImaging/usdviewq/freeCamera.py
 303
 304    
 305    TODO: Clipping planes
 306    """
 307    
 308    # DEFAULTS
 309    CAMERA_PATH: str = '/CAMERA'
 310    DEFAULT_NEAR: float = 0.1
 311    DEFAULT_FAR: float = 2000000
 312    
 313    def __init__(self, camera_prim: UsdGeom.Camera, isZUp: bool = False) -> None:
 314        """
 315        Initialize the camera with rotation and coordinate system setup.
 316        
 317        Args:
 318            camera_prim: USD camera prim to configure
 319            isZUp (bool): Whether the stage uses Z-up coordinate system
 320        """
 321        # Initial rotation values for camera orientation
 322        self._rotPsi: float = 0
 323        self._rotPhi: float = 22.5  # Slight angle to avoid dead-on view
 324        self._rotTheta: float = 22.5
 325
 326        self.camera_prim: UsdGeom.Camera = camera_prim
 327        self._camera: Gf.Camera = Gf.Camera()
 328
 329        # Set up coordinate system transformation matrices
 330        if isZUp:
 331            self._YZUpMatrix: Gf.Matrix4d = Gf.Matrix4d().SetRotate(
 332                Gf.Rotation(Gf.Vec3d.XAxis(), -90))
 333            self._YZUpInvMatrix: Gf.Matrix4d = self._YZUpMatrix.GetInverse()
 334        else:
 335            self._YZUpMatrix: Gf.Matrix4d = Gf.Matrix4d(1.0)
 336            self._YZUpInvMatrix: Gf.Matrix4d = Gf.Matrix4d(1.0)
 337
 338    def frame(self, bbox: Gf.BBox3d) -> Gf.Matrix4d:
 339        """
 340        Frame the camera to view the given bounding box.
 341        
 342        Args:
 343            bbox: A Gf.BBox3d representing the geometry to frame
 344            
 345        Returns:
 346            Gf.Matrix4d: The camera transform matrix
 347        """
 348        # Calculate framing parameters
 349        self.center: Gf.Vec3d = bbox.ComputeCentroid()
 350        selRange: Gf.Range3d = bbox.ComputeAlignedRange()
 351        self._selSize: float = max(*selRange.GetSize())
 352
 353        # Calculate camera distance based on field of view
 354        fov = AnimationConfig.CAMERA_FOV
 355        frame_fit = AnimationConfig.CAMERA_FRAME_FIT
 356        halfFov: float = fov * 0.5 or 0.5  # Prevent division by zero
 357        lengthToFit: float = self._selSize * frame_fit * 0.5
 358        self.dist: float = lengthToFit / atan(rad(halfFov))
 359
 360        
 361        # Very small objects that fill out their bounding boxes (like cubes)
 362        # may well pierce our 1 unit default near-clipping plane. Make sure
 363        # that doesn't happen.
 364        if self.dist < Camera.DEFAULT_NEAR + self._selSize * 0.5:
 365            self.dist = Camera.DEFAULT_NEAR + lengthToFit
 366
 367        self._camera.clippingRange = Gf.Range1f(self.DEFAULT_NEAR, self.DEFAULT_FAR)
 368
 369        
 370        # Apply the calculated transform
 371        return self._pushToCameraTransform()
 372
 373    
 374    def _pushToCameraTransform(self) -> Gf.Matrix4d:
 375        """
 376        Updates the camera's transform matrix, that is, the matrix that brings
 377        the camera to the origin, with the camera view pointing down:
 378           +Y if this is a Zup camera, or
 379           -Z if this is a Yup camera .
 380           
 381        Returns:
 382            Gf.Matrix4d: The camera transform matrix
 383        """
 384        
 385        def RotMatrix(vec: Gf.Vec3d, angle: float) -> Gf.Matrix4d:
 386            return Gf.Matrix4d(1.0).SetRotate(Gf.Rotation(vec, angle))
 387        
 388        # Build the camera transform matrix
 389        self._camera.transform = (
 390            Gf.Matrix4d().SetTranslate(Gf.Vec3d.ZAxis() * self.dist) *
 391            RotMatrix(Gf.Vec3d.ZAxis(), -self._rotPsi) *
 392            RotMatrix(Gf.Vec3d.XAxis(), -self._rotPhi) *
 393            RotMatrix(Gf.Vec3d.YAxis(), -self._rotTheta) *
 394            self._YZUpInvMatrix *
 395            Gf.Matrix4d().SetTranslate(self.center))
 396
 397        # Set camera properties        
 398        self._camera.SetPerspectiveFromAspectRatioAndFieldOfView(
 399            self._camera.aspectRatio, AnimationConfig.CAMERA_FOV, Gf.Camera.FOVVertical
 400        )
 401
 402        # Set camera properties
 403        self._camera.focusDistance = self.dist
 404        self.camera_prim.SetFromCamera(self._camera)
 405
 406        return self._camera.transform
 407
 408
 409def _compute_stage_bounding_box(stage: Usd.Stage, default_prim_only: bool = True) -> Gf.BBox3d:
 410    """
 411    Compute the bounding box of boundable prims in the stage.
 412    
 413    Args:
 414        stage: The USD stage to compute bounding box for
 415        default_prim_only: If True, only traverse the default prim hierarchy.
 416                          If False, traverse the entire stage.
 417    
 418    Returns:
 419        Gf.BBox3d: Combined bounding box of all boundable prims
 420
 421    Raises:
 422        ValueError: If no default prim is found and default_prim_only is True
 423    """
 424    bbox_cache: UsdGeom.BBoxCache = UsdGeom.BBoxCache(Usd.TimeCode.Default(), ['default', 'render', 'proxy'])
 425    total_bbox: Gf.BBox3d = Gf.BBox3d()  
 426
 427    # Determine the traversal approach
 428    if default_prim_only:
 429        root_prim: Usd.Prim = stage.GetDefaultPrim()
 430        if not root_prim.IsValid():
 431            raise ValueError("No default prim found, cannot compute bounding box")
 432        
 433        # Traverse only the default prim and its descendants
 434        base_pred = Usd.PrimDefaultPredicate
 435        inst_pred = Usd.TraverseInstanceProxies(base_pred)
 436
 437        for prim in Usd.PrimRange(root_prim, predicate=inst_pred):
 438            if prim.IsA(UsdGeom.Boundable):
 439                bbox: Gf.BBox3d = bbox_cache.ComputeWorldBound(prim)
 440                total_bbox = Gf.BBox3d.Combine(total_bbox, bbox)
 441    else:
 442        # Traverse the entire stage
 443        for prim in stage.Traverse():
 444            if prim.IsA(UsdGeom.Boundable):
 445                bbox: Gf.BBox3d = bbox_cache.ComputeWorldBound(prim)
 446                total_bbox = Gf.BBox3d.Combine(total_bbox, bbox)
 447
 448    return total_bbox
 449
 450
 451
 452def create_and_frame_camera(stage: Usd.Stage, asset_up_axis: str, asset_bbox: Gf.BBox3d) -> Usd.Stage:
 453    """
 454    Create a camera and frame it to the provided bounding box.
 455
 456    Runtime Testing: VG.001, VG.002 - "The asset's geometry is visible and can be 
 457    automatically framed by the viewport's perspective camera, indicating a valid 
 458    and computable bounding box."
 459
 460    Args:
 461        stage (Usd.Stage): The stage to add the camera to
 462        asset_up_axis: The up axis of the asset stage
 463        asset_bbox: Pre-computed bounding box of the asset
 464    
 465    Returns:
 466        Usd.Stage: The stage containing the framed camera
 467    """    
 468    
 469    # Use the provided bounding box
 470    if asset_bbox is None or asset_bbox.GetRange().IsEmpty():
 471        raise AssertionError("Provided bounding box is empty or None, cannot frame camera")
 472
 473    # Remove existing camera if it exists
 474    existing_prim: Usd.Prim = stage.GetPrimAtPath(Camera.CAMERA_PATH)
 475    if existing_prim.IsValid():
 476        stage.RemovePrim(Camera.CAMERA_PATH)
 477
 478    # Create camera prim
 479    camera_prim: UsdGeom.Camera = UsdGeom.Camera.Define(stage, Camera.CAMERA_PATH)
 480    if not camera_prim.GetPrim().IsValid():
 481        raise RuntimeError(f"Failed to create camera at {Camera.CAMERA_PATH}")
 482
 483    # Configure camera based on provided up axis
 484    isZUp: bool = asset_up_axis == UsdGeom.Tokens.z
 485    logger.info(f"Asset stage is ZUp: {isZUp}")
 486    camera: Camera = Camera(camera_prim, isZUp)
 487    camera_matrix: Gf.Matrix4d = camera.frame(asset_bbox)
 488    logger.info(f"Created camera at {Camera.CAMERA_PATH} with transform: {camera_matrix}")
 489    
 490    
 491    return stage
 492
 493
 494def move_asset(stage: Usd.Stage, asset_bbox: Gf.BBox3d) -> Usd.Stage:
 495    """
 496    Find the default prim of the stage and move the asset a certain distance in each axis over a certain time.
 497    Uses the current edit target layer.
 498
 499    Runtime Testing: HI.001, HI.003 - "The asset can be positioned, rotated and scaled 
 500    by setting the translate, rotate and scale attributes on the root prim." Tests that 
 501    the asset has a single root prim that is Xformable and can be transformed.
 502    """
 503    
 504    if not AnimationConfig.ASSET_MOVEMENT_ENABLED:
 505        logger.info("Asset movement animation disabled, skipping")
 506        return stage
 507
 508    # Find the default prim of the stage
 509    default_prim: Usd.Prim = stage.GetDefaultPrim()
 510    logger.info(f"Default prim: {default_prim} on stage: {stage}")
 511    if default_prim is None:
 512        raise ValueError("Default prim is not set")
 513    
 514    xform_api: UsdGeom.XformCommonAPI = UsdGeom.XformCommonAPI(default_prim)
 515    if not xform_api:
 516        raise ValueError(f"Can not initialize xform common api on the default prim {default_prim} - wrong type ?")
 517
 518    # Use the assets bounding box to determine how much the asset should move
 519    bbox_range: Gf.Range3d = asset_bbox.GetRange()
 520    min_point: Gf.Vec3d = bbox_range.GetMin()
 521    max_point: Gf.Vec3d = bbox_range.GetMax()
 522    
 523    # Generate keyframes dynamically based on configuration
 524    start_frame = AnimationConfig.ASSET_MOVEMENT_START
 525    end_frame = AnimationConfig.ASSET_MOVEMENT_END
 526    interval = AnimationConfig.ASSET_MOVEMENT_KEYFRAME_INTERVAL
 527    
 528    # Define the 8 corners of the bounding box + origin
 529    corners = [
 530        (0, 0, 0),      # Origin
 531        (min_point[0], min_point[1], min_point[2]),      # Bottom-back-left
 532        (max_point[0], min_point[1], min_point[2]),      # Bottom-back-right
 533        (max_point[0], max_point[1], min_point[2]),     # Bottom-front-right
 534        (min_point[0], max_point[1], min_point[2]),     # Bottom-front-left
 535        (min_point[0], min_point[1], max_point[2]),     # Top-back-left
 536        (max_point[0], min_point[1], max_point[2]),     # Top-back-right
 537        (max_point[0], max_point[1], max_point[2]),     # Top-front-right
 538        (min_point[0], max_point[1], max_point[2]),     # Top-front-left
 539        (0, 0, 0)      # Return to origin
 540    ]
 541    
 542    # Create keyframes with configurable timing
 543    keyframes: dict[int, Tuple[float, float, float]] = {}
 544    for i, corner in enumerate(corners):
 545        frame = start_frame + (i * interval)
 546        if frame <= end_frame:
 547            keyframes[frame] = corner
 548        else:
 549            # If we run out of time, put the last keyframe at the end
 550            keyframes[end_frame] = corner
 551            break
 552
 553    for frame, (x, y, z) in keyframes.items():
 554        xform_api.SetTranslate(Gf.Vec3d(x, y, z), frame)
 555
 556    logger.info(f"Created asset movement animation from frame {start_frame} to {end_frame} with {len(keyframes)} keyframes")
 557    return stage
 558
 559
 560def setup_test_stage(asset_path: str, test_stage_path: str) -> Usd.Stage:
 561    """
 562    Create a test stage with proper coordinate system and reference the asset.
 563
 564    Runtime Testing Requirements:
 565    - UN.001, UN.006: Z-up coordinate system for correct "up" direction
 566    - UN.002, UN.007: metersPerUnit=1.0 for correct real-world physical scale  
 567    - HI.004: Asset can be referenced without specifying prim path (default prim)
 568    """
 569    stage: Usd.Stage = Usd.Stage.CreateNew(test_stage_path)
 570
 571    asset_prim: Usd.Prim = stage.DefinePrim('/ASSET', 'Xform')
 572    if not asset_prim.IsValid():
 573        raise RuntimeError(f"Failed to create asset prim at /ASSET")
 574
 575    UsdGeom.SetStageMetersPerUnit(stage, 1.0)  # UN.002: Real-world scale
 576    stage.SetTimeCodesPerSecond(AnimationConfig.FRAME_RATE)
 577    stage.SetStartTimeCode(0)
 578    stage.SetEndTimeCode(AnimationConfig.TOTAL_DURATION)
 579    UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z)  # UN.001: Z-up coordinate system
 580    stage.SetDefaultPrim(asset_prim)
 581
 582    asset_prim.GetPrim().GetReferences().AddReference(asset_path)  # HI.001: Reference without prim path
 583
 584    stage.Save()
 585
 586    return stage
 587
 588
 589def spin_lights(stage: Usd.Stage, asset_up_axis: str) -> Usd.Stage:
 590    """
 591    Create renderer-specific distant lights and spin them in the scene.
 592    Creates separate lights optimized for different renderers (Omniverse, Storm).
 593
 594    Runtime Testing: VG.MESH.001, VG.027 - "The asset's surfaces render correctly 
 595    without unintended holes or gaps." Light rotation reveals geometry issues and 
 596    validates that surface normals are properly authored for correct shading.
 597    """
 598    
 599    if not AnimationConfig.LIGHTS_ENABLED:
 600        logger.info("Light spinning animation disabled, skipping")
 601        return stage
 602    
 603    # Create lights group
 604    lights_prim: UsdGeom.Xform = UsdGeom.Xform.Define(stage, '/LIGHTS')
 605    lights_created = 0
 606    
 607    def add_renderer_light(renderer_name: str, intensity: float) -> UsdLux.DistantLight:
 608        """Helper function to create a renderer-specific distant light."""
 609        path = f'/LIGHTS/{renderer_name}_Light'
 610        light: UsdLux.DistantLight = UsdLux.DistantLight.Define(stage, path)
 611        
 612        # Set intensity and angle
 613        light.CreateIntensityAttr(intensity)
 614        light.CreateAngleAttr(AnimationConfig.LIGHTS_ANGLE)
 615        
 616        # Position light at 45° elevation, 30° azimuth for good asset illumination
 617        light_xform: UsdGeom.XformCommonAPI = UsdGeom.XformCommonAPI(light)
 618        light_xform.SetRotate(Gf.Vec3f(45, 30, 0))
 619        
 620        logger.info(f"Created {renderer_name} light with intensity {intensity}")
 621        return light
 622    
 623    # Create Omniverse-specific light
 624    if AnimationConfig.LIGHTS_OMNIVERSE_ENABLED:
 625        add_renderer_light('Omniverse', AnimationConfig.LIGHTS_OMNIVERSE_INTENSITY)
 626        lights_created += 1
 627    
 628    # Create Storm-specific light  
 629    if AnimationConfig.LIGHTS_STORM_ENABLED:
 630        add_renderer_light('Storm', AnimationConfig.LIGHTS_STORM_INTENSITY)
 631        lights_created += 1
 632    
 633    if lights_created == 0:
 634        logger.warning("No renderer lights enabled - scene may be dark")
 635    else:
 636        logger.info(f"Created {lights_created} renderer-specific light(s)")
 637
 638
 639    # Animate the lights with the xformcommonapi
 640    xform_api: UsdGeom.XformCommonAPI = UsdGeom.XformCommonAPI(lights_prim)
 641    
 642    start_frame = AnimationConfig.LIGHTS_SPIN_START
 643    end_frame = AnimationConfig.LIGHTS_SPIN_END
 644    
 645    isZUp: bool = asset_up_axis == UsdGeom.Tokens.z
 646    if isZUp:
 647        xform_api.SetRotate(Gf.Vec3f(0, 0, 0), time=Usd.TimeCode(start_frame))
 648        xform_api.SetRotate(Gf.Vec3f(0, 0, 360), time=Usd.TimeCode(end_frame))
 649    else:
 650        xform_api.SetRotate(Gf.Vec3f(0, 0, 0), time=Usd.TimeCode(start_frame))
 651        xform_api.SetRotate(Gf.Vec3f(0, 360, 0), time=Usd.TimeCode(end_frame))
 652
 653    stage.Save()
 654    
 655    logger.info(f"Created light spinning animation from frame {start_frame} to {end_frame}")
 656    return stage
 657
 658def spin_asset(stage: Usd.Stage, axis: str = 'z', start_time: int = 300, end_time: int = 480) -> Usd.Stage:
 659    """
 660    Spin the asset in the scene around a specified axis.
 661    Uses the current edit target layer.
 662
 663    Runtime Testing: 
 664    - VG.027, VG.028, VG.029: Verifies proper normal orientation, surface shading,
 665      and correct winding order from all angles as the asset rotates
 666    - VG.025: Tests asset rotation around specified pivot point for articulation
 667    
 668    Args:
 669        stage: The USD stage containing the asset
 670        axis: The axis to spin around ('x', 'y', or 'z')
 671        start_time: The start time code for the animation
 672        end_time: The end time code for the animation
 673    """
 674    
 675    if not AnimationConfig.ASSET_SPINNING_ENABLED:
 676        logger.info("Asset spinning animation disabled, skipping")
 677        return stage
 678        
 679    if start_time >= end_time:
 680        logger.info(f"Asset spin animation on {axis.upper()}-axis disabled (start_time >= end_time)")
 681        return stage
 682    
 683    # Get the asset prim
 684    asset_prim: Usd.Prim = stage.GetPrimAtPath('/ASSET')
 685    if not asset_prim.IsValid():
 686        raise RuntimeError(f"Failed to get asset prim at /ASSET")
 687    
 688    # Get the xform common api
 689    xform_api: UsdGeom.XformCommonAPI = UsdGeom.XformCommonAPI(asset_prim)
 690    
 691    # Set up rotation vectors based on axis
 692    if axis.lower() == 'x':
 693        start_rotation: Gf.Vec3f = Gf.Vec3f(0, 0, 0)
 694        end_rotation: Gf.Vec3f = Gf.Vec3f(360, 0, 0)
 695    elif axis.lower() == 'y':
 696        start_rotation = Gf.Vec3f(0, 0, 0)
 697        end_rotation = Gf.Vec3f(0, 360, 0)
 698    elif axis.lower() == 'z':
 699        start_rotation = Gf.Vec3f(0, 0, 0)
 700        end_rotation = Gf.Vec3f(0, 0, 360)
 701    else:
 702        raise ValueError(f"Invalid axis '{axis}'. Must be 'x', 'y', or 'z'")
 703    
 704    # Set keyframes for rotation
 705    xform_api.SetTranslate(Gf.Vec3d(0, 0, 0), time=Usd.TimeCode(start_time))
 706    xform_api.SetRotate(start_rotation, time=Usd.TimeCode(start_time))
 707    xform_api.SetRotate(end_rotation, time=Usd.TimeCode(end_time))
 708    
 709    logger.info(f"Created asset spinning animation on {axis.upper()}-axis from frame {start_time} to {end_time}")
 710    return stage
 711
 712def create_origin_visualization(stage: Usd.Stage, asset_up_axis: str, asset_bbox: Gf.BBox3d, size: float = 1.0) -> Usd.Stage:
 713    """
 714    Runtime Testing: UN.001, UN.006 - "When referenced into a stage with upAxis 
 715    set to 'Z', the asset appears with the correct 'up' direction."
 716    
 717    Create three colored cylinders at the origin to visualize the coordinate axes.
 718    The cylinder length is based on the provided bounding box.
 719    The visualization is visible during the configured frame range.
 720
 721    
 722    Args:
 723        stage: The USD stage to add the visualization to
 724        asset_up_axis: The up axis of the asset stage
 725        asset_bbox: Pre-computed bounding box of the asset
 726        size: Scale factor for the size of the cylinders
 727    
 728    Returns:
 729        Usd.Stage: The stage with origin visualization added
 730    """
 731    
 732    if not AnimationConfig.ORIGIN_VISUALIZATION_ENABLED:
 733        logger.info("Origin visualization disabled, skipping")
 734        return stage
 735    
 736    # Use configured size scale
 737    size = AnimationConfig.ORIGIN_VIZ_SIZE_SCALE
 738    
 739    # Use the provided bounding box
 740    if asset_bbox is None or asset_bbox.GetRange().IsEmpty():
 741        raise RuntimeError("Provided bounding box is empty or None, using default size")
 742        height: float = 2.0 * size
 743    else:
 744        bbox_range: Gf.Range3d = asset_bbox.GetRange()
 745        
 746        # Use the largest dimension of the bounding box
 747        bbox_size: Gf.Vec3d = bbox_range.GetSize()
 748        max_dimension: float = max(bbox_size[0], bbox_size[1], bbox_size[2])
 749        
 750        # Apply minimum size constraint and scale factor
 751        height = max(max_dimension * size, 1.0 * size)
 752        
 753        logger.info(f"Asset bounding box size: {bbox_size}, using cylinder height: {height}")
 754    
 755    # Create a group for the origin visualization
 756    origin_group: UsdGeom.Xform = UsdGeom.Xform.Define(stage, '/ORIGIN_VIZ')
 757    
 758    # Cylinder dimensions
 759    radius: float = 0.005 * height
 760    
 761    # X-axis cylinder (Red)
 762    x_cylinder: UsdGeom.Cylinder = UsdGeom.Cylinder.Define(stage, '/ORIGIN_VIZ/X_AXIS')
 763    x_cylinder.CreateRadiusAttr(radius)
 764    x_cylinder.CreateHeightAttr(height)
 765    x_cylinder.CreateAxisAttr('X')  # Orient along X-axis
 766    x_cylinder.CreateDisplayColorAttr([(1.0, 0.0, 0.0)])  # Red
 767    
 768    # Y-axis cylinder (Green)
 769    y_cylinder: UsdGeom.Cylinder = UsdGeom.Cylinder.Define(stage, '/ORIGIN_VIZ/Y_AXIS')
 770    y_cylinder.CreateRadiusAttr(radius)
 771    y_cylinder.CreateHeightAttr(height)
 772    y_cylinder.CreateAxisAttr('Y')  # Orient along Y-axis
 773    y_cylinder.CreateDisplayColorAttr([(0.0, 1.0, 0.0)])  # Green
 774    
 775    # Z-axis cylinder (Blue)
 776    z_cylinder: UsdGeom.Cylinder = UsdGeom.Cylinder.Define(stage, '/ORIGIN_VIZ/Z_AXIS')
 777    z_cylinder.CreateRadiusAttr(radius)
 778    z_cylinder.CreateHeightAttr(height)
 779    z_cylinder.CreateAxisAttr('Z')  # Orient along Z-axis
 780    z_cylinder.CreateDisplayColorAttr([(0.0, 0.0, 1.0)])  # Blue
 781    
 782    # Animate visibility: visible for the configured frame range
 783    start_frame = AnimationConfig.ORIGIN_VIZ_START_FRAME
 784    end_frame = AnimationConfig.ORIGIN_VIZ_END_FRAME
 785    origin_group.CreateVisibilityAttr()
 786    
 787    # Set visibility keyframes
 788    if start_frame > 0:
 789        origin_group.GetVisibilityAttr().Set(UsdGeom.Tokens.invisible, time=Usd.TimeCode(0))
 790    origin_group.GetVisibilityAttr().Set(UsdGeom.Tokens.inherited, time=Usd.TimeCode(start_frame))
 791    origin_group.GetVisibilityAttr().Set(UsdGeom.Tokens.invisible, time=Usd.TimeCode(end_frame + 1))
 792    
 793    logger.info(f"Created origin visualization with cylinders at /ORIGIN_VIZ, height: {height}, visible frames {start_frame}-{end_frame}")
 794    
 795    return stage
 796
 797def create_size_reference_visualization(stage: Usd.Stage, asset_up_axis: str, asset_bbox: Gf.BBox3d) -> Usd.Stage:
 798    """
 799    Runtime Testing: UN.002, UN.007 - "When referenced into a stage with 
 800    metersPerUnit set to 1.0, the asset appears at its correct, real-world 
 801    physical scale (e.g., a 2-meter tall object is 2 units high in the scene)."
 802    
 803    Create a grid of cylinders spaced at configurable intervals (default 10cm).
 804
 805    
 806    Args:
 807        stage: The USD stage to add the visualization to
 808        asset_up_axis: The up axis of the asset stage
 809        asset_bbox: Pre-computed bounding box of the asset
 810        size: Scale factor for positioning
 811    
 812    Returns:
 813        Usd.Stage: The stage with size reference visualization added
 814    """
 815    
 816    if not AnimationConfig.SIZE_REFERENCE_ENABLED:
 817        logger.info("Size reference visualization disabled, skipping")
 818        return stage
 819    
 820    # Use the provided bounding box
 821    if asset_bbox is None or asset_bbox.GetRange().IsEmpty():
 822        raise RuntimeError("Provided bounding box is empty or None, cannot create size reference")
 823        return stage
 824    
 825    bbox_range: Gf.Range3d = asset_bbox.GetRange()
 826    bbox_max: Gf.Vec3d = bbox_range.GetMax()
 827    bbox_min: Gf.Vec3d = bbox_range.GetMin()
 828    
 829    # Create a group for the size reference visualization
 830    size_ref_group: UsdGeom.Xform = UsdGeom.Xform.Define(stage, '/SIZE_REFERENCE')
 831    
 832    # Define measurement units and their properties
 833    units: list[dict[str, any]] = [
 834        {'name': 'km', 'size': 1000.0, 'color': (1.0, 1.0, 0.0), 'label': '1km'},   # Light yellow
 835        {'name': 'm', 'size': 1.0, 'color': (0.0, 0.0, 1.0), 'label': '1m'},        # Light blue
 836        {'name': 'cm', 'size': 0.01, 'color': (0.0, 1.0, 0.0), 'label': '1cm'},     # Light green
 837        {'name': 'mm', 'size': 0.001, 'color': (1.0, 0.0, 0.0), 'label': '1mm'},    # Light red
 838    ]
 839    
 840    # Position squares to the right of the bounding box
 841    # base_x_offset = bbox_max[0] + (bbox_max[0] - bbox_min[0]) * 0.2
 842    # y_spacing = (bbox_max[1] - bbox_min[1]) * 0.25
 843    # base_y = bbox_min[1] + (bbox_max[1] - bbox_min[1]) * 0.5
 844    
 845    # Draw a grid that extends from origin to the edge of the asset's bounding box
 846    bbox_range = asset_bbox.GetRange()
 847    bbox_max = bbox_range.GetMax()
 848    bbox_min = bbox_range.GetMin()
 849    
 850    # Use configured grid spacing
 851    grid_spacing: float = AnimationConfig.SIZE_REF_GRID_SPACING
 852    
 853    # Calculate grid extents to cover bbox and always include origin
 854    grid_min_x: float = min(bbox_min[0], 0)
 855    grid_max_x: float = max(bbox_max[0], 0)
 856    grid_min_y: float = min(bbox_min[1], 0)
 857    grid_max_y: float = max(bbox_max[1], 0)
 858    grid_min_z: float = min(bbox_min[2], 0)
 859    grid_max_z: float = max(bbox_max[2], 0)
 860    
 861    # Calculate grid line positions - use floor/ceil to ensure complete coverage
 862    x_start: int = int(floor(grid_min_x / grid_spacing))
 863    x_end: int = int(ceil(grid_max_x / grid_spacing))
 864    y_start: int = int(floor(grid_min_y / grid_spacing))
 865    y_end: int = int(ceil(grid_max_y / grid_spacing))
 866    z_start: int = int(floor(grid_min_z / grid_spacing))
 867    z_end: int = int(ceil(grid_max_z / grid_spacing))
 868    
 869    # Calculate actual grid extents (where the first and last grid lines are positioned)
 870    actual_x_min: float = x_start * grid_spacing
 871    actual_x_max: float = x_end * grid_spacing
 872    actual_y_min: float = y_start * grid_spacing
 873    actual_y_max: float = y_end * grid_spacing
 874    actual_z_min: float = z_start * grid_spacing
 875    actual_z_max: float = z_end * grid_spacing
 876    
 877    # Create grid lines in X direction (parallel to X-axis, spaced along Z)
 878    for i in range(z_start, z_end + 1):
 879        z_pos: float = i * grid_spacing
 880        # Thicker lines at integer meters
 881        is_meter_line: bool = abs(z_pos % 1.0) < 0.01  # Account for floating point precision
 882        radius: float = 0.0025 if is_meter_line else 0.00125 
 883        
 884        # Create valid USD path name (handle negative indices)
 885        path_suffix: str = f"N{abs(i)}" if i < 0 else str(i)
 886        cylinder: str = f'/SIZE_REFERENCE/GRID_X_{path_suffix}'
 887        cylinder_prim: UsdGeom.Cylinder = UsdGeom.Cylinder.Define(stage, cylinder)
 888        cylinder_prim.CreateRadiusAttr(radius)
 889        cylinder_prim.CreateHeightAttr(actual_x_max - actual_x_min)
 890        cylinder_prim.CreateAxisAttr('X')
 891        cylinder_prim.CreateDisplayColorAttr([(0.6, 0.6, 0.6)])  # Light gray
 892        xform_api: UsdGeom.XformCommonAPI = UsdGeom.XformCommonAPI(cylinder_prim)
 893        xform_api.SetTranslate(Gf.Vec3d((actual_x_min + actual_x_max) / 2, 0, z_pos))
 894    
 895    # Create grid lines in Z direction (parallel to Z-axis, spaced along X)
 896    for i in range(x_start, x_end + 1):
 897        x_pos: float = i * grid_spacing
 898        # Thicker lines at integer meters
 899        is_meter_line = abs(x_pos % 1.0) < 0.01  # Account for floating point precision
 900        radius = 0.0025 if is_meter_line else 0.00125 
 901        
 902        # Create valid USD path name (handle negative indices)
 903        path_suffix = f"N{abs(i)}" if i < 0 else str(i)
 904        cylinder = f'/SIZE_REFERENCE/GRID_Z_{path_suffix}'
 905        cylinder_prim = UsdGeom.Cylinder.Define(stage, cylinder)
 906        cylinder_prim.CreateRadiusAttr(radius)
 907        cylinder_prim.CreateHeightAttr(actual_z_max - actual_z_min)
 908        cylinder_prim.CreateAxisAttr('Z')
 909        cylinder_prim.CreateDisplayColorAttr([(0.6, 0.6, 0.6)])  # Light gray
 910        xform_api = UsdGeom.XformCommonAPI(cylinder_prim)
 911        xform_api.SetTranslate(Gf.Vec3d(x_pos, 0, (actual_z_min + actual_z_max) / 2))
 912    
 913    # Create grid lines in X direction for X/Y plane (parallel to X-axis, spaced along Y)
 914    # These lines extend the full X grid range to connect with Y-direction lines
 915    for i in range(y_start, y_end + 1):
 916        y_pos: float = i * grid_spacing
 917        # Thicker lines at integer meters
 918        is_meter_line = abs(y_pos % 1.0) < 0.01  # Account for floating point precision
 919        radius = 0.0025 if is_meter_line else 0.00125 
 920        
 921        # Create valid USD path name (handle negative indices)
 922        path_suffix = f"N{abs(i)}" if i < 0 else str(i)
 923        cylinder = f'/SIZE_REFERENCE/GRID_XY_X_{path_suffix}'
 924        cylinder_prim = UsdGeom.Cylinder.Define(stage, cylinder)
 925        cylinder_prim.CreateRadiusAttr(radius)
 926        cylinder_prim.CreateHeightAttr(actual_x_max - actual_x_min)
 927        cylinder_prim.CreateAxisAttr('X')
 928        cylinder_prim.CreateDisplayColorAttr([(0.5, 0.5, 0.5)])  # Medium gray
 929        xform_api = UsdGeom.XformCommonAPI(cylinder_prim)
 930        xform_api.SetTranslate(Gf.Vec3d((actual_x_min + actual_x_max) / 2, y_pos, 0))
 931    
 932    # Create grid lines in Y direction for X/Y plane (parallel to Y-axis, spaced along X)
 933    # These lines extend the full Y grid range to connect with X-direction lines
 934    for i in range(x_start, x_end + 1):
 935        x_pos = i * grid_spacing
 936        # Thicker lines at integer meters
 937        is_meter_line = abs(x_pos % 1.0) < 0.01  # Account for floating point precision
 938        radius = 0.0025 if is_meter_line else 0.00125  # Reduced by 50%
 939        
 940        # Create valid USD path name (handle negative indices)
 941        path_suffix = f"N{abs(i)}" if i < 0 else str(i)
 942        cylinder = f'/SIZE_REFERENCE/GRID_XY_Y_{path_suffix}'
 943        cylinder_prim = UsdGeom.Cylinder.Define(stage, cylinder)
 944        cylinder_prim.CreateRadiusAttr(radius)
 945        cylinder_prim.CreateHeightAttr(actual_y_max - actual_y_min)
 946        cylinder_prim.CreateAxisAttr('Y')
 947        cylinder_prim.CreateDisplayColorAttr([(0.5, 0.5, 0.5)])  # Medium gray
 948        xform_api = UsdGeom.XformCommonAPI(cylinder_prim)
 949        xform_api.SetTranslate(Gf.Vec3d(x_pos, (actual_y_min + actual_y_max) / 2, 0))
 950    
 951    # Create grid lines in Y direction for Y/Z plane (parallel to Y-axis, spaced along Z)
 952    # These lines extend the full Y grid range to connect with Z-direction lines
 953    for i in range(z_start, z_end + 1):
 954        z_pos: float = i * grid_spacing
 955        # Thicker lines at integer meters
 956        is_meter_line = abs(z_pos % 1.0) < 0.01  # Account for floating point precision
 957        radius = 0.0025 if is_meter_line else 0.00125  # Reduced by 50%
 958        
 959        # Create valid USD path name (handle negative indices)
 960        path_suffix = f"N{abs(i)}" if i < 0 else str(i)
 961        cylinder = f'/SIZE_REFERENCE/GRID_YZ_Y_{path_suffix}'
 962        cylinder_prim = UsdGeom.Cylinder.Define(stage, cylinder)
 963        cylinder_prim.CreateRadiusAttr(radius)
 964        cylinder_prim.CreateHeightAttr(actual_y_max - actual_y_min)
 965        cylinder_prim.CreateAxisAttr('Y')
 966        cylinder_prim.CreateDisplayColorAttr([(0.4, 0.4, 0.4)])  # Dark gray
 967        xform_api = UsdGeom.XformCommonAPI(cylinder_prim)
 968        xform_api.SetTranslate(Gf.Vec3d(0, (actual_y_min + actual_y_max) / 2, z_pos))
 969    
 970    # Create grid lines in Z direction for Y/Z plane (parallel to Z-axis, spaced along Y)
 971    # These lines extend the full Z grid range to connect with Y-direction lines
 972    for i in range(y_start, y_end + 1):
 973        y_pos: float = i * grid_spacing
 974        # Thicker lines at integer meters
 975        is_meter_line = abs(y_pos % 1.0) < 0.01  # Account for floating point precision
 976        radius = 0.0025 if is_meter_line else 0.00125  # Reduced by 50%
 977        
 978        # Create valid USD path name (handle negative indices)
 979        path_suffix = f"N{abs(i)}" if i < 0 else str(i)
 980        cylinder = f'/SIZE_REFERENCE/GRID_YZ_Z_{path_suffix}'
 981        cylinder_prim = UsdGeom.Cylinder.Define(stage, cylinder)
 982        cylinder_prim.CreateRadiusAttr(radius)
 983        cylinder_prim.CreateHeightAttr(actual_z_max - actual_z_min)
 984        cylinder_prim.CreateAxisAttr('Z')
 985        cylinder_prim.CreateDisplayColorAttr([(0.4, 0.4, 0.4)])  # Dark gray
 986        xform_api = UsdGeom.XformCommonAPI(cylinder_prim)
 987        xform_api.SetTranslate(Gf.Vec3d(0, y_pos, (actual_z_min + actual_z_max) / 2))
 988        
 989    
 990    # Add a text label group (as comment for now since USD text is complex)
 991    # In a real implementation, you might want to add UsdGeom.Points with labels
 992    
 993    # Animate visibility: visible for the configured frame range
 994    start_frame = AnimationConfig.SIZE_REF_START_FRAME
 995    end_frame = AnimationConfig.SIZE_REF_END_FRAME
 996    size_ref_group.CreateVisibilityAttr()
 997    
 998    # Set visibility keyframes
 999    if start_frame > 0:
1000        size_ref_group.GetVisibilityAttr().Set(UsdGeom.Tokens.invisible, time=Usd.TimeCode(0))
1001    size_ref_group.GetVisibilityAttr().Set(UsdGeom.Tokens.inherited, time=Usd.TimeCode(start_frame))
1002    size_ref_group.GetVisibilityAttr().Set(UsdGeom.Tokens.invisible, time=Usd.TimeCode(end_frame + 1))
1003    
1004    logger.info(f"Created size reference visualization with grid spacing {grid_spacing}m, visible frames {start_frame}-{end_frame}")
1005    
1006    return stage
1007
1008
1009def apply_material_override(stage: Usd.Stage) -> Usd.Stage:
1010    """
1011    Create a USDPreview Surface material and bind it to the asset root prim
1012    with "strongerThanDescendants" binding to override any existing materials.
1013    
1014    Args:
1015        stage: The USD stage to add the material to
1016    
1017    Returns:
1018        Usd.Stage: The stage with material override applied
1019    """
1020    
1021    if not AnimationConfig.MATERIAL_OVERRIDE_ENABLED:
1022        logger.info("Material override disabled, skipping")
1023        return stage
1024    
1025    # Create material
1026    material_path = '/MATERIALS/OverrideMaterial'
1027    material: UsdShade.Material = UsdShade.Material.Define(stage, material_path)
1028    
1029    # Create UsdPreviewSurface shader
1030    surface_shader_path = f'{material_path}/PreviewSurface'
1031    surface_shader: UsdShade.Shader = UsdShade.Shader.Define(stage, surface_shader_path)
1032    surface_shader.CreateIdAttr("UsdPreviewSurface")
1033    
1034    # Set material properties
1035    diffuse_color = Gf.Vec3f(*AnimationConfig.MATERIAL_DIFFUSE_COLOR)
1036    surface_shader.CreateInput("diffuseColor", Sdf.ValueTypeNames.Color3f).Set(diffuse_color)
1037    # surface_shader.CreateInput("metallic", Sdf.ValueTypeNames.Float).Set(AnimationConfig.MATERIAL_METALLIC)
1038    # surface_shader.CreateInput("roughness", Sdf.ValueTypeNames.Float).Set(AnimationConfig.MATERIAL_ROUGHNESS)
1039    
1040    # Connect shader to material surface output
1041    material_surface_output = material.CreateSurfaceOutput()
1042    surface_output = surface_shader.CreateOutput("surface", Sdf.ValueTypeNames.Token)
1043    material_surface_output.ConnectToSource(surface_output)
1044    
1045    # Get the asset root prim (/ASSET)
1046    asset_prim: Usd.Prim = stage.GetPrimAtPath('/ASSET')
1047    if not asset_prim.IsValid():
1048        logger.warning("Asset prim not found at /ASSET, cannot apply material override")
1049        return stage
1050    
1051    # Bind material to asset with strongerThanDescendants
1052    binding_api: UsdShade.MaterialBindingAPI = UsdShade.MaterialBindingAPI.Apply(asset_prim)
1053    binding_api.Bind(material, UsdShade.Tokens.strongerThanDescendants)
1054    
1055    logger.info(f"Applied material override to /ASSET with strongerThanDescendants binding")
1056    
1057    return stage
1058
1059
1060def main(input_asset_path: str, output_folder: str) -> None:
1061    """
1062    Main function to process a USD asset and create animated test stages.
1063    
1064    Args:
1065        input_asset_path (str): Path to the input USD asset
1066        output_folder (str): Directory where output files will be created
1067    """
1068    
1069    # Validate configuration
1070    AnimationConfig.validate_config()
1071    
1072    # Log animation sequence
1073    phases = AnimationConfig.get_animation_phases()
1074    logger.info("Animation sequence:")
1075    for phase in phases:
1076        logger.info(f"  {phase['name']}: frames {phase['start']}-{phase['end']} ({phase['description']})")
1077    
1078    # Ensure output folder exists
1079    os.makedirs(output_folder, exist_ok=True)
1080    
1081    # Validate input asset
1082    if not os.path.exists(input_asset_path):
1083        raise FileNotFoundError(f"Input asset not found: {input_asset_path}")
1084    
1085    logger.info(f"Processing asset: {input_asset_path}")
1086    logger.info(f"Output folder: {output_folder}")
1087    
1088    # Load asset and get properties
1089    try:
1090        loads_without_warnings_or_errors(input_asset_path)
1091        logger.info("Asset validation passed - no warnings or errors")
1092    except USDLoadingError as e:
1093        logger.warning(f"Asset loaded with warnings or errors, but continuing...\n{e}")
1094    
1095    asset_stage: Usd.Stage = Usd.Stage.Open(input_asset_path)
1096    asset_up_axis: str = UsdGeom.GetStageUpAxis(asset_stage)
1097    asset_bbox: Gf.BBox3d = _compute_stage_bounding_box(asset_stage, default_prim_only=True)
1098    
1099    # Define output file paths
1100    test_stage_path: str = os.path.join(output_folder, 'test_stage.usda')
1101    camera_layer_path: str = os.path.join(output_folder, 'camera_layer.usda')
1102    asset_animation_layer_path: str = os.path.join(output_folder, 'asset_animation.usda')
1103    lights_layer_path: str = os.path.join(output_folder, 'lights_animation.usda')
1104    
1105    logger.info(f"Creating test stage: {test_stage_path}")
1106    
1107    # Create and set up the test stage
1108    test_stage: Usd.Stage = setup_test_stage(input_asset_path, test_stage_path)
1109
1110    # Create and frame camera
1111    if AnimationConfig.CAMERA_ENABLED:
1112        logger.info(f"Creating camera layer: {camera_layer_path}")
1113        camera_layer: Sdf.Layer = Sdf.Layer.CreateNew(camera_layer_path)
1114        Usd.Stage.Open(camera_layer).SetTimeCodesPerSecond(AnimationConfig.FRAME_RATE)
1115        test_stage.GetRootLayer().subLayerPaths.append(os.path.basename(camera_layer_path))
1116        test_stage.SetEditTarget(camera_layer)
1117        test_stage = create_and_frame_camera(stage=test_stage, asset_up_axis=asset_up_axis, asset_bbox=asset_bbox)
1118    else:
1119        logger.info("Camera creation disabled, skipping")
1120
1121    # Create a single animation layer for asset animations
1122    logger.info(f"Creating asset animation layer: {asset_animation_layer_path}")
1123    asset_anim_layer: Sdf.Layer = Sdf.Layer.CreateNew(asset_animation_layer_path)
1124    Usd.Stage.Open(asset_anim_layer).SetTimeCodesPerSecond(AnimationConfig.FRAME_RATE)
1125    test_stage.GetRootLayer().subLayerPaths.append(os.path.basename(asset_animation_layer_path))
1126    test_stage.SetEditTarget(asset_anim_layer)
1127     
1128    # Apply asset animations using the shared layer with configured timing
1129    test_stage = spin_asset(test_stage, 
1130                           axis=AnimationConfig.ASSET_SPIN_Z_AXIS, 
1131                           start_time=AnimationConfig.ASSET_SPIN_Z_START, 
1132                           end_time=AnimationConfig.ASSET_SPIN_Z_END)
1133    test_stage = spin_asset(test_stage, 
1134                           axis=AnimationConfig.ASSET_SPIN_X_AXIS, 
1135                           start_time=AnimationConfig.ASSET_SPIN_X_START, 
1136                           end_time=AnimationConfig.ASSET_SPIN_X_END)
1137    test_stage = spin_asset(test_stage, 
1138                           axis=AnimationConfig.ASSET_SPIN_Y_AXIS, 
1139                           start_time=AnimationConfig.ASSET_SPIN_Y_START, 
1140                           end_time=AnimationConfig.ASSET_SPIN_Y_END)
1141    test_stage = move_asset(test_stage, asset_bbox)
1142
1143    # Create separate layer for lights animation
1144    logger.info(f"Creating lights animation layer: {lights_layer_path}")
1145    lights_layer: Sdf.Layer = Sdf.Layer.CreateNew(lights_layer_path)
1146    Usd.Stage.Open(lights_layer).SetTimeCodesPerSecond(AnimationConfig.FRAME_RATE)
1147    test_stage.GetRootLayer().subLayerPaths.append(os.path.basename(lights_layer_path))
1148    test_stage.SetEditTarget(lights_layer)
1149    test_stage = spin_lights(test_stage, asset_up_axis)
1150
1151    # Add origin visualization
1152    logger.info("Adding origin visualization")
1153    test_stage.SetEditTarget(test_stage.GetRootLayer())
1154    test_stage = create_origin_visualization(test_stage, asset_up_axis, asset_bbox)
1155
1156    # Add size reference visualization
1157    logger.info("Adding size reference visualization")
1158    test_stage = create_size_reference_visualization(test_stage, asset_up_axis, asset_bbox)
1159
1160    # Apply material override
1161    logger.info("Applying material override")
1162    test_stage = apply_material_override(test_stage)
1163
1164    # Save the stage with asset animations
1165    test_stage.Save()
1166    
1167    logger.info("✅ Successfully created animated test stage and layers")
1168    
1169
1170
1171if __name__ == "__main__":
1172    parser = argparse.ArgumentParser(
1173        description="Create an animated USD test stage from an input asset",
1174        formatter_class=argparse.RawDescriptionHelpFormatter,
1175        epilog="""
1176Examples:
1177  %(prog)s asset.usd ./output/
1178  %(prog)s /path/to/asset.usda /path/to/output/folder/
1179  
1180This script will create several USD files in the output folder:
1181  - test_stage.usda (main stage with asset reference)
1182  - camera_layer.usda (framed camera)
1183  - asset_animation.usda (asset spinning and movement)
1184  - lights_animation.usda (spinning lights)
1185        """
1186    )
1187    
1188    parser.add_argument(
1189        "input_asset", 
1190        help="Path to the input USD asset file (.usd, .usda, .usdc)"
1191    )
1192    
1193    parser.add_argument(
1194        "output_folder",
1195        help="Directory where output files will be created (will be created if it doesn't exist)"
1196    )
1197    
1198    parser.add_argument(
1199        "--verbose", "-v",
1200        action="store_true",
1201        help="Enable verbose logging"
1202    )
1203    
1204    args = parser.parse_args()
1205    
1206    # Set up logging level
1207    if args.verbose:
1208        logging.getLogger().setLevel(logging.DEBUG)
1209    
1210    main(args.input_asset, args.output_folder)
1211