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