Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a “Flow Analysis” tool to the ImGui UI and introduces procedural computation of common vortical quantities (vorticity magnitude, helicity, Lambda2, Q-criterion) from three scalar velocity component fields, alongside importer updates to expose vector component fields as separate SpatialFields.
Changes:
- Add a new ImGui modal (
Flow Analysis) to select U/V/WSpatialFields and compute derived flow volumes. - Add
tsd::io::computeVorticity()(structured + NanoVDB paths, plus an optional VTK unstructured path) and a new corevortimplementation. - Update VTI/VTU importers to expose 3-component point arrays as separate component fields (
_x/_y/_z), enabling selection for flow analysis.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
tsd/src/tsd/ui/imgui/modals/VorticityDialog.h |
Declares the new Flow Analysis modal and its UI state. |
tsd/src/tsd/ui/imgui/modals/VorticityDialog.cpp |
Implements field selection UI and kicks off background computation via task modal. |
tsd/src/tsd/ui/imgui/CMakeLists.txt |
Builds the new modal implementation. |
tsd/src/tsd/ui/imgui/Application.h |
Wires the new modal into the app. |
tsd/src/tsd/ui/imgui/Application.cpp |
Adds menu entry and renders the modal when visible. |
tsd/src/tsd/io/procedural/computeVorticity.hpp |
Declares the new procedural API and options/result types. |
tsd/src/tsd/io/procedural/computeVorticity.cpp |
Implements extraction + computation + volume wrapping for outputs. |
tsd/src/tsd/io/procedural.hpp |
Exposes computeVorticity in the procedural umbrella header. |
tsd/src/tsd/io/importers/import_VTU.cpp |
Exposes all point arrays as SpatialFields sharing topology; adds default volume colormap. |
tsd/src/tsd/io/importers/import_VTI.cpp |
Splits 3-component point arrays into _x/_y/_z fields (main field becomes _x). |
tsd/src/tsd/io/importers/import_USD.cpp |
Formatting-only include/order and wrapping changes. |
tsd/src/tsd/io/CMakeLists.txt |
Builds the new procedural implementation file. |
tsd/src/tsd/core/algorithms/vort.h |
Adds the core numerical implementation for vortical quantities. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| foreach_item_const(scene.objectDB().field, [&](const SpatialField *sf) { | ||
| if (sf) { | ||
| fields.push_back(const_cast<SpatialField *>(sf)); | ||
| fieldNames.push_back(sf->name()); |
There was a problem hiding this comment.
computeVorticity() only reads the input SpatialFields, but the UI has to const_cast SpatialField* out of a const iteration to call it. Consider changing computeVorticity() (and helper extract* functions) to take const SpatialField* for u/v/w so callers don’t need to cast away constness.
| len); | ||
|
|
||
| std::cout << "[vort] Vortical variables computed" << std::endl; | ||
| } |
There was a problem hiding this comment.
This prints to stdout every time vort() runs (and vort.h includes just for that). In production this will spam logs and can significantly slow large computations. Please remove the std::cout line (or switch to the project logging utilities behind an explicit verbose/debug flag).
| inline void vort(const float *u, | ||
| const float *v, | ||
| const float *w, | ||
| const double *x_, | ||
| const double *y_, | ||
| const double *z_, | ||
| float *vorticity, | ||
| float *helicity, | ||
| float *lambda2, | ||
| float *qCriterion, | ||
| size_t nx, | ||
| size_t ny, | ||
| size_t nz) |
There was a problem hiding this comment.
New numerical routines (grad3D/vort/vort_from_jacobians) are added without any unit tests. Since tsd/tests already uses Catch2 for core utilities, please add a small test that checks vorticity/lambda2/Q outputs on a simple analytic velocity field (e.g., solid-body rotation) and verifies boundary handling on a minimal grid.
| // --- Write point data arrays --- | ||
| vtkPointData *pointData = grid->GetPointData(); | ||
| for (uint32_t i = 0; i < pointData->GetNumberOfArrays(); ++i) { | ||
| vtkDataArray *array = pointData->GetArray(i); | ||
|
|
||
| int numComponents = array->GetNumberOfComponents(); | ||
| if (numComponents > 1) { | ||
|
|
||
| if (numComponents == 1) { | ||
| auto a = makeArray3DFromVTK( | ||
| scene, array, dims[0], dims[1], dims[2], "[import_VTI]"); | ||
| field->setParameterObject("data", *a); | ||
| break; | ||
| } else if (numComponents == 3) { | ||
| // Split into 3 scalar SpatialFields: {name}_x, {name}_y, {name}_z | ||
| const char *arrName = array->GetName(); | ||
| std::string baseName = (arrName && arrName[0] != '\0') | ||
| ? std::string(arrName) | ||
| : fileOf(filepath); | ||
|
|
||
| std::string nameX = baseName + "_x"; | ||
| std::string nameY = baseName + "_y"; | ||
| std::string nameZ = baseName + "_z"; | ||
|
|
||
| size_t n = (size_t)dims[0] * (size_t)dims[1] * (size_t)dims[2]; | ||
|
|
||
| auto arrX = scene.createArray(ANARI_FLOAT32, dims[0], dims[1], dims[2]); | ||
| auto arrY = scene.createArray(ANARI_FLOAT32, dims[0], dims[1], dims[2]); | ||
| auto arrZ = scene.createArray(ANARI_FLOAT32, dims[0], dims[1], dims[2]); | ||
|
|
||
| float *pX = arrX->mapAs<float>(); | ||
| float *pY = arrY->mapAs<float>(); | ||
| float *pZ = arrZ->mapAs<float>(); | ||
|
|
||
| for (vtkIdType idx = 0; idx < (vtkIdType)n; ++idx) { | ||
| double tuple[3] = {0.0, 0.0, 0.0}; | ||
| array->GetTuple(idx, tuple); | ||
| pX[idx] = (float)tuple[0]; | ||
| pY[idx] = (float)tuple[1]; | ||
| pZ[idx] = (float)tuple[2]; | ||
| } | ||
|
|
||
| arrX->unmap(); | ||
| arrY->unmap(); | ||
| arrZ->unmap(); | ||
|
|
||
| // Main field (_x component) | ||
| field->setName(nameX.c_str()); | ||
| field->setParameterObject("data", *arrX); | ||
|
|
||
| // _y component field in the scene object pool | ||
| auto fieldY = scene.createObject<SpatialField>( | ||
| tokens::spatial_field::structuredRegular); | ||
| fieldY->setName(nameY.c_str()); | ||
| fieldY->setParameter("origin", float3(origin[0], origin[1], origin[2])); | ||
| fieldY->setParameter( | ||
| "spacing", float3(spacing[0], spacing[1], spacing[2])); | ||
| fieldY->setParameterObject("data", *arrY); | ||
|
|
||
| // _z component field in the scene object pool | ||
| auto fieldZ = scene.createObject<SpatialField>( | ||
| tokens::spatial_field::structuredRegular); | ||
| fieldZ->setName(nameZ.c_str()); | ||
| fieldZ->setParameter("origin", float3(origin[0], origin[1], origin[2])); | ||
| fieldZ->setParameter( | ||
| "spacing", float3(spacing[0], spacing[1], spacing[2])); | ||
| fieldZ->setParameterObject("data", *arrZ); | ||
|
|
||
| logStatus( | ||
| "[import_VTI] split 3-component array '%s' into '%s', '%s', '%s'", | ||
| baseName.c_str(), | ||
| nameX.c_str(), | ||
| nameY.c_str(), | ||
| nameZ.c_str()); | ||
| break; | ||
| } else { | ||
| logWarning( | ||
| "[import_VTI] only single-component arrays are supported, " | ||
| "array '%s' has %d components -- only using first component", | ||
| "[import_VTI] array '%s' has %d components (only 1 or 3 are " | ||
| "supported) -- skipping", | ||
| array->GetName(), | ||
| numComponents); | ||
| continue; | ||
| } | ||
|
|
||
| auto a = makeArray3DFromVTK( | ||
| scene, array, dims[0], dims[1], dims[2], "[import_VTI]"); | ||
| field->setParameterObject("data", *a); | ||
| break; | ||
| } | ||
|
|
||
| return field; |
There was a problem hiding this comment.
If the VTI contains only arrays with component counts other than 1 or 3, this loop will skip everything and the function will still return a SpatialField with its default (invalid) 'data' parameter. That can break rendering/processing later. Please detect the "no supported arrays found" case and return {} (and/or remove the created field) with a clear error message.
| // Helper: create a new unstructured SpatialField with shared topology | ||
| auto makeTopoField = [&](const std::string &name) -> SpatialFieldRef { | ||
| auto f = scene.createObject<tsd::core::SpatialField>( | ||
| tokens::spatial_field::unstructured); | ||
| f->setName(name.c_str()); | ||
| f->setParameterObject("vertex.position", *vertexArray); | ||
| f->setParameterObject("index", *indexArray); | ||
| f->setParameterObject("cell.index", *cellIndexArray); | ||
| f->setParameterObject("cell.type", *cellTypesArray); | ||
| return f; | ||
| }; | ||
|
|
||
| // Vertex data: expose all point arrays | ||
| vtkPointData *pointData = grid->GetPointData(); | ||
| for (int i = 0; i < std::min(1, pointData->GetNumberOfArrays()); ++i) { | ||
| SpatialFieldRef firstField; | ||
| std::string baseName = fileOf(filepath); | ||
|
|
||
| for (int i = 0; i < pointData->GetNumberOfArrays(); ++i) { | ||
| vtkDataArray *array = pointData->GetArray(i); | ||
| if (!array) | ||
| continue; | ||
| auto a = makeFloatArray1D(scene, array, numPoints); | ||
| field->setParameterObject("vertex.data", *a); | ||
| int nComp = array->GetNumberOfComponents(); | ||
| std::string arrName = (array->GetName() && array->GetName()[0] != '\0') | ||
| ? array->GetName() | ||
| : baseName; | ||
|
|
||
| if (nComp == 1) { | ||
| auto dataArr = scene.createArray(ANARI_FLOAT32, numPoints); | ||
| auto *buf = dataArr->mapAs<float>(); | ||
| for (vtkIdType j = 0; j < numPoints; ++j) | ||
| buf[j] = static_cast<float>(array->GetComponent(j, 0)); | ||
| dataArr->unmap(); | ||
| auto f = makeTopoField(arrName); | ||
| f->setParameterObject("vertex.data", *dataArr); | ||
| if (!firstField) | ||
| firstField = f; | ||
| } else if (nComp == 3) { | ||
| for (int c = 0; c < 3; ++c) { | ||
| const char *suffix = (c == 0) ? "_x" : (c == 1) ? "_y" : "_z"; | ||
| std::string compName = arrName + suffix; | ||
| auto dataArr = scene.createArray(ANARI_FLOAT32, numPoints); | ||
| auto *buf = dataArr->mapAs<float>(); | ||
| for (vtkIdType j = 0; j < numPoints; ++j) | ||
| buf[j] = static_cast<float>(array->GetComponent(j, c)); | ||
| dataArr->unmap(); | ||
| auto f = makeTopoField(compName); | ||
| f->setParameterObject("vertex.data", *dataArr); | ||
| if (!firstField) | ||
| firstField = f; | ||
| } | ||
| logStatus( | ||
| "[import_VTU] split 3-component array '%s' into '%s_x', '%s_y', " | ||
| "'%s_z'", | ||
| arrName.c_str(), | ||
| arrName.c_str(), | ||
| arrName.c_str(), | ||
| arrName.c_str()); | ||
| } else { | ||
| logWarning( | ||
| "[import_VTU] array '%s' has %d components (only 1 or 3 are " | ||
| "supported) -- skipping", | ||
| arrName.c_str(), | ||
| nComp); | ||
| } | ||
| } | ||
|
|
||
| // Cell data | ||
| // Fallback: no point arrays → create a topology-only field | ||
| if (!firstField) | ||
| firstField = makeTopoField(baseName); | ||
|
|
There was a problem hiding this comment.
The additional SpatialFields created for each point-data array are not referenced by any layer node or object parameter, so Scene::removeUnusedObjects()/cleanup will delete them immediately. If these fields are intended to be selectable later (e.g., for Flow Analysis), they need some kind of persistent reference (e.g., insert layer nodes for them under the import root, or attach them to a container object).
| // Main field (_x component) | ||
| field->setName(nameX.c_str()); | ||
| field->setParameterObject("data", *arrX); | ||
|
|
||
| // _y component field in the scene object pool | ||
| auto fieldY = scene.createObject<SpatialField>( | ||
| tokens::spatial_field::structuredRegular); | ||
| fieldY->setName(nameY.c_str()); | ||
| fieldY->setParameter("origin", float3(origin[0], origin[1], origin[2])); | ||
| fieldY->setParameter( | ||
| "spacing", float3(spacing[0], spacing[1], spacing[2])); | ||
| fieldY->setParameterObject("data", *arrY); | ||
|
|
||
| // _z component field in the scene object pool | ||
| auto fieldZ = scene.createObject<SpatialField>( | ||
| tokens::spatial_field::structuredRegular); | ||
| fieldZ->setName(nameZ.c_str()); | ||
| fieldZ->setParameter("origin", float3(origin[0], origin[1], origin[2])); | ||
| fieldZ->setParameter( | ||
| "spacing", float3(spacing[0], spacing[1], spacing[2])); | ||
| fieldZ->setParameterObject("data", *arrZ); | ||
|
|
||
| logStatus( | ||
| "[import_VTI] split 3-component array '%s' into '%s', '%s', '%s'", | ||
| baseName.c_str(), | ||
| nameX.c_str(), | ||
| nameY.c_str(), | ||
| nameZ.c_str()); | ||
| break; |
There was a problem hiding this comment.
Similar to VTU: the split _y/_z SpatialFields are created but never referenced, so "Cleanup Unused Objects" will remove them. If the goal is to expose vector components as standalone fields for downstream tools, consider adding a persistent reference (e.g., insert nodes for them, or otherwise keep them alive).
| dst.resize(n); | ||
| const float *src = ca->dataAs<float>(); | ||
| for (size_t i = 0; i < n; ++i) | ||
| dst[i] = src[i]; |
There was a problem hiding this comment.
readCoordsParam() assumes the coords Array is float32 and has at least n elements (dataAs() + unchecked loop). This will assert/UB for ANARI_FLOAT64 coord arrays and can read out of bounds if the array is shorter than the field dimension. Please validate elementType (support float32/float64 like export_StructuredVolumeToNanoVDB does) and verify ca->size() >= n before copying.
| dst.resize(n); | |
| const float *src = ca->dataAs<float>(); | |
| for (size_t i = 0; i < n; ++i) | |
| dst[i] = src[i]; | |
| // Validate element type: support float32 and float64 coordinates | |
| anari::DataType elemType = ca->elementType(); | |
| if (elemType != ANARI_FLOAT32 && elemType != ANARI_FLOAT64) { | |
| logError("[computeVorticity] field coord parameter '%s' has unsupported " | |
| "element type (expected float32/float64)", | |
| name); | |
| return false; | |
| } | |
| // Validate we have at least n elements before copying | |
| if (ca->size() < n) { | |
| logError("[computeVorticity] field coord parameter '%s' is too short " | |
| "(have %zu elements, need %zu)", | |
| name, | |
| static_cast<size_t>(ca->size()), | |
| n); | |
| return false; | |
| } | |
| dst.resize(n); | |
| if (elemType == ANARI_FLOAT32) { | |
| const float *src = ca->dataAs<float>(); | |
| for (size_t i = 0; i < n; ++i) | |
| dst[i] = static_cast<double>(src[i]); | |
| } else { // ANARI_FLOAT64 | |
| const double *src = ca->dataAs<double>(); | |
| for (size_t i = 0; i < n; ++i) | |
| dst[i] = src[i]; | |
| } |
| auto field = scene.createObject<SpatialField>( | ||
| tokens::spatial_field::structuredRegular); | ||
| field->setName(name.c_str()); | ||
| field->setParameter("origin", origin); | ||
| field->setParameter("spacing", spacing); | ||
| field->setParameterObject("data", *dataArr); | ||
|
|
There was a problem hiding this comment.
wrapAsVolume() always creates a structuredRegular SpatialField and the caller derives a single uniform spacing from the first two coordinates. For structuredRectilinear / nanovdbRectilinear inputs this can misrepresent world-space mapping (non-uniform coords are lost). Either (a) generate structuredRectilinear outputs by preserving coordsX/Y/Z, or (b) explicitly validate the coords are uniformly spaced before converting to structuredRegular.
No description provided.