Kit CAE enables developers to add new schemas for file formats and data models. The built-in schemas beyond CaeFieldArray and CaeDataSet
serve as examples of what such schemas may look like. For existing Kit CAE functionality to work with custom data models, we need a plugin/callback
mechanism for data model-aware processing. For example, when voxelization of a dataset is needed, we require a mechanism that allows code requesting
voxelized data to do so in a data-model-agnostic fashion from a CaeDataSet.
Kit CAE uses Omniverse's Command system (omni.kit.commands) to achieve this functionality. Commands provide a mechanism
for extensions to define new subclasses of omni.kit.commands.Command that can be executed using a central omni.kit.commands.execute() call.
Kit CAE defines a set of Command subclasses called Operator Commands. These cover a range of data transformation
operations needed to support the data processing and visualization functionality exposed in Kit CAE. Extensions can register
subclasses based on these Operator Commands with the Command system to provide data model-specific implementations.
For example, the operator command ComputeBounds computes bounding boxes for datasets. To provide specialization for a
CaeDataSet primitive that has the CaeSidsUnstructuredAPI applied, you simply need to register a command named CaeSidsUnstructuredComputeBounds.
When a request is made to execute an operator command on any primitive, the implementation uses the following resolution approach:
-
API Schema Resolution: For each applied API schema, a command name is constructed using the API schema's name (without the
APIsuffix). This forms the prefix, which is then added to the operator command class name. For example, if the operator command isComputeBoundsbeing invoked on aCaeDataSetprimitive withCaeSidsUnstructuredAPIapplied, the command name tested will beCaeSidsUnstructuredComputeBounds. If a command with that name has not been registered with the Command system, the implementation moves on to the next applied API schema until all applied API schemas are tried. -
Type Hierarchy Resolution: If none of the applied schema checks work, the same process is performed using the primitive type name itself, moving up the USD primitive type hierarchy. For a
CaeDataSetprimitive, the implementation will tryCaeDataSetComputeBounds,TypedComputeBounds, and finallySchemaBaseComputeBoundsas the last resort.
Each operator command class has an invoke class method that documents the arguments and return type of that command.
The invoke() method throws exceptions on errors. When the return value is an array, we return a FieldArrayLike, which
can represent GPU or CPU arrays. This enables development of operator implementations that are not required to explicitly
perform data transfers between device and host before returning results. Device transfer can be deferred until absolutely
necessary when the result is consumed.
The omni.cae.data.commands module defines several core operator command types:
Computes the physical data bounds of a dataset primitive. The bounds are returned as a Gf.Range3d.
Usage:
from omni.cae.data.commands import ComputeBounds
from pxr import Gf
async def coroutine():
# ...
datasetPrim: Usd.Prim = ...
timeCode: Usd.TimeCode = ...
bds: Gf.Range3d = await ComputeBounds.invoke(datasetPrim, timeCode=timeCode)
if bds.IsEmpty():
logger.info("empty bounds!")Similar to ComputeBounds, but instead of world bounds, this computes structured IJK extents for the dataset primitive.
The command arguments allow the invoker to specify maximum dimensions along each axis and/or a region of interest
that can be used to further qualify the extents.
Usage:
from omni.cae.data import IJKExtents
from omni.cae.data.commands import ComputeIJKExtents
from pxr import Gf
async def coroutine():
# ...
datasetPrim: Usd.Prim = ...
timeCode: Usd.TimeCode = ...
exts: IJKExtents = await ComputeIJKExtents.invoke(
datasetPrim,
max_dims=(128, 128, 128),
roi=Gf.Range3d((0.0, 0.0, 0.0), (50.5, 10.0, 10.0)), # or None
timeCode=timeCode
)
if exts.IsEmpty():
logger.info("empty extents!")Converts a dataset to a standard point cloud representation. The command arguments allow optional selection of field arrays
to load, specified by field:<name> relationships on the dataset primitive. Since field arrays can be associated with
cell centers instead of vertices in the dataset, implementations of this command are expected to perform necessary
transformations to convert cell-centered fields to vertex-centered fields.
The returned result (PointCloud) intentionally mirrors a subset of attributes on UsdGeom.Points primitives. Data arrays
in the result are provided as FieldArrayLike and can represent both GPU or CPU hosted arrays. Use
omni.cae.data.array_utils.as_numpy_array() to reliably access a CPU hosted array if needed.
PointCloud provides a convenience method PointCloud.numpy() to convert all arrays to NumPy (with device-to-host copying if necessary).
Usage:
from omni.cae.data.commands import ConvertToPointCloud, PointCloud
async def coroutine():
# ...
datasetPrim: Usd.Prim = ...
timeCode: Usd.TimeCode = ...
result: PointCloud = await ConvertToPointCloud.invoke(
datasetPrim,
fields=["Temp", "Pressure"], # or []
timeCode=timeCode
)
logger.info("Points shape: %s", result.coords.shape)
logger.info("Num fields: %d", len(result.fields))
logger.info("Temp shape: %s", result.fields["Temp"].shape)
logger.info("Pressure shape: %s", result.fields["Pressure"].shape)Converts a dataset to a surface mesh representation. This is similar to ConvertToPointCloud,
except the returned value is a Mesh suitable for setting up attributes on a UsdGeom.Mesh primitive.
Usage:
from omni.cae.data.commands import ConvertToMesh, Mesh
async def coroutine():
# ...
datasetPrim: Usd.Prim = ...
timeCode: Usd.TimeCode = ...
result: Mesh = await ConvertToMesh.invoke(
datasetPrim,
fields=["Temp"],
timeCode=timeCode
)Voxelizes a dataset into a volumetric representation.
Usage:
from omni.cae.data.commands import Voxelize
from omni.cae.data.types import Range3i
import warp as wp
async def coroutine():
# ...
datasetPrim: Usd.Prim = ...
timeCode: Usd.TimeCode = ...
result: wp.Volume = await Voxelize.invoke(
datasetPrim,
fields=["Temp"],
bbox=Range3i((0, 0, 0), (128, 128, 128)), # or None
voxel_size=0.5,
device_ordinal=0,
timeCode=timeCode
)Advects particles over a vector field and returns the streamlines. The attributes on the returned Streamlines object are aligned with UsdGeom.BasisCurves.
Usage:
from omni.cae.data.commands import Streamlines, GenerateStreamlines
async def coroutine():
# ...
datasetPrim: Usd.Prim = ...
timeCode: Usd.TimeCode = ...
result: Streamlines = await GenerateStreamlines.invoke(
datasetPrim,
seeds,
velocity_fields=["VelocityX", "VelocityY", "VelocityZ"],
color_field="Temp",
dX=0.1,
maxLength=100,
timeCode=timeCode
)The omni.cae.vtk.commands module defines VTK-specific operator command types used by algorithms that require VTK for data processing.
Converts any dataset to a VTK dataset. If you want to introduce a new data model and want it to work with VTK algorithms,
you must implement a specialization for this operator. The signature is similar to ConvertToMesh with one additional
parameter, forcePointData, which provides a convenience capability to internally convert all cell-centered data to point-centered data.
Usage:
from omni.cae.vtk.commands import ConvertToVTKDataSet
from vtkmodules.numpy_interface import dataset_adapter as dsa
async def coroutine():
# ...
datasetPrim: Usd.Prim = ...
timeCode: Usd.TimeCode = ...
result: dsa.DataSet = await ConvertToVTKDataSet.invoke(
datasetPrim,
fields=["Temp"],
forcePointData=False,
timeCode=timeCode
)The omni.cae.index.commands module defines NVIDIA IndeX-specific operator command types used by IndeX importers or
compute tasks to perform necessary data transformation for consumption by IndeX.
Converts any dataset to an IIrregular_volume_subset used for unstructured grid volume rendering. This command is
called by IndeX code, so applications will rarely need to call CreateIrregularVolumeSubset.invoke(...) directly.
Instead, here's how to implement and populate an IIrregular_volume_subset.
Implementation Example:
from omni.cae.index.commands import CreateIrregularVolumeSubset
from omni.cae.index.bindings import (
IIrregular_volume_subset, Mesh_parameters, Mesh_storage,
Attribute_parameters, Attribute_storage, Attribute_affiliation,
Attribute_type, BBox_float32
)
import numpy as np
class ExampleSchemaTypeNameCreateIrregularVolumeSubset(CreateIrregularVolumeSubset):
async def do(self) -> None:
dataset: Usd.Prim = self.dataset # the dataset primitive
fields: list[str] = self.field # list of field names to volume render
bbox: BBox_float32 = self.bbox # bounding box to limit the import conversion
subset: IIrregular_volume_subset = self.subset # the instance to populate
# 1. Populate Mesh_parameters; this tells IndeX how much memory it needs to allocate.
# With IIrregular_volume_subset, cells are defined as a collection of triangular
# or quadrangular faces.
params = Mesh_parameters()
params.nb_vertices = ... # number of vertices
params.nb_cells = ... # number of cells
params.nb_faces = ... # number of faces
params.nb_face_vtx_indices = ... # number of face vertex indices
params.nb_cell_face_indices = ... # number of cell face indices
# 2. Allocate storage
storage: Mesh_storage = subset.generate_mesh_storage(params)
# 3. Once storage is allocated, populate the actual data in the allocated memory.
# Vertex coordinates: x-y-z coordinates for the vertices
s_verts: np.ndarray = storage.get_vertices(params)
assert (s_verts.dtype == np.float32 and s_verts.ndim == 2 and
s_verts.shape[1] == 3 and s_verts.shape[0] == params.nb_vertices)
# ... populate vertex data ...
# Cells: specified as (nb_faces, start_face_index) for each cell where:
# - nb_faces: number of faces forming this cell
# - start_face_index: starting index into the cell-face index array
s_cells: np.ndarray = storage.get_cells(params)
assert (s_cells.dtype == np.uint32 and s_cells.ndim == 2 and
s_cells.shape[1] == 2 and s_cells.shape[0] == params.nb_cells)
# ... populate cell data ...
# Cell face indices array: 0-based indices for `faces` array that provides face definitions
s_cell_face_indices: np.ndarray = storage.get_cell_face_indices(params)
assert (s_cell_face_indices.dtype == np.uint32 and s_cell_face_indices.ndim == 1 and
s_cell_face_indices.shape[0] == params.nb_cell_face_indices)
# ... populate cell face indices ...
# Faces: specified as (nb_vertices, start_vertex_index) for each face where:
# - nb_vertices: number of vertices in the face
# - start_vertex_index: starting index into the face-vertex index array
s_faces: np.ndarray = storage.get_faces(params)
assert (s_faces.dtype == np.uint32 and s_faces.ndim == 2 and
s_faces.shape[1] == 2 and s_faces.shape[0] == params.nb_faces)
# ... populate face data ...
# Face vertex indices array: 0-based indices for `vertices` array that comprise the faces
s_face_vtx_indices: np.ndarray = storage.get_face_vtx_indices(params)
assert (s_face_vtx_indices.dtype == np.uint32 and s_face_vtx_indices.ndim == 1 and
s_face_vtx_indices.shape[0] == params.nb_face_vtx_indices)
# ... populate face vertex indices ...When implementing operator commands for your custom data models:
- Follow Naming Conventions: Use the pattern
{SchemaName}{OperatorCommand}for your command classes - Handle Errors Gracefully: Use appropriate exception handling and logging
- Optimize for Performance: Consider GPU/CPU memory management and avoid unnecessary data transfers
- Document Your Implementation: Provide clear documentation for your custom operator commands
- Test Thoroughly: Ensure your implementations work correctly with various data sizes and edge cases