Minimal Placeable Visual#
Property |
Value |
---|---|
Version |
|
Dependency |
|
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#
Tags |
Summary |
Compatibility |
Validator |
---|---|---|---|
🔑 |
openusd |
||
🔑 |
hierarchy usd |
||
🔑 |
Stage must specify a default prim to define the root entry point. |
openusd |
|
🔑 |
Stage must specify upAxis = “Z” to define the orientation of the stage |
core usd |
|
🔑 |
Stage must specify metersPerUnit = 1.0 to define the linear unit scale |
core usd |
|
🔑 |
core usd |
||
🔑 |
All geometry shall be represented as non-subdivided mesh primitives using the UsdGeomMesh schema. |
core usd |
|
✅ |
core usd |
||
✅ |
Repeated occurrences of identically shaped objects should have identical mesh connectivity |
core usd |
Manual |
✅ |
core usd |
||
✅ |
Mesh normals values must be valid to produce correct shading. |
core usd |
|
✅ |
core usd |
||
🚀 |
Boundable geometry primitives should have valid extent values. |
openusd |
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
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