diff --git a/nanovdb/nanovdb/NanoVDB.h b/nanovdb/nanovdb/NanoVDB.h index 78955cd251..aceef7fd2c 100644 --- a/nanovdb/nanovdb/NanoVDB.h +++ b/nanovdb/nanovdb/NanoVDB.h @@ -4719,7 +4719,7 @@ using OnIndexGrid = Grid; * @endcode */ -/// @brief Use this function, which depends a pointer to GridData, to call +/// @brief Use this function, which depends on a pointer to GridData, to call /// other functions that depend on a NanoGrid of a known ValueType. /// @details This function allows for generic programming by converting GridData /// to a NanoGrid of the type encoded in GridData::mGridType. @@ -5762,6 +5762,26 @@ class ChannelAccessor : public DefaultReadAccessor }; // ChannelAccessor +/// @brief Generic Accessor type that maps to either a ReadAccessor or ChannelAccessor +/// @tparam BuildT Build type, e.g. float or ValueOnIndex +/// @tparam ValueT Value type, e.g. float or Vec3f +template +using AccType = typename util::conditional::is_index, + ChannelAccessor, DefaultReadAccessor>::type; + +/// @brief Generic template functions that return an Accessor to either an index grid or a regular grid +template +inline __hostdev__ auto getAccessor(const GridT &grid, ValueT *sideCar = nullptr) +{ + using BuildT = typename GridT::BuildType; + if constexpr(BuildTraits::is_index) { + return sideCar ? ChannelAccessor(grid, sideCar) : ChannelAccessor(grid); + } else { + static_assert(util::is_same::value, "wrong ValueT for regular GridT"); + return DefaultReadAccessor(grid); + } +} + #if 0 // This MiniGridHandle class is only included as a stand-alone example. Note that aligned_alloc is a C++17 feature! // Normally we recommend using GridHandle defined in util/GridHandle.h but this minimal implementation could be an diff --git a/nanovdb/nanovdb/examples/CMakeLists.txt b/nanovdb/nanovdb/examples/CMakeLists.txt index e769d66e9e..a1c6548b09 100644 --- a/nanovdb/nanovdb/examples/CMakeLists.txt +++ b/nanovdb/nanovdb/examples/CMakeLists.txt @@ -109,6 +109,7 @@ nanovdb_example(NAME "ex_bump_pool_buffer") nanovdb_example(NAME "ex_collide_level_set") nanovdb_example(NAME "ex_raytrace_fog_volume") nanovdb_example(NAME "ex_raytrace_level_set") +nanovdb_example(NAME "ex_raytrace_iso_surface") nanovdb_example(NAME "ex_dilate_nanovdb_cuda" OPENVDB) nanovdb_example(NAME "ex_merge_nanovdb_cuda" OPENVDB) nanovdb_example(NAME "ex_refine_nanovdb_cuda" OPENVDB) diff --git a/nanovdb/nanovdb/examples/ex_raytrace_iso_surface/common.h b/nanovdb/nanovdb/examples/ex_raytrace_iso_surface/common.h new file mode 100644 index 0000000000..8b3d6bb447 --- /dev/null +++ b/nanovdb/nanovdb/examples/ex_raytrace_iso_surface/common.h @@ -0,0 +1,187 @@ +// Copyright Contributors to the OpenVDB Project +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#define _USE_MATH_DEFINES +#include +#include +#include +#include +#include "ComputePrimitives.h" + +struct RenderOp; +template +__global__ void renderIsoSurfacePersistentKernel(RenderOp renderOp, float* image, const GridT* grid, int numPixels, int* nextPixel); + +struct RenderOp +{ + using Vec3T = nanovdb::math::Vec3; + using RayT = nanovdb::math::Ray; + int mWidth, mHeight; + float mDx, mIso, mWBBoxDimZ; + Vec3T mWBBoxCenter; + + template + RenderOp(nanovdb::GridHandle& handle, int width, int height) + { + mWidth = width; + mHeight = height; + const auto *metaData = handle.gridMetaData(); + mDx = float(metaData->voxelSize()[0]); + mIso = mDx; + mWBBoxDimZ = (float)metaData->worldBBox().dim()[2] * 2; + mWBBoxCenter = Vec3T(metaData->worldBBox().min() + metaData->worldBBox().dim() * 0.5f); + } + + template + inline float renderImage(bool useCuda, float* image, const GridT* grid) + { + using ClockT = std::chrono::high_resolution_clock; + auto t0 = ClockT::now(); + + computeForEach( + useCuda, mWidth * mHeight, 512, __FILE__, __LINE__, [this, image, grid] __hostdev__(int start, int end) { + (*this)(start, end, image, grid); + }); + computeSync(useCuda, __FILE__, __LINE__); + + auto t1 = ClockT::now(); + auto duration = std::chrono::duration_cast(t1 - t0).count() / 1000.f; + return duration; + } + + template + inline __hostdev__ void operator()(int start, int end, float* image, const GridT* grid) const + { + static_assert(nanovdb::util::is_same::value, "only works for float and OnIndex grids"); + auto acc = nanovdb::getAccessor(*grid); + for (int i = start; i < end; ++i) { + this->renderPixel(i, image, grid, acc); + } + } + + template + inline __hostdev__ void renderPixel(int i, float* image, const GridT* grid, AccT& acc) const + { + float t0, v; + nanovdb::Coord ijk; + RayT iRay = this->getIndexRay(i, grid); + if (nanovdb::math::isoCrossing(iRay, acc, ijk, v, t0, mIso)) {// intersect... + this->composite(image, i, (t0 * mDx) / (mWBBoxDimZ * 2), 1.0f); + } else { + this->composite(image, i, 0.0f, 0.0f);// write background value. + } + } + + template + inline float renderImagePersistent(float* image, const GridT* grid, int* nextPixel) const + { + int device = 0; + NANOVDB_CUDA_CHECK_ERROR(cudaGetDevice(&device), __FILE__, __LINE__); + + cudaDeviceProp properties; + NANOVDB_CUDA_CHECK_ERROR(cudaGetDeviceProperties(&properties, device), __FILE__, __LINE__); + + constexpr int blockSize = 256; + // Launch a small, fixed pool of blocks that persists on the GPU and + // pulls pixel work from a global counter instead of launching one + // logical thread per pixel up front. + int blockCount = properties.multiProcessorCount * 4; + if (blockCount < 1) blockCount = 1; + + // Reset the work queue before each timed render. The kernel advances + // this counter by one warp of pixels at a time. + NANOVDB_CUDA_CHECK_ERROR(cudaMemset(nextPixel, 0, sizeof(int)), __FILE__, __LINE__); + + using ClockT = std::chrono::high_resolution_clock; + auto t0 = ClockT::now(); + + const int numPixels = mWidth * mHeight; + renderIsoSurfacePersistentKernel<<>>(*this, image, grid, numPixels, nextPixel); + NANOVDB_CUDA_CHECK_ERROR(cudaGetLastError(), __FILE__, __LINE__); + NANOVDB_CUDA_CHECK_ERROR(cudaDeviceSynchronize(), __FILE__, __LINE__); + + auto t1 = ClockT::now(); + auto duration = std::chrono::duration_cast(t1 - t0).count() / 1000.f; + return duration; + } + + template + inline __hostdev__ RayT getIndexRay(int i, const GridT *grid) const + { + // perspective camera along Z-axis... + const uint32_t x = i % mWidth, y = i / mWidth; + const float fov = 45.f; + const float u = (float(x) + 0.5f) / mWidth; + const float v = (float(y) + 0.5f) / mHeight; + const float aspect = mWidth / float(mHeight); + const float Px = (2.f * u - 1.f) * tanf(fov / 2 * 3.14159265358979323846f / 180.f) * aspect; + const float Py = (2.f * v - 1.f) * tanf(fov / 2 * 3.14159265358979323846f / 180.f); + const Vec3T origin = mWBBoxCenter + Vec3T(0, 0, mWBBoxDimZ); + Vec3T dir(Px, Py, -1.f); + dir.normalize(); + RayT wRay(origin, dir); + return wRay.worldToIndexF(*grid);// transform the ray to the grid's index-space. + } + + inline __hostdev__ void composite(float* outImage, int offset, float value, float alpha) const + { + const uint32_t x = offset % mWidth, y = offset / mWidth; + + // checkerboard background... + const int mask = 1 << 7; + const float bg = ((x & mask) ^ (y & mask)) ? 1.0f : 0.5f; + outImage[offset] = alpha * value + (1.0f - alpha) * bg; + } + + inline void saveImage(const std::string& filename, const float* image) const + { + const auto isLittleEndian = []() -> bool { + static int x = 1; + static bool result = reinterpret_cast(&x)[0] == 1; + return result; + }; + + float scale = 1.0f; + if (isLittleEndian()) scale = -scale; + + std::fstream fs(filename, std::ios::out | std::ios::binary); + if (!fs.is_open()) throw std::runtime_error("Unable to open file: " + filename); + + fs << "Pf\n" + << mWidth << "\n" + << mHeight << "\n" + << scale << "\n"; + + for (int i = 0; i < mWidth * mHeight; ++i) { + float r = image[i]; + fs.write((char*)&r, sizeof(float)); + } + } +}; + +template +__global__ void renderIsoSurfacePersistentKernel(RenderOp renderOp, float* image, const GridT* grid, int numPixels, int* nextPixel) +{ + static_assert(nanovdb::util::is_same::value, "only works for float and OnIndex grids"); + auto acc = nanovdb::getAccessor(*grid); + const unsigned int lane = threadIdx.x & 31u; + + // Keep the fixed set of launched threads busy until all pixels have been assigned. + while (true) { + int base = 0; + // Each warp asks the shared counter for the next batch of 32 pixels. + // Only lane 0 updates the counter; __shfl_sync copies lane 0's result + // to the other lanes in the warp. + if (lane == 0) base = atomicAdd(nextPixel, 32); + base = __shfl_sync(0xFFFFFFFFu, base, 0); + + // Each lane renders one pixel from the batch: lane 0 renders base, + // lane 1 renders base + 1, and so on. + const int i = base + int(lane); + if (i >= numPixels) break; + + renderOp.renderPixel(i, image, grid, acc); + } +} diff --git a/nanovdb/nanovdb/examples/ex_raytrace_iso_surface/main.cc b/nanovdb/nanovdb/examples/ex_raytrace_iso_surface/main.cc new file mode 100644 index 0000000000..a6ee62f67b --- /dev/null +++ b/nanovdb/nanovdb/examples/ex_raytrace_iso_surface/main.cc @@ -0,0 +1,51 @@ +// Copyright Contributors to the OpenVDB Project +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +#if defined(NANOVDB_USE_CUDA) +using BufferT = nanovdb::cuda::DeviceBuffer; +#else +using BufferT = nanovdb::HostBuffer; +#endif + +extern void runNanoVDB(nanovdb::GridHandle& handle, int numIterations, int width, int height, BufferT& imageBuffer, bool usePersistentThreads); + +int main(int ac, char** av) +{ + try { + bool usePersistentThreads = false; + const char* gridName = nullptr; + for (int i = 1; i < ac; ++i) { + if (std::strcmp(av[i], "--persistent") == 0) { + usePersistentThreads = true; + } else if (!gridName) { + gridName = av[i]; + } else { + throw std::runtime_error("Usage: ex_raytrace_iso_surface [--persistent] [grid.nvdb]"); + } + } + nanovdb::GridHandle handle; + if (gridName) { + handle = nanovdb::io::readGrid(gridName); + std::cout << "Loaded NanoVDB grid[" << handle.gridMetaData()->shortGridName() << "]...\n"; + } else { + handle = nanovdb::tools::createLevelSetSphere(100.0f, nanovdb::Vec3d(-20, 0, 0), 1.0, 3.0, nanovdb::Vec3d(0), "sphere"); + } + + const int numIterations = 50; + const int width = 4096; + const int height = 4096; + BufferT imageBuffer(width * height * sizeof(float)); + + runNanoVDB(handle, numIterations, width, height, imageBuffer, usePersistentThreads); + } + catch (const std::exception& e) { + std::cerr << "An exception occurred: \"" << e.what() << "\"" << std::endl; + } + return 0; +} diff --git a/nanovdb/nanovdb/examples/ex_raytrace_iso_surface/nanovdb.cu b/nanovdb/nanovdb/examples/ex_raytrace_iso_surface/nanovdb.cu new file mode 100644 index 0000000000..1e53c65a9c --- /dev/null +++ b/nanovdb/nanovdb/examples/ex_raytrace_iso_surface/nanovdb.cu @@ -0,0 +1,66 @@ +// Copyright Contributors to the OpenVDB Project +// SPDX-License-Identifier: Apache-2.0 + +#define _USE_MATH_DEFINES +#include +#include + +#if defined(NANOVDB_USE_CUDA) +#include +using BufferT = nanovdb::cuda::DeviceBuffer; +#else +using BufferT = nanovdb::HostBuffer; +#endif +#include +#include +#include +#include + +#include "common.h" + +void runNanoVDB(nanovdb::GridHandle& handle, int numIterations, int width, int height, BufferT& imageBuffer, bool usePersistentThreads) +{ + float *h_outImage = reinterpret_cast(imageBuffer.data()); + RenderOp renderOp(handle, width, height); + + auto kernel = [&](auto *h_grid){ + float sum = 0; + for (int i = 0; i < numIterations; ++i, sum += renderOp.renderImage(false/*useCuda*/, h_outImage, h_grid)); + std::cout << "Average of " << numIterations << " renderings (NanoVDB-Host) = " << (sum/numIterations) << " ms" << std::endl; + renderOp.saveImage("raytrace_iso_surface-nanovdb-host.pfm", (float*)imageBuffer.data()); + +#if defined(NANOVDB_USE_CUDA) + handle.deviceUpload(); + using BuildT = typename nanovdb::util::remove_pointer_t::BuildType; + auto* d_grid = handle.deviceGrid(); + if (!d_grid) throw std::runtime_error("GridHandle does not contain a valid device grid"); + imageBuffer.deviceUpload(); + float* d_outImage = reinterpret_cast(imageBuffer.deviceData()); + sum = 0; + if (usePersistentThreads) { + int* d_nextPixel = nullptr; + NANOVDB_CUDA_CHECK_ERROR(cudaMalloc(&d_nextPixel, sizeof(int)), __FILE__, __LINE__); + for (int i = 0; i < numIterations; ++i, sum += renderOp.renderImagePersistent(d_outImage, d_grid, d_nextPixel)); + NANOVDB_CUDA_CHECK_ERROR(cudaFree(d_nextPixel), __FILE__, __LINE__); + std::cout << "Average of " << numIterations << " renderings (NanoVDB-Cuda-Persistent) = " << (sum/numIterations) << " ms " << std::endl; + imageBuffer.deviceDownload(); + renderOp.saveImage("raytrace_iso_surface-nanovdb-cuda-persistent.pfm", (float*)imageBuffer.data()); + } else { + for (int i = 0; i < numIterations; ++i, sum += renderOp.renderImage(true/*useCuda*/, d_outImage, d_grid)); + std::cout << "Average of " << numIterations << " renderings (NanoVDB-Cuda) = " << (sum/numIterations) << " ms " << std::endl; + imageBuffer.deviceDownload(); + renderOp.saveImage("raytrace_iso_surface-nanovdb-cuda.pfm", (float*)imageBuffer.data()); + } +#endif + };// kernel + + if (auto *h_grid = handle.grid()) { + kernel(h_grid); + } else if (auto *h_grid = handle.grid()) { + kernel(h_grid); + } else if (auto *h_grid = handle.grid()) { + kernel(h_grid); + } else { + throw std::runtime_error("GridHandle does not contain a valid device grid"); + } +}// runNanoVDB diff --git a/nanovdb/nanovdb/examples/ex_raytrace_level_set/nanovdb.cu b/nanovdb/nanovdb/examples/ex_raytrace_level_set/nanovdb.cu index 378211c602..7b92541b9d 100644 --- a/nanovdb/nanovdb/examples/ex_raytrace_level_set/nanovdb.cu +++ b/nanovdb/nanovdb/examples/ex_raytrace_level_set/nanovdb.cu @@ -20,15 +20,14 @@ using BufferT = nanovdb::HostBuffer; void runNanoVDB(nanovdb::GridHandle& handle, int numIterations, int width, int height, BufferT& imageBuffer) { - using GridT = nanovdb::FloatGrid; + using GridT = nanovdb::FloatGrid; using CoordT = nanovdb::Coord; - using RealT = float; - using Vec3T = nanovdb::math::Vec3; - using RayT = nanovdb::math::Ray; + using RealT = float; + using Vec3T = nanovdb::math::Vec3; + using RayT = nanovdb::math::Ray; auto *h_grid = handle.grid(); - if (!h_grid) - throw std::runtime_error("GridHandle does not contain a valid host grid"); + if (!h_grid) throw std::runtime_error("GridHandle does not contain a valid host grid"); float* h_outImage = reinterpret_cast(imageBuffer.data()); @@ -58,7 +57,7 @@ void runNanoVDB(nanovdb::GridHandle& handle, int numIterations, int wid float t0; CoordT ijk; float v; - if (nanovdb::math::ZeroCrossing(iRay, acc, ijk, v, t0)) { + if (nanovdb::math::zeroCrossing(iRay, acc, ijk, v, t0)) { // write distance to surface. (we assume it is a uniform voxel) float wT0 = t0 * float(grid->voxelSize()[0]); compositeOp(image, i, width, height, wT0 / (wBBoxDimZ * 2), 1.0f); diff --git a/nanovdb/nanovdb/math/HDDA.h b/nanovdb/nanovdb/math/HDDA.h index 18bb01a929..82e541f411 100644 --- a/nanovdb/nanovdb/math/HDDA.h +++ b/nanovdb/nanovdb/math/HDDA.h @@ -179,7 +179,7 @@ class HDDA Vec3T mDelta, mNext; // delta time and next time }; // class HDDA -/////////////////////////////////////////// ZeroCrossing //////////////////////////////////////////// +/////////////////////////////////////////// zeroCrossing //////////////////////////////////////////// /// @brief returns true if the ray intersects a zero-crossing at the voxel level of the grid in the accessor /// The empty-space ray-marching is performed at all levels of the tree using an @@ -187,8 +187,9 @@ class HDDA /// voxel after the intersection point, v contains the grid values at ijk, and t is set to the time of /// the intersection along the ray. template -inline __hostdev__ bool ZeroCrossing(RayT& ray, AccT& acc, Coord& ijk, typename AccT::ValueType& v, float& t) +inline __hostdev__ bool zeroCrossing(RayT& ray, AccT& acc, Coord& ijk, typename AccT::ValueType& v, float& t) { + static_assert(util::is_floating_point::value, "zeroCrossing assumed a grid with floating point values"); if (!ray.clip(acc.root().bbox()) || ray.t1() > 1e20) return false; // clip ray to bbox static const float Delta = 1.0001f; @@ -210,8 +211,42 @@ inline __hostdev__ bool ZeroCrossing(RayT& ray, AccT& acc, Coord& ijk, typename } } return false; +}// zeroCrossing + +template +[[deprecated("Use zeroCrossing(ray, acc, ijk, v, t)")]] +inline __hostdev__ bool ZeroCrossing(RayT& ray, AccT& acc, Coord& ijk, typename AccT::ValueType& v, float& t) +{ + return zeroCrossing(ray, acc, ijk, v,t); } +/////////////////////////////////////////// isoCrossing //////////////////////////////////////////// + +template +inline __hostdev__ bool isoCrossing(RayT& ray, AccT& acc, Coord& ijk, typename AccT::ValueType& v, float& t, const typename AccT::ValueType& iso = 0.0f) +{ + static_assert(util::is_floating_point::value, "isoCrossing assumed a grid with floating point values"); + if (!ray.clip(acc.root().bbox()) || ray.t1() > 1e20) return false; // clip ray to bbox + static const float Delta = 1.0001f; + ijk = RoundDown(ray.start()); // first hit of bbox + HDDA hdda(ray, acc.getDim(ijk, ray)); + const auto v0 = acc.getValue(ijk) - iso; + while (hdda.step()) { + ijk = RoundDown(ray(hdda.time() + Delta)); + hdda.update(ray, acc.getDim(ijk, ray)); + if (hdda.dim() > 1 || !acc.isActive(ijk)) continue; // either a tile value or an inactive voxel + while (hdda.step() && acc.isActive(hdda.voxel())) { // in the narrow band + v = acc.getValue(hdda.voxel()) - iso; + if (v * v0 < 0) { // zero crossing + ijk = hdda.voxel(); + t = hdda.time(); + return true; + } + } + } + return false; +}// isoCrossing + /////////////////////////////////////////// DDA //////////////////////////////////////////// /// @brief A Digital Differential Analyzer. Unlike HDDA (defined above) this DDA @@ -343,10 +378,10 @@ class DDA Vec3T mDelta, mNext; // delta time and next time }; // class DDA -/////////////////////////////////////////// ZeroCrossingNode //////////////////////////////////////////// +/////////////////////////////////////////// zeroCrossingNode //////////////////////////////////////////// template -inline __hostdev__ bool ZeroCrossingNode(RayT& ray, const NodeT& node, float v0, nanovdb::math::Coord& ijk, float& v, float& t) +inline __hostdev__ bool zeroCrossingNode(RayT& ray, const NodeT& node, float v0, nanovdb::math::Coord& ijk, float& v, float& t) { math::BBox bbox(node.origin(), node.origin() + Coord(node.dim() - 1)); @@ -376,6 +411,13 @@ inline __hostdev__ bool ZeroCrossingNode(RayT& ray, const NodeT& node, float v0, } } return false; +}// zeroCrossingNode + +template +[[deprecated("Use zeroCrossingNode(ray, node, v0, ijk, v, t)")]] +inline __hostdev__ bool ZeroCrossingNode(RayT& ray, const NodeT& node, float v0, nanovdb::math::Coord& ijk, float& v, float& t) +{ + return zeroCrossingNode(ray, node, v0, ijk, v, t); } /////////////////////////////////////////// TreeMarcher //////////////////////////////////////////// @@ -399,7 +441,7 @@ inline __hostdev__ bool firstActive(RayT& ray, AccT& acc, Coord &ijk, float& t) ijk = RoundDown( ray(t) );// update ijk } return true; -} +}// firstActive /////////////////////////////////////////// TreeMarcher //////////////////////////////////////////// diff --git a/openvdb/openvdb/CMakeLists.txt b/openvdb/openvdb/CMakeLists.txt index b91ade88e0..302f890f28 100644 --- a/openvdb/openvdb/CMakeLists.txt +++ b/openvdb/openvdb/CMakeLists.txt @@ -537,6 +537,7 @@ set(OPENVDB_LIBRARY_TOOLS_INCLUDE_FILES tools/PointScatter.h tools/PointsToMask.h tools/PoissonSolver.h + tools/PolySoupToLevelSet.h tools/PotentialFlow.h tools/Prune.h tools/RayIntersector.h diff --git a/openvdb/openvdb/Grid.h b/openvdb/openvdb/Grid.h index cb0a48e4e2..fc566b8387 100644 --- a/openvdb/openvdb/Grid.h +++ b/openvdb/openvdb/Grid.h @@ -784,6 +784,7 @@ class Grid: public GridBase /// representation of the filled box. Follow fill operations with a prune() /// operation for optimal sparseness. void sparseFill(const CoordBBox& bbox, const ValueType& value, bool active = true); + /// @brief Set all voxels within a given axis-aligned box to a constant value. /// @param bbox inclusive coordinates of opposite corners of an axis-aligned box /// @param value the value to which to set voxels within the box @@ -792,6 +793,7 @@ class Grid: public GridBase /// @note This operation generates a sparse, but not always optimally sparse, /// representation of the filled box. Follow fill operations with a prune() /// operation for optimal sparseness. + /// @details Identical to sparseFill defined above. void fill(const CoordBBox& bbox, const ValueType& value, bool active = true); /// @brief Set all voxels within a given axis-aligned box to a constant value diff --git a/openvdb/openvdb/python/app/README.md b/openvdb/openvdb/python/app/README.md new file mode 100644 index 0000000000..3bd6bb1ac9 --- /dev/null +++ b/openvdb/openvdb/python/app/README.md @@ -0,0 +1,232 @@ +# OpenVDB Python Examples + +This directory contains example scripts demonstrating the use of OpenVDB's Python bindings. + +## Available Examples + +### shrink_wrap.py + +Converts USD mesh files to OpenVDB level sets with optional mesh reconstruction. + +#### Features + +- **USD Mesh Reading**: Uses Pixar's USD library (`pxr.Usd`) to read mesh geometry +- **Level Set Conversion**: Converts polygon soup to level set volumes with LOD pyramid generation +- **Multiple Conversion Modes**: + - Voxel size-based (automatic or manual bbox) + - Dimension-based with explicit bounding box + - Supports both triangles and quads +- **Mesh Reconstruction**: Optional conversion back to mesh with adaptive polygonization +- **Output Formats**: + - VDB files with multiple LOD grids + - USD mesh files + +#### Dependencies + +```bash +# Required +pip install numpy usd-core + +# OpenVDB Python bindings must be built and available +# See main OpenVDB documentation for building Python bindings +``` + +#### Usage + +**Basic conversion to VDB** (creates `input-out.vdb`): +```bash +python shrink_wrap.py input.usd --voxel 0.1 +``` + +**Convert to VDB with custom parameters:** +```bash +python shrink_wrap.py input.usd \ + --voxel 0.05 \ + --erode 10.0 \ + --half-width 4.0 +``` + +**Use dimension-based sizing:** +```bash +python shrink_wrap.py input.usd \ + --dim 256 \ + --bbox-min -10 -10 -10 \ + --bbox-max 10 10 10 +``` + +**Convert to mesh with adaptivity** (creates `input-out.usd`): +```bash +python shrink_wrap.py input.usd \ + --to-mesh \ + --voxel 0.1 \ + --adaptivity 0.005 +``` + +**Save only finest grid:** +```bash +python shrink_wrap.py input.usd \ + --voxel 0.1 \ + --finest-only +``` + +#### Command-Line Arguments + +**Required:** +- `input.usd`: Input USD mesh file + +**Output:** +- Output filename is auto-generated from input filename: + - Default (VDB): `-out.vdb` + - With `--to-mesh`: `-out.usd` + - Multi-LOD mesh: `-out_lod0.usd`, `-out_lod1.usd`, etc. +- Output is created in the same directory as the input file + +**Conversion Mode:** +- `--to-mesh`: Convert grids back to mesh (output as USD) + +**Level Set Parameters (one required):** +- `--voxel FLOAT`: Voxel size for finest level set (required, uses minVoxelSize + bbox) +- `--dim INT`: Grid dimension for finest level (uses dimension + bbox) +- `--bbox-min X Y Z`: Bounding box minimum (optional, auto-computed if not provided) +- `--bbox-max X Y Z`: Bounding box maximum (optional, auto-computed if not provided) +- `--erode FLOAT`: Maximum deformation allowed (default: 8.0) +- `--threshold FLOAT`: Closing threshold (default: 0.0) +- `--half-width FLOAT`: Narrow band half-width in voxels (default: 3.0) + +**Mesh Conversion Parameters:** +- `--adaptivity FLOAT`: Mesh adaptivity for convertToPolygons (0-1, default: 0.0) + - 0 = high detail (no simplification) + - 1 = maximum simplification +- `--isovalue FLOAT`: Isovalue for mesh extraction (default: 0.0) + +**Output Options:** +- `--finest-only`: Only write finest grid (do not write entire LOD pyramid) + +#### Technical Details + +**LOD Pyramid Generation:** + +The script uses OpenVDB's `convertPolygonSoupToLevelSet` which generates multiple level-of-detail grids: +- Grid 0: Finest resolution (smallest voxel size) +- Grid 1+: Progressively coarser resolutions + +The LOD pyramid automatically generates multiple resolution levels based on the input parameters. + +**Two Conversion Modes:** + +1. **Voxel Size Mode**: + ```bash + --voxel 0.1 + ``` + Specifies minimum voxel size. Bounding box is auto-computed from mesh if not provided. + Can optionally specify explicit bbox: + ```bash + --voxel 0.1 --bbox-min -10 -10 -10 --bbox-max 10 10 10 + ``` + +2. **Dimension Mode**: + ```bash + --dim 256 + ``` + Uses grid dimension to compute voxel size from bounding box. Bounding box is auto-computed from mesh if not provided. + Can optionally specify explicit bbox: + ```bash + --dim 256 --bbox-min -10 -10 -10 --bbox-max 10 10 10 + ``` + +**USD I/O:** + +The script uses Pixar's USD library for both reading and writing: +- Reads arbitrary USD mesh prims +- Handles triangles, quads, and n-gons (triangulated with fan method) + +**Mesh Topology:** + +- Input: Supports mixed triangle/quad meshes and n-gons +- N-gons (faces with 5+ vertices) are automatically triangulated using fan triangulation + +#### Examples + +**Convert a simple mesh** (creates `sphere-out.vdb`): +```bash +python shrink_wrap.py sphere.usd --voxel 0.5 +``` + +**High-resolution conversion with custom parameters** (creates `detailed_mesh-out.vdb`): +```bash +python shrink_wrap.py detailed_mesh.usd \ + --voxel 0.01 \ + --erode 5.0 \ + --half-width 5.0 +``` + +**Convert to simplified mesh** (creates `input-out.usd`): +```bash +python shrink_wrap.py input.usd \ + --to-mesh \ + --voxel 0.1 \ + --adaptivity 0.005 +``` + +**Create multiple LOD meshes** (creates `input-out_lod0.usd`, `input-out_lod1.usd`, etc.): +```bash +# Without --finest-only, this creates multiple LOD files +python shrink_wrap.py input.usd \ + --to-mesh \ + --voxel 0.1 +``` + +#### Verifying Output + +**VDB files:** +```bash +# View grid metadata +vdb_print input-out.vdb + +# Visualize (if available) +vdb_view input-out.vdb +``` + +**USD files:** +```bash +# View mesh (requires usd-core with usdview) +usdview input-out.usd +``` + +#### Troubleshooting + +**Error: "pxr.Usd not found"** +```bash +pip install usd-core +``` + +**Error: "openvdb module not found"** + +Build OpenVDB Python bindings: +```bash +cd build +cmake -DCMAKE_INSTALL_PREFIX=$HOME/openvdb -DOPENVDB_BUILD_CORE=ON -DOPENVDB_BUILD_PYTHON_MODULE=ON -DOPENVDB_BUILD_VDB_LOD=ON -DOPENVDB_BUILD_VDB_RENDER=ON -DOPENVDB_BUILD_VDB_VIEW=ON -DOPENVDB_BUILD_VDB_TOOL=ON .. +make -j4 +make install +``` + +**Error: "No mesh found in USD file"** + +Check that your USD file contains a mesh prim: +```bash +usdcat input.usd # View USD contents +``` + +#### Performance Tips + +- **Voxel Size**: Larger voxel sizes = faster conversion, less memory +- **Erode Parameter**: Lower values = faster but may lose thin features +- **Closing Threshold Parameter**: Feature size for closing holes +- **Finest Only**: Use `--finest-only` if you don't need LOD pyramid +- **Adaptivity**: Higher values = faster mesh output but less detail + +#### See Also + +- OpenVDB Documentation: https://www.openvdb.org/documentation/ +- USD Documentation: https://openusd.org/ +- `vdb_tool` command-line utility for additional VDB operations diff --git a/openvdb/openvdb/python/app/shrink_wrap.py b/openvdb/openvdb/python/app/shrink_wrap.py new file mode 100755 index 0000000000..61ae105c8e --- /dev/null +++ b/openvdb/openvdb/python/app/shrink_wrap.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Shrink Wrap - USD Mesh to Level Set Conversion + +Reads a mesh from a USD file, converts it to OpenVDB level sets, +and optionally converts back to mesh or saves as .vdb file. + +Dependencies: + - openvdb (Python bindings) + - numpy + - usd-core (install with: pip install usd-core) + +Example usage: + # Convert USD mesh to VDB level set (creates input-out.vdb) + python shrink_wrap.py input.usd --voxel 0.1 + + # Convert to mesh with adaptivity (creates input-out.usd) + python shrink_wrap.py input.usd --to-mesh --voxel 0.1 --adaptivity 0.005 + + # Use dimension-based sizing + python shrink_wrap.py input.usd --dim 256 +""" + +import argparse +import sys +import os +import numpy as np + +# Import required dependencies with helpful error messages +try: + import openvdb +except ImportError: + print("Error: openvdb module not found.", file=sys.stderr) + print("Build OpenVDB Python bindings with: cmake -DOPENVDB_BUILD_PYTHON_MODULE=ON", file=sys.stderr) + sys.exit(1) + +try: + from usd_utils import USDInterface +except ImportError: + print("Error: usd_utils module not found.", file=sys.stderr) + print("Make sure usd_utils.py is in openvdb/openvdb/python/examples/", file=sys.stderr) + print("Install USD support with: pip install usd-core", file=sys.stderr) + sys.exit(1) + + +def generate_output_filename(input_path, to_mesh=False): + """Generate output filename from input path. + + Output is created in the same directory as the input file. + + Args: + input_path: Input USD file path + to_mesh: Whether converting to mesh (determines extension) + + Returns: + Output filename with format: /-out.vdb or .usd + """ + # Get directory and base filename without extension + input_dir = os.path.dirname(input_path) + base = os.path.splitext(os.path.basename(input_path))[0] + + # Determine extension based on mode + ext = "usd" if to_mesh else "vdb" + + # Generate output filename in same directory as input + output_filename = f"{base}-out.{ext}" + if input_dir: + output_filename = os.path.join(input_dir, output_filename) + + return output_filename + + +def read_mesh_from_usd(usd_path, verbose=False): + """Read mesh data from USD file using USDInterface. + + This function loads all meshes from the USD file, applies world transformations, + merges them, and triangulates all faces. + + Args: + usd_path: Path to USD file + verbose: Whether to print loading information (default: False) + + Returns: + Tuple of (vertices, triangles) where: + - vertices: (N, 3) float32 numpy array + - triangles: (M, 3) numpy array + + Raises: + ValueError: If no mesh found in USD file or if USD loading fails + """ + try: + usd_interface = USDInterface(usd_path, verbose=verbose) + except Exception as e: + raise ValueError(f"Failed to load USD file: {e}") + + vertices = usd_interface.merged_verts.astype(np.float32) + triangles = usd_interface.merged_faces.astype(np.uint32) + + if len(vertices) == 0 or len(triangles) == 0: + raise ValueError(f"No valid mesh data found in {usd_path}") + + return vertices, triangles + + +def polygon_soup_to_level_set(vertices, triangles, quads=None, + min_voxel_size=None, dim=None, + bbox_min=None, bbox_max=None, + erode=8.0, thres=0.0, half_width=3.0): + """Convert polygon soup to level set grids (LOD pyramid). + + Args: + vertices: (N, 3) float32 numpy array + triangles: (M, 3) uint32 numpy array or None + quads: (K, 4) uint32 numpy array or None + min_voxel_size: Minimum voxel size for finest grid (required for voxel mode) + dim: Grid dimension (alternative to min_voxel_size) + bbox_min: Bounding box minimum (optional, tuple of 3 floats) + bbox_max: Bounding box maximum (optional, tuple of 3 floats) + erode: Maximum deformation allowed (default: 8.0) + thres: Closing threshold (default: 0.0) + half_width: Narrow band half-width in voxels (default: 3.0) + + Returns: + List of openvdb.FloatGrid objects (finest to coarsest) + + Raises: + ValueError: If no triangles or quads provided, or if required parameters missing + """ + # Validate inputs + if triangles is None and quads is None: + raise ValueError("Must provide triangles or quads (or both)") + + # Auto-compute bbox if not provided + if bbox_min is None or bbox_max is None: + bbox_min = tuple(vertices.min(axis=0)) + bbox_max = tuple(vertices.max(axis=0)) + + # Choose overload based on provided parameters + if dim is not None: + # Overload 2: dimension + bbox + grids = openvdb.FloatGrid.convertPolygonSoupToLevelSet( + dim=dim, + bboxMin=bbox_min, + bboxMax=bbox_max, + points=vertices, + triangles=triangles, + quads=quads, + erode=erode, + thres=thres, + halfWidth=half_width + ) + + elif min_voxel_size is not None: + # Overload 3: minVoxelSize + bbox + grids = openvdb.FloatGrid.convertPolygonSoupToLevelSet( + minVoxelSize=min_voxel_size, + bboxMin=bbox_min, + bboxMax=bbox_max, + points=vertices, + triangles=triangles, + quads=quads, + erode=erode, + thres=thres, + halfWidth=half_width + ) + + else: + raise ValueError("Must specify either --voxel or --dim parameter") + + return grids + + +def levelset_to_mesh(grid, isovalue=0.0, adaptivity=0.0): + """Convert level set grid back to mesh. + + Args: + grid: openvdb.FloatGrid + isovalue: Isosurface value (default: 0.0 for zero crossing) + adaptivity: Mesh simplification (0=none, 1=max, default: 0.0) + + Returns: + (points, triangles, quads) numpy arrays + """ + points, triangles, quads = grid.convertToPolygons( + isovalue=isovalue, + adaptivity=adaptivity + ) + return points, triangles, quads + + +def write_grids_to_vdb(vdb_path, grids, grid_names=None): + """Write list of grids to .vdb file. + + Args: + vdb_path: Output .vdb file path + grids: List of openvdb grids + grid_names: Optional list of names for grids (default: "grid_0", "grid_1", ...) + + """ + # Set grid names + if grid_names is None: + grid_names = [f"grid_{i}" for i in range(len(grids))] + + for grid, name in zip(grids, grid_names): + grid.name = name + + # Write all grids to single file + openvdb.write(vdb_path, grids) + + +def main(): + parser = argparse.ArgumentParser( + description="Shrink Wrap - Convert USD mesh to OpenVDB level sets", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Convert USD mesh to VDB with voxel size (creates input-out.vdb) + %(prog)s input.usd --voxel 0.1 + + # Convert to mesh with adaptivity (creates input-out.usd) + %(prog)s input.usd --to-mesh --voxel 0.1 --adaptivity 0.005 + + # Use dimension-based sizing + %(prog)s input.usd --dim 256 + """ + ) + + # Input/output + parser.add_argument("input_usd", help="Input USD mesh file") + + # Conversion mode + parser.add_argument("--to-mesh", action="store_true", + help="Convert grids back to mesh (output as USD)") + + # Level set parameters + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--voxel", type=float, + help="Voxel size for level set (required, uses minVoxelSize + bbox)") + group.add_argument("--dim", type=int, + help="Grid dimension for finest level (uses dimension + bbox)") + + parser.add_argument("--bbox-min", type=float, nargs=3, metavar=("X", "Y", "Z"), + help="Bounding box minimum (optional, auto-computed from mesh if not provided)") + parser.add_argument("--bbox-max", type=float, nargs=3, metavar=("X", "Y", "Z"), + help="Bounding box maximum (optional, auto-computed from mesh if not provided)") + + parser.add_argument("--erode", type=float, default=8.0, + help="Maximum deformation allowed (default: 8.0)") + parser.add_argument("--threshold", type=float, default=0.0, + help="Closing threshold (default: 0.0)") + parser.add_argument("--half-width", type=float, default=3.0, + help="Narrow band half-width in voxels (default: 3.0)") + + # Mesh conversion parameters + parser.add_argument("--adaptivity", type=float, default=0.0, + help="Mesh adaptivity for convertToPolygons (0-1, default: 0.0)") + parser.add_argument("--isovalue", type=float, default=0.0, + help="Isovalue for mesh extraction (default: 0.0)") + + # Output options + parser.add_argument("--finest-only", action="store_true", + help="Only write finest grid (do not write entire LOD pyramid)") + + args = parser.parse_args() + + # Validate inputs + if not os.path.exists(args.input_usd): + print(f"Error: Input file not found: {args.input_usd}", file=sys.stderr) + return 1 + + # Generate output filename based on input and mode + args.output = generate_output_filename(args.input_usd, args.to_mesh) + print(f"Output file: {args.output}") + + # Step 1: Read mesh from USD + print(f"Reading mesh from {args.input_usd}...") + try: + vertices, triangles = read_mesh_from_usd(args.input_usd, verbose=False) + except Exception as e: + print(f"Error reading USD file: {e}", file=sys.stderr) + return 1 + + print(f" Vertices: {len(vertices)}") + print(f" Triangles: {len(triangles)}") + + # Step 2: Convert to level sets + print("\nConverting to level sets...") + + bbox_min = tuple(args.bbox_min) if args.bbox_min else None + bbox_max = tuple(args.bbox_max) if args.bbox_max else None + + try: + grids = polygon_soup_to_level_set( + vertices, triangles, + min_voxel_size=args.voxel, + dim=args.dim, + bbox_min=bbox_min, + bbox_max=bbox_max, + erode=args.erode, + thres=args.threshold, + half_width=args.half_width + ) + except Exception as e: + print(f"Error converting to level set: {e}", file=sys.stderr) + return 1 + + print(f" Generated {len(grids)} LOD grid(s):") + for i, grid in enumerate(grids): + voxel_size = grid.transform.voxelSize()[0] + active_count = grid.activeVoxelCount() + print(f" Grid {i}: voxel_size={voxel_size:.4f}, active_voxels={active_count:,}") + + # Step 3: Output + if args.to_mesh: + # Convert back to mesh and save as USD + print(f"\nConverting grids to mesh...") + + if args.finest_only: + grids_to_convert = [grids[0]] + else: + grids_to_convert = grids + + try: + # Convert each grid and write separate USD files (or combine) + if len(grids_to_convert) == 1: + grid = grids_to_convert[0] + points, tris, quads_out = levelset_to_mesh(grid, args.isovalue, args.adaptivity) + + print(f" Output mesh: {len(points)} vertices, {len(tris)} triangles, {len(quads_out)} quads") + + # Write using USDInterface (preserves quads) + USDInterface.write_file(args.output, points, tris, quads_out) + print(f"\nWrote mesh to {args.output}") + else: + # Multiple grids - write each to separate USD + base, ext = os.path.splitext(args.output) + for i, grid in enumerate(grids_to_convert): + points, tris, quads_out = levelset_to_mesh(grid, args.isovalue, args.adaptivity) + output_path = f"{base}_lod{i}{ext}" + + USDInterface.write_file(output_path, points, tris, quads_out, mesh_name=f"Mesh_LOD{i}") + print(f" Wrote LOD {i} to {output_path}") + except Exception as e: + print(f"Error converting to mesh: {e}", file=sys.stderr) + return 1 + else: + # Save as .vdb file + print(f"\nWriting grids to {args.output}...") + + if args.finest_only: + grids_to_save = [grids[0]] + grid_names = ["level_set"] + else: + grids_to_save = grids + grid_names = [f"level_set_lod{i}" for i in range(len(grids))] + + try: + write_grids_to_vdb(args.output, grids_to_save, grid_names) + print(f" Wrote {len(grids_to_save)} grid(s) to {args.output}") + except Exception as e: + print(f"Error writing VDB file: {e}", file=sys.stderr) + return 1 + + print("\nDone!") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/openvdb/openvdb/python/app/usd_utils.py b/openvdb/openvdb/python/app/usd_utils.py new file mode 100644 index 0000000000..2dd3fd00ec --- /dev/null +++ b/openvdb/openvdb/python/app/usd_utils.py @@ -0,0 +1,273 @@ +import numpy as np + + +have_pxr_lib = False +try: + # conditionally load so it is an optional dependency + import pxr # USD support + from pxr import Usd, UsdGeom + have_pxr_lib = True +except ImportError: + pass + +# See https://github.com/ColinKennedy/USD-Cookbook/blob/master/tricks/traverse_instanced_prims/README.md +def traverse_instanced_children(prim): + """Get every Prim child beneath `prim`, even if `prim` is instanced. + + Important: + If `prim` is instanced, any child that this function yields will + be an instance proxy. + + Args: + prim (`pxr.Usd.Prim`): Some Prim to check for children. + + Yields: + `pxr.Usd.Prim`: The children of `prim`. + + """ + for child in prim.GetFilteredChildren(Usd.TraverseInstanceProxies()): + yield child + + for subchild in traverse_instanced_children(child): + yield subchild + + +def simple_triangulate_faces(face_vertex_counts, face_vertex_inds_flat): + """ + Given a polygonal mesh (with arbitrary degree faces) in a flat list representation, triangulates the faces, and returns a new (Tx3) numpy array. + + This is a naive pure-python for-loop, so it will be slow for large meshes. + TODO write some clever numpy or something. + """ + + tri_faces = [] + i_start = 0 + for i_face in range(len(face_vertex_counts)): + D = face_vertex_counts[i_face] + i_end = i_start + D + for k in range(1, D - 1): + # fan tesselation with first vertex as root + tri_faces.append((face_vertex_inds_flat[i_start], + face_vertex_inds_flat[i_start + k], + face_vertex_inds_flat[i_start + k + 1])) + + i_start = i_end + + return np.array(tri_faces, dtype=face_vertex_inds_flat.dtype) + + +def merge_meshes(verts_list, faces_list): + """Merge multiple meshes into a single mesh. + + Concatenates vertices and adjusts face indices to account for the offset + from combining vertex arrays. + + Args: + verts_list: List of vertex arrays, each (N_i, 3) + faces_list: List of face index arrays, each (M_i, 3) or (M_i, 4) + + Returns: + Tuple of (combined_verts, combined_faces): + - combined_verts: (N_total, 3) array with all vertices + - combined_faces: (M_total, 3/4) array with adjusted face indices + """ + N = len(verts_list) + + # Shift face indices to account for vertex concatenation + # Each mesh's face indices need to be offset by the total number + # of vertices from all previous meshes + v_sum = 0 # Running sum of vertex counts + faces_list_shifted = [] + for i in range(N): + # Add vertex offset to all face indices in this mesh + faces_list_shifted.append(faces_list[i] + v_sum) + v_sum += verts_list[i].shape[0] + + # Concatenate all vertices and adjusted faces + combined_verts = np.concatenate(verts_list, axis=0) + combined_faces = np.concatenate(faces_list_shifted, axis=0) + + return combined_verts, combined_faces + + +def get_world_transform(prim): + """ + Get the local transformation of a prim using Xformable. + See https://graphics.pixar.com/usd/release/api/class_usd_geom_xformable.html + Args: + prim: The prim to calculate the world transformation. + Returns: + 4x4 world object --> world transformation matrix + """ + + # https://docs.omniverse.nvidia.com/prod_kit/prod_kit/programmer_ref/usd/transforms/get-world-transforms.html + + xform = UsdGeom.Xformable(prim) + time = Usd.TimeCode.Default( + ) # The time at which we compute the bounding box + world_transform = xform.ComputeLocalToWorldTransform(time) + mat = np.array(world_transform) + return mat + + # translation: Gf.Vec3d = world_transform.ExtractTranslation() + # rotation: Gf.Rotation = world_transform.ExtractRotation() + # scale: Gf.Vec3d = Gf.Vec3d(*(v.GetLength() for v in world_transform.ExtractRotationMatrix())) + # return translation, rotation, scale + + +class USDInterface: + + def __init__(self, usd_path, verbose=True): + + if not have_pxr_lib: + raise ValueError("USD support is not available, try `pip install usd-core`") + + self.usd_path = usd_path + self.verbose = verbose + self.load_file() + + def load_file(self): + """Load and process all meshes from the USD file. + + This method: + 1. Opens the USD stage and discovers all mesh prims (including instanced) + 2. Extracts vertices and faces from each mesh + 3. Triangulates all n-gon faces using fan tessellation + 4. Applies world transformations to vertices + 5. Merges all meshes into single arrays + 6. Converts coordinate system from Z-up to Y-up + + Sets instance variables: + self.stage: USD stage object + self.root_prim: Root prim of the stage + self.mesh_prims: List of UsdGeom.Mesh prims found + self.merged_verts: (N, 3) array of all vertices in world space + self.merged_faces: (M, 3) array of all triangle faces + self.merged_faces_source_prim: (M,) array indicating source prim index for each face + """ + # Open the USD file + if self.verbose: + print(f"USD: loading {self.usd_path}") + self.stage = Usd.Stage.Open(self.usd_path) + self.root_prim = self.stage.GetPseudoRoot() + + # Discover all mesh prims in the scene hierarchy + # traverse_instanced_children handles instanced geometry properly + self.mesh_prims = [] + for prim in traverse_instanced_children(self.root_prim): + if self.verbose: + print(f" USD traversing: {prim.GetPath()}") + + if prim.IsA(UsdGeom.Mesh): + self.mesh_prims.append(prim) + + if self.verbose: + print(f"USD: found {len(self.mesh_prims)} mesh prims in file") + + # Collect vertices and faces from all meshes + verts_list = [] + faces_list = [] + face_source_prim_list = [] # Track which prim each face came from + + for ip, p in enumerate(self.mesh_prims): + if self.verbose: + print(f" mesh prim: {p.GetPath()}") + mesh = UsdGeom.Mesh(p) + + # Extract face topology (USD stores as flat arrays) + # face_vertex_counts: [3, 4, 3, ...] - vertices per face + # face_vertex_inds_flat: [0, 1, 2, 0, 2, 3, 4, ...] - flattened indices + face_vertex_counts = np.array(mesh.GetFaceVertexCountsAttr().Get(), dtype=np.longlong) + face_vertex_inds_flat = np.array(mesh.GetFaceVertexIndicesAttr().Get(), dtype=np.longlong) + + # Triangulate all faces (converts quads/n-gons to triangles) + tri_faces = simple_triangulate_faces(face_vertex_counts, face_vertex_inds_flat) + vertices = np.array(mesh.GetPointsAttr().Get()) + + # Apply world transformation to get vertices in world space + # This handles translation, rotation, scale, and parenting + tmat = get_world_transform(p) + # Convert to homogeneous coordinates (add w=1) + vertices_homog = np.concatenate((vertices, np.ones_like(vertices[:, 0:1])), axis=-1) + # Apply 4x4 transform and extract xyz + vertices = (vertices_homog @ tmat)[:, :3] + + verts_list.append(vertices) + faces_list.append(tri_faces) + + # Tag each face with its source prim index for debugging + face_source_prim = np.zeros_like(tri_faces[:, 0]) + ip + face_source_prim_list.append(face_source_prim) + + # Merge all meshes into single vertex/face arrays + self.merged_verts, self.merged_faces = merge_meshes(verts_list, faces_list) + self.merged_faces_source_prim = np.concatenate(face_source_prim_list, axis=0) + + # Convert from Z-up (USD convention) to Y-up (OpenVDB convention) + # np.roll shifts axis order: [x, y, z] -> [z, x, y] -> use as [x, y, z] in Y-up + self.merged_verts = np.roll(self.merged_verts, 2, axis=-1) + + if False: + import polyscope as ps # for debugging only + ps.init() + ps.set_up_dir("y_up") + ps_mesh = ps.register_surface_mesh("usd mesh", self.merged_verts, self.merged_faces) + ps_mesh.add_scalar_quantity("source prim", self.merged_faces_source_prim, defined_on='faces') + ps.show() + + if self.verbose: + print(f"USD: loaded full mesh of {self.merged_verts.shape[0]} verts and {self.merged_faces.shape[0]} faces.") + + @staticmethod + def write_file(usd_path, points, triangles=None, quads=None, mesh_name="Mesh"): + """Write mesh to USD file using Pixar's USD library. + + Preserves both triangles and quads without triangulation. + + Args: + usd_path: Output USD file path + points: (N, 3) numpy array of vertices + triangles: (M, 3) numpy array or None + quads: (K, 4) numpy array or None + mesh_name: Name for the mesh prim (default: "Mesh") + + Raises: + ValueError: If USD support not available or no triangles/quads provided + """ + if not have_pxr_lib: + raise ValueError("USD support is not available, try `pip install usd-core`") + + from pxr import Vt, Sdf + + # Create new stage + stage = Usd.Stage.CreateNew(usd_path) + + # Create mesh prim + mesh_path = Sdf.Path(f"/{mesh_name}") + mesh = UsdGeom.Mesh.Define(stage, mesh_path) + + # Set vertices + mesh.GetPointsAttr().Set(Vt.Vec3fArray.FromNumpy(points)) + + # Combine triangles and quads into face data + face_vertex_indices = [] + face_vertex_counts = [] + + if triangles is not None and len(triangles) > 0: + for tri in triangles: + face_vertex_indices.extend([int(x) for x in tri]) + face_vertex_counts.append(3) + + if quads is not None and len(quads) > 0: + for quad in quads: + face_vertex_indices.extend([int(x) for x in quad]) + face_vertex_counts.append(4) + + if not face_vertex_indices: + raise ValueError("No triangles or quads provided") + + mesh.GetFaceVertexIndicesAttr().Set(Vt.IntArray(face_vertex_indices)) + mesh.GetFaceVertexCountsAttr().Set(Vt.IntArray(face_vertex_counts)) + + # Save stage + stage.GetRootLayer().Save() diff --git a/openvdb/openvdb/python/pyGrid.h b/openvdb/openvdb/python/pyGrid.h index ba35ce961f..676d099c6e 100644 --- a/openvdb/openvdb/python/pyGrid.h +++ b/openvdb/openvdb/python/pyGrid.h @@ -19,6 +19,7 @@ #include #include #include // for tools::volumeToMesh() +#include #include #include #include // for math::isExactlyEqual() @@ -564,6 +565,116 @@ meshToSignedDistanceField( } } +/// @brief Call tools::polySoupToLevelSet() with dimension and bounding box +template +inline nb::list +polySoupToLevelSetFromDimension( + int dim, + const Vec3f& bboxMin, + const Vec3f& bboxMax, + nb::ndarray, nb::device::cpu> pointsObj, + std::optional, nb::device::cpu>>& trianglesObj, + std::optional, nb::device::cpu>>& quadsObj, + float erode, + float thres, + float halfWidth) +{ + // Extract vertices + std::vector points(pointsObj.shape(0)); + for (size_t i = 0; i < pointsObj.shape(0); ++i) + points[i] = Vec3s(pointsObj(i, 0), pointsObj(i, 1), pointsObj(i, 2)); + + // Extract triangles + std::vector triangles; + if (trianglesObj) { + triangles.resize(trianglesObj->shape(0)); + for (size_t i = 0; i < trianglesObj->shape(0); ++i) + triangles[i] = Vec3I((*trianglesObj)(i, 0), (*trianglesObj)(i, 1), (*trianglesObj)(i, 2)); + } + + // Extract quads + std::vector quads; + if (quadsObj) { + quads.resize(quadsObj->shape(0)); + for (size_t i = 0; i < quadsObj->shape(0); ++i) + quads[i] = Vec4I((*quadsObj)(i, 0), (*quadsObj)(i, 1), (*quadsObj)(i, 2), (*quadsObj)(i, 3)); + } + + // Construct BBox internally from min/max parameters + math::BBox bbox(bboxMin, bboxMax); + + // Create ShrinkWrapLimit functor with user parameters + tools::ShrinkWrapLimit shrinkWrap(erode, thres); + + // Call C++ function with dimension overload + std::vector grids = + tools::polySoupToLevelSet( + dim, bbox, points, triangles, quads, + shrinkWrap, halfWidth); + + // Convert to Python list + nb::list result; + for (const auto& grid : grids) { + result.append(grid); + } + return result; +} + +/// @brief Call tools::polySoupToLevelSet() with minVoxelSize and bounding box +template +inline nb::list +polySoupToLevelSetFromMinVoxelSize( + float minVoxelSize, + const Vec3f& bboxMin, + const Vec3f& bboxMax, + nb::ndarray, nb::device::cpu> pointsObj, + std::optional, nb::device::cpu>>& trianglesObj, + std::optional, nb::device::cpu>>& quadsObj, + float erode, + float thres, + float halfWidth) +{ + // Extract vertices + std::vector points(pointsObj.shape(0)); + for (size_t i = 0; i < pointsObj.shape(0); ++i) + points[i] = Vec3s(pointsObj(i, 0), pointsObj(i, 1), pointsObj(i, 2)); + + // Extract triangles + std::vector triangles; + if (trianglesObj) { + triangles.resize(trianglesObj->shape(0)); + for (size_t i = 0; i < trianglesObj->shape(0); ++i) + triangles[i] = Vec3I((*trianglesObj)(i, 0), (*trianglesObj)(i, 1), (*trianglesObj)(i, 2)); + } + + // Extract quads + std::vector quads; + if (quadsObj) { + quads.resize(quadsObj->shape(0)); + for (size_t i = 0; i < quadsObj->shape(0); ++i) + quads[i] = Vec4I((*quadsObj)(i, 0), (*quadsObj)(i, 1), (*quadsObj)(i, 2), (*quadsObj)(i, 3)); + } + + // Construct BBox internally from min/max parameters + math::BBox bbox(bboxMin, bboxMax); + + // Create ShrinkWrapLimit functor with user parameters + tools::ShrinkWrapLimit shrinkWrap(erode, thres); + + // Call C++ function with minVoxelSize + bbox overload + std::vector grids = + tools::polySoupToLevelSet( + minVoxelSize, bbox, points, triangles, quads, + shrinkWrap, halfWidth); + + // Convert to Python list + nb::list result; + for (const auto& grid : grids) { + result.append(grid); + } + return result; +} + template::value>* = nullptr> inline std::tuple, nb::ndarray > volumeToQuadMesh(const GridType&, double) @@ -1291,10 +1402,62 @@ exportGrid(nb::module_ m) "Either the triangle or the quad array may be empty or None.\n" "The resulting volume will have the given transform (or the identity\n" "transform if no transform is given) and a narrow band width of\n" - "exBandWidth exterior voxels and inBandWidth interior voxels.") - + "exBandWidth exterior voxels and inBandWidth interior voxels."); + + // Add polySoupToLevelSet methods only for floating-point grids + if constexpr (std::is_floating_point::value) { + typedGridClass + .def_static("convertPolygonSoupToLevelSet", + &pyGrid::polySoupToLevelSetFromDimension, + nb::arg("dim"), + nb::arg("bboxMin"), + nb::arg("bboxMax"), + nb::arg("points"), + nb::arg("triangles") = nb::none(), + nb::arg("quads") = nb::none(), + nb::arg("erode") = 8.0f, + nb::arg("thres") = 0.0f, + nb::arg("halfWidth") = float(LEVEL_SET_HALF_WIDTH), + "Generate LOD family from dimension and bounding box.\n\n" + "Args:\n" + " dim: Largest voxel dimension for finest grid\n" + " bboxMin: Minimum corner of bounding box (tuple or Vec3)\n" + " bboxMax: Maximum corner of bounding box (tuple or Vec3)\n" + " points: Nx3 array of vertex positions\n" + " triangles: Mx3 array of triangle indices (optional)\n" + " quads: Kx4 array of quad indices (optional)\n" + " erode: Maximum deformation allowed (default: 8.0)\n" + " thres: Engineering threshold (default: 0.0)\n" + " halfWidth: Half-width of narrow band (default: 3.0)\n\n" + "Returns:\n" + " List of grids from finest to coarsest resolution") + .def_static("convertPolygonSoupToLevelSet", + &pyGrid::polySoupToLevelSetFromMinVoxelSize, + nb::arg("minVoxelSize"), + nb::arg("bboxMin"), + nb::arg("bboxMax"), + nb::arg("points"), + nb::arg("triangles") = nb::none(), + nb::arg("quads") = nb::none(), + nb::arg("erode") = 8.0f, + nb::arg("thres") = 0.0f, + nb::arg("halfWidth") = float(LEVEL_SET_HALF_WIDTH), + "Generate LOD family from minVoxelSize and bounding box.\n\n" + "Args:\n" + " minVoxelSize: Smallest voxel size for finest grid\n" + " bboxMin: Minimum corner of bounding box (tuple or Vec3)\n" + " bboxMax: Maximum corner of bounding box (tuple or Vec3)\n" + " points: Nx3 array of vertex positions\n" + " triangles: Mx3 array of triangle indices (optional)\n" + " quads: Kx4 array of quad indices (optional)\n" + " erode: Maximum deformation allowed (default: 8.0)\n" + " thres: Engineering threshold (default: 0.0)\n" + " halfWidth: Half-width of narrow band (default: 3.0)\n\n" + "Returns:\n" + " List of grids from finest to coarsest resolution"); + } - .def("prune", &pyGrid::prune, + typedGridClass.def("prune", &pyGrid::prune, nb::arg("tolerance") = 0, "Remove nodes whose values all have the same active state and are equal to within a given tolerance.") .def("pruneInactive", &pyGrid::pruneInactive, diff --git a/openvdb/openvdb/python/test/TestOpenVDB.py b/openvdb/openvdb/python/test/TestOpenVDB.py index c1dcef2188..e3678803bc 100644 --- a/openvdb/openvdb/python/test/TestOpenVDB.py +++ b/openvdb/openvdb/python/test/TestOpenVDB.py @@ -796,6 +796,84 @@ def testMeshConversion(self): self.assertTrue(98 < pmax[2] < 102) + def testConvertPolygonSoupToLevelSet(self): + # Test polygon soup to LOD level set conversion. + + # Generate the vertices of a cube. + cubeVertices = [(x, y, z) for x in (0, 100) for y in (0, 100) for z in (0, 100)] + cubePoints = np.array(cubeVertices, dtype=np.float32) + + # Generate the faces of a cube as quads. + cubeQuads = np.array([ + (0, 1, 3, 2), # left + (0, 2, 6, 4), # front + (4, 6, 7, 5), # right + (5, 7, 3, 1), # back + (2, 3, 7, 6), # top + (0, 4, 5, 1), # bottom + ], dtype=np.uint32) + + minVoxelSize = 2.0 + halfWidth = 3.0 + + # Only scalar, floating-point grids support convertPolygonSoupToLevelSet() + # (and the OpenVDB module might have been compiled without DoubleGrid support). + for gridType in [n for n in openvdb.GridTypes + if n.__name__ in ('FloatGrid', 'DoubleGrid')]: + + # Test dimension + bboxMin + bboxMax overload + bboxMin = (0.0, 0.0, 0.0) + bboxMax = (100.0, 100.0, 100.0) + + grids_bbox1 = gridType.convertPolygonSoupToLevelSet( + dim=128, + bboxMin=bboxMin, + bboxMax=bboxMax, + points=cubePoints, + quads=cubeQuads, + halfWidth=halfWidth) + + self.assertIsInstance(grids_bbox1, list) + self.assertGreater(len(grids_bbox1), 0) + + # Verify grids have correct properties + for grid in grids_bbox1: + self.assertIsInstance(grid, gridType) + self.assertGreater(grid.activeVoxelCount(), 0) + voxelSize = grid.transform.voxelSize()[0] + self.assertAlmostEqual(grid.background, halfWidth * voxelSize, delta=0.01) + + # Test minVoxelSize + bboxMin + bboxMax overload + grids_bbox2 = gridType.convertPolygonSoupToLevelSet( + minVoxelSize=minVoxelSize, + bboxMin=bboxMin, + bboxMax=bboxMax, + points=cubePoints, + quads=cubeQuads, + halfWidth=halfWidth) + + self.assertIsInstance(grids_bbox2, list) + self.assertGreater(len(grids_bbox2), 0) + + # First grid should have the specified minVoxelSize + if len(grids_bbox2) > 0: + firstVoxelSize = grids_bbox2[0].transform.voxelSize()[0] + self.assertAlmostEqual(firstVoxelSize, minVoxelSize, delta=0.01) + + # Verify each grid has correct properties + for grid in grids_bbox2: + self.assertIsInstance(grid, gridType) + self.assertGreater(grid.activeVoxelCount(), 0) + voxelSize = grid.transform.voxelSize()[0] + self.assertAlmostEqual(grid.background, halfWidth * voxelSize, delta=0.01) + + # Boolean-valued grids should not have convertPolygonSoupToLevelSet() + self.assertFalse(hasattr(openvdb.BoolGrid, 'convertPolygonSoupToLevelSet')) + + # Vector-valued grids should not have convertPolygonSoupToLevelSet() + self.assertFalse(hasattr(openvdb.Vec3SGrid, 'convertPolygonSoupToLevelSet')) + + if __name__ == '__main__': print('Testing %s' % os.path.dirname(openvdb.__file__)) sys.stdout.flush() diff --git a/openvdb/openvdb/tools/FastSweeping.h b/openvdb/openvdb/tools/FastSweeping.h index b7d652a217..74c7294548 100644 --- a/openvdb/openvdb/tools/FastSweeping.h +++ b/openvdb/openvdb/tools/FastSweeping.h @@ -90,7 +90,7 @@ enum class FastSweepingDomain { /// Each iteration performs 2^3 = 8 individual sweeps. /// /// @note Strictly speaking a fog volume is normalized to the range [0,1] but this -/// method accepts a scalar volume with an arbritary range, as long as the it +/// method accepts a scalar volume with an arbitrary range, as long as the it /// includes the @a isoValue. /// /// @details Topology of output grid is identical to that of the input grid, except @@ -180,7 +180,7 @@ sdfToSdf(const GridT &sdfGrid, /// is supplied as an argument for @a mode. /// /// @note Strictly speaking a fog volume is normalized to the range [0,1] but this -/// method accepts a scalar volume with an arbritary range, as long as the it +/// method accepts a scalar volume with an arbitrary range, as long as the it /// includes the @a isoValue. /// /// @details Topology of output grid is identical to that of the input grid, except @@ -298,7 +298,7 @@ sdfToExt(const SdfGridT &sdfGrid, /// is supplied as an argument for @a mode. /// /// @note Strictly speaking a fog volume is normalized to the range [0,1] but this -/// method accepts a scalar volume with an arbritary range, as long as the it +/// method accepts a scalar volume with an arbitrary range, as long as the it /// includes the @a isoValue. /// /// @details Topology of output grids are identical to that of the input grid, except @@ -361,7 +361,7 @@ fogToSdfAndExt(const FogGridT &fogGrid, /// is supplied as an argument for @a mode. /// /// @note Strictly speaking a fog volume is normalized to the range [0,1] but this -/// method accepts a scalar volume with an arbritary range, as long as the it +/// method accepts a scalar volume with an arbitrary range, as long as the it /// includes the @a isoValue. /// /// @details Topology of output grids are identical to that of the input grid, except diff --git a/openvdb/openvdb/tools/LevelSetFilter.h b/openvdb/openvdb/tools/LevelSetFilter.h index bc1a425815..3746e7ce0d 100644 --- a/openvdb/openvdb/tools/LevelSetFilter.h +++ b/openvdb/openvdb/tools/LevelSetFilter.h @@ -16,7 +16,7 @@ #define OPENVDB_TOOLS_LEVELSETFILTER_HAS_BEEN_INCLUDED #include "LevelSetTracker.h" -#include "Interpolation.h" +#include "Interpolation.h"// for tools::AlphaMask #include // for std::max() #include #include @@ -173,7 +173,7 @@ class LevelSetFilter : public LevelSetTracker using BufferT = typename tree::LeafManager::BufferType; using LeafRange = typename tree::LeafManager::LeafRange; using LeafIterT = typename LeafRange::Iterator; - using AlphaMaskT = tools::AlphaMask; + using AlphaMaskT = tools::AlphaMask;// defined in tools/Interpolation.h Filter(LevelSetFilter* parent, const MaskType* mask) : mParent(parent), mMask(mask) {} Filter(const Filter&) = default; diff --git a/openvdb/openvdb/tools/LevelSetUtil.h b/openvdb/openvdb/tools/LevelSetUtil.h index 012da732ec..b93906c751 100644 --- a/openvdb/openvdb/tools/LevelSetUtil.h +++ b/openvdb/openvdb/tools/LevelSetUtil.h @@ -11,7 +11,11 @@ #ifndef OPENVDB_TOOLS_LEVEL_SET_UTIL_HAS_BEEN_INCLUDED #define OPENVDB_TOOLS_LEVEL_SET_UTIL_HAS_BEEN_INCLUDED +#include "Count.h" // for minMax +#include "LevelSetFilter.h" // for LevelSetFilter::normalize #include "MeshToVolume.h" // for traceExteriorBoundaries +#include "Morphology.h" // for dilateActiveValues +#include "Prune.h" // for pruneInactive #include "SignedFloodFill.h" // for signedFloodFillWithValues #include @@ -173,6 +177,31 @@ void segmentSDF(const GridOrTreeType& volume, std::vector& segments); +/// @brief Convert a distance field (unsigned or signed) into a proper signed +/// distance field / level set. +/// +/// @details Uses the same internal pipeline as meshToVolume to determine the +/// sign of the distance field from the exterior boundary. The input +/// values are first normalized into the squared index-space representation +/// that the pipeline expects (boundary threshold = 0.75, i.e. (sqrt(3)/2)^2), +/// then converted back to world-space signed distances. +/// +/// @param grid Distance field grid to convert in place. The grid's +/// transform is used to determine the voxel size. +/// @param removeDisconnectedInterior When enabled, deactivate interior +/// narrow-band voxels that are not topologically connected +/// to the exterior boundary. This removes interior surfaces +/// caused by overlapping or self-intersecting geometry. +/// @param rebuildNarrowBand When enabled,rebuild a symmetric narrow band from +/// the zero crossing via PDE renormalization. +template +void +distanceFieldToSDF( + GridType& grid, + bool removeDisconnectedInterior = false, + bool rebuildNarrowBand = true); + + //////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// @@ -2599,6 +2628,150 @@ segmentSDF(const GridOrTreeType& volume, std::vector +void +distanceFieldToSDF( + GridType& grid, + bool removeDisconnectedInterior, + bool rebuildNarrowBand) +{ + using ValueType = typename GridType::ValueType; + using TreeType = typename GridType::TreeType; + using LeafNodeType = typename TreeType::LeafNodeType; + + TreeType& distTree = grid.tree(); + const ValueType voxelSize = ValueType(grid.transform().voxelSize()[0]); + + // Normalize: world-space distance to squared index-space distance. + // The internal pipeline (traceExteriorBoundaries, ValidateIntersectingVoxels) + // operates on squared distances where the boundary threshold 0.75 = + // (sqrt(3)/2)^2 corresponds to the voxel half-diagonal. + ValueType maxSqDist(0); + { + std::vector nodes; + nodes.reserve(distTree.leafCount()); + distTree.getNodes(nodes); + + const ValueType invVoxel = ValueType(1) / voxelSize; + + tbb::parallel_for(tbb::blocked_range(0, nodes.size()), + [&](const tbb::blocked_range& r) { + for (size_t i = r.begin(); i != r.end(); ++i) { + for (auto iter = nodes[i]->beginValueOn(); iter; ++iter) { + ValueType d = *iter * invVoxel; + ValueType sign = d < ValueType(0) ? ValueType(-1) : ValueType(1); + iter.setValue(sign * d * d); + } + } + }); + + const auto mm = minMax(distTree); + maxSqDist = mm.max(); + } + + // Trim wide bands before flood-fill for performance. + // The squared 3-voxel half-diagonal in index-space units. + const ValueType voxelDistSqLimit(0.75*9); + if (maxSqDist > voxelDistSqLimit) { + std::vector nodes; + nodes.reserve(distTree.leafCount()); + distTree.getNodes(nodes); + + tbb::parallel_for(tbb::blocked_range(0, nodes.size()), + [&](const tbb::blocked_range& r) { + for (size_t i = r.begin(); i != r.end(); ++i) { + for (auto iter = nodes[i]->beginValueOn(); iter; ++iter) { + if (std::abs(*iter) > voxelDistSqLimit) { + nodes[i]->setValueOff(iter.pos()); + } + } + } + }); + + pruneInactive(distTree, /*threading=*/true); + } + + // Sweep from exterior, negating reachable voxels. Stops at boundary + // voxels (value <= 0.75). + traceExteriorBoundaries(distTree); + + // Remove interior narrow-band voxels not connected to the exterior. + if (removeDisconnectedInterior) { + std::vector nodes; + nodes.reserve(distTree.leafCount()); + distTree.getNodes(nodes); + + const tbb::blocked_range nodeRange(0, nodes.size()); + + // Boundary voxels (0 < dist <= 0.75) with no negative neighbor + // are disconnected from the exterior, push past the threshold. + tbb::parallel_for(nodeRange, + mesh_to_volume_internal::ValidateIntersectingVoxels( + distTree, nodes)); + + // Deactivate voxels pushed past the boundary threshold. + tbb::parallel_for(nodeRange, + [&](const tbb::blocked_range& r) { + for (size_t i = r.begin(); i != r.end(); ++i) { + for (auto iter = nodes[i]->beginValueOn(); iter; ++iter) { + if (*iter > ValueType(0.75)) nodes[i]->setValueOff(iter.pos()); + } + } + }); + + pruneInactive(distTree, /*threading=*/true); + } + + // Denormalize: squared index-space to world-space signed distance. + { + std::vector nodes; + nodes.reserve(distTree.leafCount()); + distTree.getNodes(nodes); + + tbb::parallel_for(tbb::blocked_range(0, nodes.size()), + mesh_to_volume_internal::TransformValues( + nodes, voxelSize, /*unsignedDist=*/false)); + } + + // Set background and flood-fill sign into tile regions. + const auto mm = minMax(distTree); + const ValueType exteriorWidth = mm.max(); + const ValueType interiorWidth = mm.min(); + + distTree.root().setBackground(exteriorWidth, /*updateChildNodes=*/false); + signedFloodFillWithValues(distTree, exteriorWidth, interiorWidth); + + grid.setGridClass(GRID_LEVEL_SET); + + // Optionally rebuild a symmetric narrow band via PDE renormalization. + if (rebuildNarrowBand) { + const int halfWidth = 3; // standard narrow band half-width in voxels + tools::dilateActiveValues(distTree, halfWidth, + tools::NN_FACE, tools::PRESERVE_TILES); + + util::NullInterrupter interrupter; + LevelSetFilter filter(grid, &interrupter); +#if 1 + filter.setSpatialScheme(math::FIRST_BIAS); + filter.setTemporalScheme(math::TVD_RK1); +#else + filter.setSpatialScheme(math::HJWENO5_BIAS); + filter.setTemporalScheme(math::TVD_RK3); +#endif + //filter.setSpatialScheme(math::FIRST_BIAS);// <- + filter.setNormCount(halfWidth); + filter.normalize(); + filter.prune(); + + const ValueType bandWidth = voxelSize * ValueType(halfWidth); + tools::pruneLevelSet(distTree, bandWidth, -bandWidth); + } +}// distanceFieldToSDF + + +//////////////////////////////////////// + + // Explicit Template Instantiation #ifdef OPENVDB_USE_EXPLICIT_INSTANTIATION @@ -2678,6 +2851,11 @@ OPENVDB_REAL_TREE_INSTANTIATE(_FUNCTION) OPENVDB_REAL_TREE_INSTANTIATE(_FUNCTION) #undef _FUNCTION +#define _FUNCTION(TreeT) \ + void distanceFieldToSDF(Grid&, bool, bool) +OPENVDB_REAL_TREE_INSTANTIATE(_FUNCTION) +#undef _FUNCTION + #endif // OPENVDB_USE_EXPLICIT_INSTANTIATION diff --git a/openvdb/openvdb/tools/MeshToVolume.h b/openvdb/openvdb/tools/MeshToVolume.h index a6bdcd5818..2aac956e75 100644 --- a/openvdb/openvdb/tools/MeshToVolume.h +++ b/openvdb/openvdb/tools/MeshToVolume.h @@ -2721,7 +2721,7 @@ struct ExpandNarrowband return ValueType(std::sqrt(dist)) * mVoxelSize; } - /// @note Returns true if the current voxel was updated and neighbouring + /// @note Returns true if the current voxel was updated and neighboring /// voxels need to be evaluated. bool updateVoxel(const Coord& ijk, const Int32 manhattanLimit, diff --git a/openvdb/openvdb/tools/MultiResGrid.h b/openvdb/openvdb/tools/MultiResGrid.h index 10eee0e1b4..fd1cae8a25 100644 --- a/openvdb/openvdb/tools/MultiResGrid.h +++ b/openvdb/openvdb/tools/MultiResGrid.h @@ -5,10 +5,6 @@ /// /// @author Ken Museth /// -/// @warning This class is fairly new and as such has not seen a lot of -/// use in production. Please report any issues or request for new -/// features directly to ken.museth@dreamworks.com. -/// /// @brief Multi-resolution grid that contains LoD sequences of trees /// with powers of two refinements. /// @@ -19,7 +15,7 @@ /// @note Prolongation means interpolation from coarse -> fine /// @note Restriction means interpolation (or remapping) from fine -> coarse /// -/// @todo Add option to define the level of the input grid (currenlty +/// @todo Add option to define the level of the input grid (currently /// 0) so as to allow for super-sampling. #ifndef OPENVDB_TOOLS_MULTIRESGRID_HAS_BEEN_INCLUDED @@ -692,7 +688,7 @@ struct MultiResGrid::MaskOp { OPENVDB_ASSERT( coarseTree.empty() ); - // Create Mask of restruction performed on fineTree + // Create Mask of restriction performed on fineTree MaskT mask(fineTree, false, true, TopologyCopy() ); // Multi-threaded dilation which also linearizes the tree to leaf nodes diff --git a/openvdb/openvdb/tools/PolySoupToLevelSet.h b/openvdb/openvdb/tools/PolySoupToLevelSet.h new file mode 100644 index 0000000000..e2e5d04621 --- /dev/null +++ b/openvdb/openvdb/tools/PolySoupToLevelSet.h @@ -0,0 +1,484 @@ +// Copyright Contributors to the OpenVDB Project +// SPDX-License-Identifier: Apache-2.0 + +/// @author Ken Museth +/// +/// @file tools/PolySoupToLevelSet.h +/// +/// @brief Generates a LOD family of watertight shrink wrap level set surfaces +/// (or meshes) from a soup of polygons. +/// +/// @details Details of this algorithm are given in an upcoming publication. + +#ifndef OPENVDB_TOOLS_POLYSOUP_TO_LEVELSET_HAS_BEEN_INCLUDED +#define OPENVDB_TOOLS_POLYSOUP_TO_LEVELSET_HAS_BEEN_INCLUDED + +#include +#include +#include +#include + +#include "Composite.h" // for csgUnion +#include "ValueTransformer.h"// for tools::foreach +#include "GridTransformer.h" // for resampleToMatch +#include "MeshToVolume.h"// for meshToLevelSet +#include "VolumeToMesh.h"// for volumeToMesh +#include "LevelSetDilatedMesh.h"// for createLevelSetDilatedMesh +#include "LevelSetFilter.h"// for Filter +#include "LevelSetMeasure.h"// for levelSetVolume +#include "FastSweeping.h"// for fogToSdf +#include "LevelSetUtil.h" // for distanceFieldToSDF + +#include +#include + +namespace openvdb { +OPENVDB_USE_VERSION_NAMESPACE +namespace OPENVDB_VERSION_NAME { +namespace tools { + +/// @brief Simple structure for a polygon soup +/// @details bbox is allowed to be invalid, in which case it will be derived from vtx +struct PolySoup { + std::vector vtx; + std::vector tri; + std::vector quad; + math::BBox bbox; +}; + +/// @brief Class used to define and control the shrink wrap behaviour. +/// @note This class is required to have a member method with the signature: +/// float operator()(float dx) const. +/// @details See D(dx) in our paper for details on the closing threshold. +class ShrinkWrapLimit; + +/// @brief Class that implements the actual shrink wrap algorithm +/// @tparam GridType Template parameter of the desired shrink wrap grids +template +class PolySoupToLevelSet; + +/// @brief Convert a soup of polygons to a shrink wrapped level set volume. This version +/// takes a PolySoup struct and optional voxel dimension and/or voxel size. If the +/// voxel size is invalid, i.e. not positive, the dimension and bbox of the PolySoup +/// is used to derive the voxel size. +/// +/// @return A shared pointer to grid of type @c GridType containing a narrow-band level set +/// that shrink wraps the input polygons. +/// +/// @throw TypeError if @c GridType is not scalar or not floating-point +/// +/// @note Unlike tools::meshToLevelSet this method works for any polygons, +/// and does not require a closed surface. +/// +/// @param poly Struct with polygon soup, that will be destroyed (moved) +/// @param dim Optional dimension in voxel units (assuming voxelSize is invalid) +/// @param voxelSize Optional voxel size in world units (if invalid dim will be used instead) +/// @param D Functor mapping voxel size to maximum allowed surface deformation +/// allowed by shrink wrapping as a function of the voxel size +/// @param halfWidth Half the width of the narrow band, in voxel units +/// @param progress Optional pointer to progress bar +template +typename GridType::Ptr +polySoupToLevelSet( + PolySoup &&poly, + int dim = 256, + float voxelSize = 0.0,// invalid so use dim instead + const ShrinkWrapT &D = ShrinkWrapT(), + float halfWidth = float(LEVEL_SET_HALF_WIDTH), + ProgressT *progress = nullptr, + int offset_mode = 0); + +/// @brief Convert a soup of polygons to a LOD sequence of shrink wrapped level set volumes. +/// +/// @return a vector of grids of type @c GridType containing a narrow-band level set +/// at various resolution shrink wrapping the input polygon mesh. The first +/// element in this vector has the highest resolution. +/// +/// @throw TypeError if @c GridType is not scalar or not floating-point +/// +/// @note Unlike tools::meshToLevelSet this method works for any polygons, +/// and does not require a closed surface. +/// +/// @param dim Largest voxel dimension of the finest output grid +/// @param bbox Bounding box of the vertices of the polygon mesh +/// @param vtx Vector of world space vertex positions +/// @param tri Vector of triangle indices +/// @param quads Vector of quad indices +/// @param D Functor mapping voxel size to maximum allowed surface deformation +/// allowed by shrink wrapping as a function of the voxel size +/// @param halfWidth Half the width of the narrow band, in voxel units +/// @param progress Optional pointer to progress bar +template +std::vector +polySoupToLevelSet( + int dim, + const math::BBox &bbox, + std::vector& vtx, + std::vector& tri, + std::vector& quad, + const ShrinkWrapT &D = ShrinkWrapT(), + float halfWidth = float(LEVEL_SET_HALF_WIDTH), + ProgressT *progress = nullptr, + int offset_mode = 0); + +/// @brief Convert a soup of polygons to a LOD sequence of shrink wrapped level set volumes. +/// +/// @return a vector of grids of type @c GridType containing a narrow-band level set +/// at various resolution shrink wrapping the input polygon mesh. The first +/// element in this vector has the highest resolution. +/// +/// @throw TypeError if @c GridType is not scalar or not floating-point +/// +/// @note Unlike tools::meshToLevelSet this method works for any polygons, +/// and does not require a closed surface. +/// +/// @param minVoxelSize Finest/smallest voxel size of the output grids +/// @param bbox bounding box of the vertices of the polygon mesh +/// @param vtx vector of world space vertex positions +/// @param tri vector of triangle indies +/// @param quads vector of quad indices +/// @param D functor mapping voxel size to maximum allowed surface deformation +/// allowed by shrink wrapping as a function of the voxel size +/// @param halfWidth half the width of the narrow band, in voxel units +/// @param progress optional pointer to progress bar +template +std::vector +polySoupToLevelSet( + float minVoxelSize, + const math::BBox &bbox, + std::vector& vtx, + std::vector& tri, + std::vector& quad, + const ShrinkWrapT &D = ShrinkWrapT(), + float halfWidth = float(LEVEL_SET_HALF_WIDTH), + ProgressT *progress = nullptr, + int offset_mode = 0); + +///////////////////////////////////////////////////////////////////////////////////// + +/// @brief This class implements our shrink wrap algorithm. Normally the free-standing +/// function called above should be used instead of this class. +/// @tparam GridType Grid type of the generated level set surfaces (defaults to FloatGrid) +template +class PolySoupToLevelSet +{ +public: + + /// @brief Constructor from a desired voxel dimension. + /// @param poly Polygon soup that will be moved to this instance. + /// @param dim Desired voxel dimension of the output level set. + /// @param width Half-width of the output narrow-band level set, in voxel units. + PolySoupToLevelSet(PolySoup &&poly, int dim, float width = float(LEVEL_SET_HALF_WIDTH)); + + /// @brief Constructor from a desired voxel size. + /// @param poly Polygon soup that will be moved to this instance. + /// @param voxelSize Desired voxel size of the output level set in world units. + /// @param width Half-width of the output narrow-band level set, in voxel units. + PolySoupToLevelSet(PolySoup &&poly, float voxelSize, float width = float(LEVEL_SET_HALF_WIDTH)); + + /// @brief Performs the actual processing to generate the shrink wrap surfaces. + /// @tparam ShrinkWrapT Optional template parameter of the functor controlling + /// the number of constrained erosion steps (see our paper). + /// @tparam ProgressT Template parameter of the optional progress bar. + /// @param D Optional functor controlling the number of constrained + /// erosion steps and the closing threshold (see our paper). + /// @param progress Optional pointer to a progress bar. + template + void process(const ShrinkWrapT &D, ProgressT *progress, int mode = 0); + + /// @brief Number of shrink wrap grids generated, i.e. depth of the LOD hierarchy. + size_t gridCount() const {return mGrids.size();} + + /// @brief Returns a shared pointer to a particular shrink wrap grid + /// @param n Number of the shrink wrap grid, where n = 0 has the finest sampling and + /// n = gridCount-1 is the coarsest voxel sampling. + typename GridType::Ptr grid(int n = 0) const {return mGrids[n];} + + /// @brief Vector with shared pointers to all the shrink wrap grids + /// @note The grids are arranged fine to coarse, grids()[0] is the finest. + std::vector grids() const {return mGrids;} + + /// @brief Generate an adaptive polygon mesh from a particular shrink wrap SDF + /// @param n Number of the shrink wrap grid, where n = 0 has the finest sampling and + /// n = gridCount-1 is the coarsest voxel sampling. + /// @param adaptivity Optional adaptivity parameter used for meshing. A value of zero + /// means adaptivity is disabled, i.e. uniform quads are produced. + /// @param isoValue Iso-value used for the mesh generation. + /// @return Reference to the internal PolySoup populated with the generated mesh. + const PolySoup& mesh(int n = 0, float adaptivity = 0.005f, float isoValue = 0.0f) + { + volumeToMesh(*mGrids[n], mPoly.vtx, mPoly.tri, mPoly.quad, isoValue, adaptivity); + return mPoly; + } + + /// @brief Static method that computes the bounding box of a list of vertex coordinates. + /// @param vtx Vector of vertex coordinates. + static math::BBox getBBox(const std::vector &vtx); + + /// @brief Generates a dx-offset level set surface from the internal polygon soup. + /// @param dx Voxel size (world units) of the output level set. + /// @param mode 0) old method (using mesh -> UDF -> mesh -> SDF), 1) Mihai's + /// signed-flood-fill and 2) Greg's createLevelSetDilatedMesh + /// @return Shared pointer to the newly created level set grid. + auto offset(float dx, int mode = 0); + + float minVoxelSize() const {return mMinVoxelSize;} + float maxVoxelSize() const {return mMaxVoxelSize;} + float halfWidth() const {return mHalfWidth;} + +private: + PolySoup mPoly; + float mMinVoxelSize, mMaxVoxelSize, mHalfWidth; + std::vector mGrids;// fine(0) -> coarse grids(size-1) + bool mIsGridSDF; + + /// @brief Private method that resamples inGrid(dx) to outGrid(dx/2). + auto upsample(const GridType &inGrid); + + /// @brief Performs the shrink wrap operation as a constrained level set erosion. + auto shrinkWrap(GridType &grid, const GridType &gridB, float &d); + +};// PolySoupToLevelSet + +///////////////////////////////////////////////////////////////////////////////////// + +template +PolySoupToLevelSet::PolySoupToLevelSet(PolySoup &&poly, int dim, float width) + : mPoly(poly), mHalfWidth(width) +{ + if constexpr(!std::is_floating_point::value) { + OPENVDB_THROW(TypeError, "polySoupToLevelSet: supported only for scalar floating-point grids"); + } + if (!mPoly.bbox) mPoly.bbox = PolySoupToLevelSet::getBBox(mPoly.vtx); + const float maxLength = mPoly.bbox.extents()[mPoly.bbox.maxExtent()]; + mMinVoxelSize = maxLength/(float(dim) - 2.0f*(mHalfWidth + 1.0f));// +1 since final surface is dilated by dx + mMaxVoxelSize = maxLength / 2.0f; + OPENVDB_ASSERT(2*mMinVoxelSize <= mMaxVoxelSize); +}// tools::PolySoupToLevelSet::PolySoupToLevelSet() + +///////////////////////////////////////////////////////////////////////////////////// + +template +PolySoupToLevelSet::PolySoupToLevelSet(PolySoup &&poly, float voxelSize, float width) + : mPoly(poly), mMinVoxelSize(voxelSize), mHalfWidth(width) +{ + if constexpr(!std::is_floating_point::value) { + OPENVDB_THROW(TypeError, "polySoupToLevelSet: supported only for scalar floating-point grids"); + } + if (!mPoly.bbox) mPoly.bbox = PolySoupToLevelSet::getBBox(mPoly.vtx); + const float maxLength = mPoly.bbox.extents()[mPoly.bbox.maxExtent()]; + mMaxVoxelSize = maxLength / 2.0f; + OPENVDB_ASSERT(2*mMinVoxelSize <= mMaxVoxelSize); +}// tools::PolySoupToLevelSet::PolySoupToLevelSet() + +///////////////////////////////////////////////////////////////////////////////////// + +template +template +void PolySoupToLevelSet::process(const ShrinkWrapT &D, ProgressT *progress, int offset_mode) +{ + auto myProgress = [&](const std::string &s){if constexpr(!std::is_same::value) if (progress) (*progress)(s);}; + + if (progress) std::cerr << std::endl; + + // Fine to coarse offset generation + for (float dx = mMinVoxelSize; dx <= mMaxVoxelSize; dx *= 2.0f) { + myProgress("Offset: dx=" + std::to_string(dx)+", range: "+std::to_string(mMinVoxelSize)+" -> "+std::to_string(mMaxVoxelSize)); + mGrids.push_back(this->offset(dx, offset_mode)); + } + + // Coarse to fine shrink wrap algorithm + double vol[2] = {0.0, 0.0};// levelSetVolume returns Real (double); keep full precision. + // Zero-init silences a GCC -Wmaybe-uninitialized false positive: + // vol[0] is only read when d>0, after the loop's increment has set it. + auto grid = mGrids.back();// initiate grid with the coarsest offset + mGrids.pop_back(); + mIsGridSDF = true; + for (auto iter = mGrids.rbegin(), end = mGrids.rend(); iter != end; ++iter) {// coarse -> fine + grid = this->upsample(*grid);// grid(dx) -> grid(dx/2) + for (float d = 0.0f, dx = float(grid->voxelSize()[0]), Ddx = D(dx); d < Ddx; vol[0] = vol[1]) { + myProgress("Shrink wrap d=" + std::to_string(d) + ", D("+std::to_string(dx) + ")=" + std::to_string(Ddx)); + grid = this->shrinkWrap(*grid, **iter, d); + vol[1] = levelSetVolume(*grid); + if (d>0.0f && math::isApproxZero(vol[0]-vol[1])) break; + } + *iter = grid; + }// loop from coarse to fine voxel sizes + +}// tools::PolySoupToLevelSet::process() + +////////////////////////////////////////////////////////////////////////// + +template +math::BBox PolySoupToLevelSet::getBBox(const std::vector &vtx) +{ + using RangeT = tbb::blocked_range::const_iterator>; + RangeT range(vtx.begin(), vtx.end(), 1024); + struct BBoxOp { + math::BBox bbox; + BBoxOp() : bbox() {} + BBoxOp(BBoxOp& s, tbb::split) : bbox(s.bbox) {} + void operator()(const RangeT& r) {for (auto p=r.begin(); p!=r.end(); ++p) bbox.expand(*p);} + void join(BBoxOp& rhs) {bbox.expand(rhs.bbox);} + } tmp; +#if 0 + tmp(range);// serial +#else + tbb::parallel_reduce(range, tmp);// parallel +#endif + return tmp.bbox; +}// tools::PolySoupToLevelSet::getBBox + +////////////////////////////////////////////////////////////////////////// + +template +auto PolySoupToLevelSet::offset(float dx, int mode) +{ + auto xform = math::Transform::createLinearTransform(dx); + typename GridType::Ptr grid(nullptr); + switch (mode) { + case 0:// algorithm presented in the paper, using mesh<-> VDB round-trip + grid = meshToUnsignedDistanceField(*xform, mPoly.vtx, mPoly.tri, mPoly.quad, mHalfWidth);// mesh -> UDF + volumeToMesh(*grid, mPoly.vtx, mPoly.tri, mPoly.quad, /*iso*/dx, /*adapt*/0.0);// UDF -> mesh (clears and re-allocates mesh) + grid = meshToLevelSet(*xform, mPoly.vtx, mPoly.tri, mPoly.quad, mHalfWidth);// mesh -> SDF + break; + case 1:// algorithm using Mihai's signed flood-fill algorithm + grid = meshToUnsignedDistanceField(*xform, mPoly.vtx, mPoly.tri, mPoly.quad, mHalfWidth + 1);// mesh -> UDF + tools::foreach(grid->beginValueOn(), [dx](const typename GridType::ValueOnIter& it){it.setValue(*it - dx);}, /*threaded*/true, /*share functor*/true); + //tools::changeBackground(grid->tree(), mHalfWidth*dx); + tools::changeLevelSetBackground(grid->tree(), mHalfWidth); + //grid->tree().root().setBackground(exteriorWidth, /*updateChildNodes=*/true); + //tools::signedFloodFillWithValues(grid->tree(), exteriorWidth, interiorWidth); + tools::distanceFieldToSDF(*grid, /*removeDisconnectedInterior*/true, /*rebuildNarrowBand*/true); + break; + case 2:// algorithm using Greg's polyOffset algorithm + grid = tools::createLevelSetDilatedMesh(mPoly.vtx, mPoly.tri, mPoly.quad, /*radius*/dx, /*voxel size*/dx, mHalfWidth); + //tools::distanceFieldToSDF(*grid, /*removeDisconnectedInterior*/true, /*rebuildNarrowBand*/false); + break; + default: + OPENVDB_THROW(TypeError, "polySoupToLevelSet::offset: invalid mode(" + std::to_string(mode) + ")"); + break; + }// end of switch + return grid; +}// tools::PolySoupToLevelSet::offset + +////////////////////////////////////////////////////////////////////////// + +template +auto PolySoupToLevelSet::upsample(const GridType &inGrid) +{ + auto outGrid = createLevelSet(inGrid.voxelSize()[0]/2, mHalfWidth); + resampleToMatch(inGrid, *outGrid); + mIsGridSDF = true; + return outGrid; +}// tools::PolySoupToLevelSet::upsample + +////////////////////////////////////////////////////////////////////////// + +template +auto PolySoupToLevelSet::shrinkWrap(GridType &grid, const GridType &gridB, float &d) +{ + const float maxDist = 2.0f; + LevelSetFilter filter(grid); + filter.setNormCount(3);// halfWidth +#if 1//first-order + filter.setSpatialScheme(math::FIRST_BIAS); + filter.setTemporalScheme(math::TVD_RK1); +#else// higher order + filter.setSpatialScheme(math::HJWENO5_BIAS); + filter.setTemporalScheme(math::TVD_RK3); +#endif + if (mIsGridSDF == false) { + filter.normalize(); + filter.prune();// is this needed? + } + filter.offset(static_cast(maxDist * grid.voxelSize()[0]));// erode by maxDist * dx + mIsGridSDF = false;// the CSG operation messed up the SDF + d += maxDist; + return csgUnionCopy(grid, gridB); +}// tools::PolySoupToLevelSet::shrinkWrap + +////////////////////////////////////////////////////////////////////////// + +class ShrinkWrapLimit { + const float mErode, mThres; +public: + ShrinkWrapLimit(float erode = 8.0f, float thres = 0.0f) : mErode(erode), mThres(thres) {} + float operator()(float dx) const {// if mThres == 0 this always returns mErode + return dx>=2*mThres ? mErode : dx<=mThres ? 1.0f : 1.0f + (mErode-1.0f)*(dx-mThres)/mThres; + } +};// ShrinkWrapLimit + +///////////////////////////////////////////////////////////////////////////////////// + +template +typename GridType::Ptr +polySoupToLevelSet( + PolySoup &&poly, + int dim, + float voxelSize, + const ShrinkWrapT &D, + float halfWidth, + ProgressT *progress, + int offset_mode) +{ + static_assert(std::is_floating_point::value, + "polySoupToLevelSet requires an SDF grid with floating-point values"); + using T = PolySoupToLevelSet; + auto ptr = voxelSize > 0.0f ? std::make_unique(std::move(poly), voxelSize, halfWidth) : + std::make_unique(std::move(poly), dim, halfWidth); + ptr->process(D, progress, offset_mode); + return ptr->grid(); +} + +///////////////////////////////////////////////////////////////////////////////////// + +template +std::vector +polySoupToLevelSet( + int dim, + const math::BBox &bbox, + std::vector& vtx, + std::vector& tri, + std::vector& quad, + const ShrinkWrapT &D, + float halfWidth, + ProgressT *progress, + int offset_mode) +{ + static_assert(std::is_floating_point::value, + "polySoupToLevelSet requires an SDF grid with floating-point values"); + PolySoup poly{std::move(vtx), std::move(tri), std::move(quad), bbox}; + PolySoupToLevelSet tmp(std::move(poly), dim, halfWidth); + tmp.process(D, progress, offset_mode); + return tmp.grids(); +} + +///////////////////////////////////////////////////////////////////////////////////// + +template +std::vector +polySoupToLevelSet( + float minVoxelSize, + const math::BBox &bbox, + std::vector& vtx, + std::vector& tri, + std::vector& quad, + const ShrinkWrapT &D, + float halfWidth, + ProgressT *progress, + int offset_mode) +{ + static_assert(std::is_floating_point::value, + "polySoupToLevelSet requires an SDF grid with floating-point values"); + PolySoup poly{std::move(vtx), std::move(tri), std::move(quad), bbox}; + PolySoupToLevelSet tmp(std::move(poly), minVoxelSize, halfWidth); + tmp.process(D, progress, offset_mode); + return tmp.grids(); +}// polySoupToLevelSet + +} // namespace tools +} // namespace OPENVDB_VERSION_NAME +} // namespace openvdb + +#endif // OPENVDB_TOOLS_POLYSOUP_TO_LEVELSET_HAS_BEEN_INCLUDED \ No newline at end of file diff --git a/openvdb_cmd/CMakeLists.txt b/openvdb_cmd/CMakeLists.txt index 2788e9968e..c76265f551 100644 --- a/openvdb_cmd/CMakeLists.txt +++ b/openvdb_cmd/CMakeLists.txt @@ -18,6 +18,7 @@ option(OPENVDB_BUILD_VDB_PRINT "Build vdb_print" ON) option(OPENVDB_BUILD_VDB_LOD "Build vdb_lod" OFF) option(OPENVDB_BUILD_VDB_RENDER "Build vdb_render" OFF) option(OPENVDB_BUILD_VDB_VIEW "Build vdb_view" OFF) +option(OPENVDB_BUILD_VDB_VIZ "Build vdb_viz (Polyscope-based viewer)" OFF) option(OPENVDB_BUILD_VDB_TOOL "Build vdb_tool" OFF) option(OPENVDB_BUILD_VDB_AX "Build the OpenVDB AX command line binary" OFF) @@ -79,6 +80,10 @@ if (OPENVDB_BUILD_VDB_VIEW) add_subdirectory(vdb_view) endif() +if (OPENVDB_BUILD_VDB_VIZ) + add_subdirectory(vdb_viz) +endif() + if (OPENVDB_BUILD_VDB_AX) add_subdirectory(vdb_ax) endif() diff --git a/openvdb_cmd/vdb_tool/CHANGES.md b/openvdb_cmd/vdb_tool/CHANGES.md deleted file mode 100644 index 8c589c74ec..0000000000 --- a/openvdb_cmd/vdb_tool/CHANGES.md +++ /dev/null @@ -1,107 +0,0 @@ - -# Changes and to-dos: - -- [X] vdb_tool::readGeo -- [X] vdb_tool::readVDB -- [X] vdb_tool::particlesToLevelSet -- [X] vdb_tool::processLevelSet -- [X] vdb_tool::offsetLevelSet -- [X] vdb_tool::filterLevelSet -- [X] vdb_tool::levelSetToMesh -- [X] vdb_tool::writeGeo -- [X] vdb_tool::writeVDB -- [X] read ASCI obj particle files -- [X] read ASCI ply particle files -- [X] read binary ply particle files -- [X] write binary ply mesh files -- [X] write ascii obj mesh files -- [X] Geometry::readVdb -- [X] Geometry::readPts -- [X] define time and space order -- [X] Mesh::readPly -- [X] vdb_tool::readMesh -- [X] vdb_tool::meshToLevelSet -- [X] Geometry::readObj -- [X] Geometry::readPly -- [X] Geometry::readNvdb -- [X] vdb_tool::writeVDB -- [X] allow actions to have multiple "-" -- [X] add "-sphere" -- [X] add volume/geometry ages to all actions -- [X] add CSG operations -- [X] "-read" supports multiple files -- [X] "-write" supports multiple files -- [X] added "-print" -- [X] works with tcsh, sh, ksh, and zsh shells -- [X] added "-default" -- [X] cache a list of base grids instead of FloatGrids -- [X] -points2vdb : points -> PointDataGrid -- [X] -vdb2points : PointDataGrid -> points -- [X] -write geo=1 vdb=1,3 file.ply file.vdb -- [X] -iso2ls, convert scalar field to level set -- [X] -ls2fog, convert level set to fog volume -- [X] -scatter, scatter points -- [X] -prune, prune level set -- [X] -flood, signed flood fill of level set -- [X] -multires, generate multi-resolution grids -- [X] -expand, expand narrow band of level set -- [X] -cpt, generate closest-point transfer -- [X] -grad, generate gradient field -- [X] -div, generate divergence from vector field -- [X] -curl, generate curl from vector field -- [X] -curvature, generate mean curvature from scalar field -- [X] -length, generate length of vector field -- [X] -render, render level set and fog volumes -- [X] -enright, performs advection test on level set -- [X] -for i=0,10,1 -end -- [X] -each s=str1,str2 -end -- [X] -read grids=sphere file_%4i.vdb -- [X] Geometry::readSTL -- [X] Geometry::writeSTL -- [X] -clip against either a mask grid, bbox or frustum -- [X] Added local counter "%I" to for-loops -- [X] Added global counter "%G" -- [X] add Tool::savePNG -- [X] add Tool::saveEXR -- [X] -platonic faces=4 -- [X] -segment vdb=0 keep=0 -- [X] -resample vdb=0[,1] scale=0 translate=0,0,0 order=1[0|2] keep=0 -- [X] add Geometry::readABC -- [X] add support for unix pipelining -- [X] add Tool::saveJPG -- [X] add Geometry::read/write to support streaming -- [X] -read stdin.[ply,obj,stl,geo,vdb] -- [X] -write stdout.[ply,obj,stl,geo,vdb] -- [X] actions can now have an optional alias, e.g. -read, -i -- [X] -write file.nvdb stdout.nvdb -- [X] -write bits=32|16|8|N codec=blosc|zip|active -- [X] -help read,write,ls2mesh brief=true -- [X] use openvdb namespace -- [X] Major revision with Parser.h -- [X] -read delayed=false file.vdb -- [X] -clear vdb=1,2,3 geo=* -- [X] -config update=false execute=true configure.txt -- [X] -each f=*.vdb -- [X] add stack-based translator and storage -- [X] -eval '{1:@G}' -- [X] add if-statement: {$x:0:==:if(0.5)} equals if (x==0) 0.5 and {\$x:1:>:if(0.5:sin?0.3:cos)} equals if (x>1) sin(0.5) else cos(0.3) -- [X] add switch-statement: {\$i:switch(1:A?2:B?3:C)} equals switch(x) case 1: A; break; case 2: B; break; case 3: C -- [X] Added numerous methods to scripting language -- [X] -mesh2ls vdb=0 (use another vdb to defined the transform) -- [X] -iso2ls vdb=0,1 (use another vdb to defined the transform) -- [X] loops will now skip, instead of throw, if its initial condition is invalid -- [X] -for i=1,9 (third argument defaults to 1, i.e. i=1,9,1) -- [X] -if 0|1|false|true ... -end (if statement) -- [X] -eval help="*" or -eval help=if,switch -- [X] {date}, {uuid}, {1:a:set}, {a:get}, {a:is_set}, {sphere:sp:match} -- [X] composite: -min, -max, -sum -- [X] -transform vdb=0,3 geo=5 (scale -> rotate -> translate of VDB grids and geometry) -- [X] -print mem=1 prints variables saved to memory, e.g. loop variables -- [X] use cmake (thanks to Greg Klar!) -- [X] read NanoVDB voxel volumes (thanks to Greg Klar) -- [X] -ls2mesh iso=0.1 mask=1 invert=true -- [X] -write binary abc mesh files (thanks to Alexandre Sirois-Vigneux) -- [x] -write keep=false (by default grids and geometries written are also removed) -- [ ] -merge -- [ ] -points2mask -- [ ] -erodeTopology diff --git a/openvdb_cmd/vdb_tool/CMakeLists.txt b/openvdb_cmd/vdb_tool/CMakeLists.txt index 59b9d9174a..5c6496d06c 100644 --- a/openvdb_cmd/vdb_tool/CMakeLists.txt +++ b/openvdb_cmd/vdb_tool/CMakeLists.txt @@ -36,17 +36,22 @@ option(OPENVDB_TOOL_NANO_USE_BLOSC "Compile NanoVDB with Blosc compression suppo option(OPENVDB_TOOL_USE_PNG "Compile with PNG support" OFF) option(OPENVDB_TOOL_USE_EXR "Compile with EXR support" OFF) option(OPENVDB_TOOL_USE_JPG "Compile with JPG support" OFF) +option(OPENVDB_TOOL_USE_MPEG "Compile with MPEG support" OFF) option(OPENVDB_TOOL_USE_PDAL "Compile with extended points support" OFF) option(OPENVDB_TOOL_USE_ABC "Compile with Alembic support" OFF) +option(OPENVDB_TOOL_USE_USD "Compile with OpenUSD geometry read support" OFF) +option(OPENVDB_TOOL_USE_GLTF "Compile with glTF (.gltf / .glb) read support via tinygltf" OFF) option(OPENVDB_TOOL_USE_ALL "Compile with all optional components" OFF) if(OPENVDB_TOOL_USE_ALL) set(OPENVDB_TOOL_USE_NANO ON) set(OPENVDB_TOOL_USE_PNG ON) set(OPENVDB_TOOL_USE_EXR ON) set(OPENVDB_TOOL_USE_JPG ON) + set(OPENVDB_TOOL_USE_MPEG ON) set(OPENVDB_TOOL_USE_ABC ON) set(OPENVDB_TOOL_USE_PDAL ON) - + set(OPENVDB_TOOL_USE_USD ON) + set(OPENVDB_TOOL_USE_GLTF ON) endif() if(OPENVDB_TOOL_USE_NANO) @@ -107,6 +112,11 @@ if(OPENVDB_TOOL_USE_JPG) target_include_directories(vdb_tool_common INTERFACE ${JPEG_INCLUDE_DIR}) endif() +if(OPENVDB_TOOL_USE_MPEG) + target_compile_definitions(vdb_tool_common INTERFACE "VDB_TOOL_USE_MPEG") + find_program(FFMPEG_PATH NAMES ffmpeg REQUIRED) +endif() + if(OPENVDB_TOOL_USE_EXR) target_compile_definitions(vdb_tool_common INTERFACE "VDB_TOOL_USE_EXR") find_package(OpenEXR ${MINIMUM_OPENEXR_VERSION} REQUIRED) @@ -119,6 +129,37 @@ if(OPENVDB_TOOL_USE_ABC) target_link_libraries(vdb_tool_common INTERFACE Alembic::Alembic) endif() +if(OPENVDB_TOOL_USE_USD) + target_compile_definitions(vdb_tool_common INTERFACE "VDB_TOOL_USE_USD") + # OpenUSD exports its CMake config under the "pxr" package name. + find_package(pxr CONFIG REQUIRED) + # Minimum set of OpenUSD libraries needed to traverse a stage and pull + # UsdGeomMesh data; pxr will transitively pull the rest. + target_link_libraries(vdb_tool_common INTERFACE usd usdGeom sdf gf vt tf) +endif() + +if(OPENVDB_TOOL_USE_GLTF) + # tinygltf is a single-header BSD-3 reader for .gltf / .glb. Fetched at + # configure time so no system install is required. Used in header-only + # mode via TINYGLTF_HEADER_ONLY (set in Geometry.h), so every TU including + # the header gets inline definitions — no separate impl source file needed. + target_compile_definitions(vdb_tool_common INTERFACE "VDB_TOOL_USE_GLTF") + include(FetchContent) + FetchContent_Declare(tinygltf + GIT_REPOSITORY https://github.com/syoyo/tinygltf.git + GIT_TAG v2.9.5 + GIT_SHALLOW TRUE + ) + # tinygltf's CMakeLists builds examples by default — disable. + set(TINYGLTF_BUILD_LOADER_EXAMPLE OFF CACHE BOOL "" FORCE) + set(TINYGLTF_BUILD_GL_EXAMPLES OFF CACHE BOOL "" FORCE) + set(TINYGLTF_BUILD_VALIDATOR_EXAMPLE OFF CACHE BOOL "" FORCE) + set(TINYGLTF_BUILD_BUILDER_EXAMPLE OFF CACHE BOOL "" FORCE) + set(TINYGLTF_HEADER_ONLY ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(tinygltf) + target_include_directories(vdb_tool_common INTERFACE ${tinygltf_SOURCE_DIR}) +endif() + if(WIN32 AND (OPENVDB_TOOL_USE_ALL OR (OPENVDB_TOOL_USE_ABC AND OPENVDB_TOOL_USE_EXR))) message(WARNING " The OpenEXR and Alembic VCPKG packages are using conflicting Imath versions.\n" diff --git a/openvdb_cmd/vdb_tool/README.md b/openvdb_cmd/vdb_tool/README.md index 23bb29066f..5d012b77af 100644 --- a/openvdb_cmd/vdb_tool/README.md +++ b/openvdb_cmd/vdb_tool/README.md @@ -1,57 +1,86 @@ # vdb_tool +The vdb_tool is a versatile command-line utility that chains together high-level operations from the OpenVDB library. It can convert polygon meshes and particles into level sets, perform complex volumetric transformations, and generate adaptive meshes or ray-traced images. Results can be exported as particles, meshes, or VDB files, or streamed directly to STDOUT for seamless pipelining with other renderers. We denote the operations **actions**, and their arguments **options**. Any sequence of **actions** and their **options** can be exported and imported to configuration files, which allows convenient reuse. This command-line tool also supports a string-evaluation language that can be used to define procedural expressions for options of the actions. Currently the following list of actions are supported: -This command-line tool, dubbed vdb_tool, can combine any number of the of high-level tools available in openvdb/tools. For instance, it can convert a sequence of polygon meshes and particles to level sets, and perform a large number of operations on these level set surfaces. It can also generate adaptive polygon meshes from level sets, ray-trace images and export particles, meshes or VDBs to disk or even stream VDBs to STDOUT so other tools can render them (using pipelining). We denote the operations **actions**, and their arguments **options**. Any sequence of **actions** and their **options** can be exported and imported to configuration files, which allows convenient reuse. This command-line tool also supports a string-evaluation language that can be used to define procedural expressions for options of the actions. Currently the following list of actions are supported: - + | Action | Description | -|-------|-------| -| **for/end** | Defines the scope of a for-loop with a range for a loop-variable | -| **each/end** | Defines the scope of an each-loop with a list for a loop-variable | -| **if/end** | If-statement used to enable/disable actions | -| **eval** | Evaluate an expression written in our Reverse Polish Notation (see below) | -| **config** | Load a configuration file and add the actions for processing | -| **default** | Set default values used by all subsequent actions | -| **read** | Read mesh, points and level sets as obj, ply, abc, stl, off, pts, vdb or nvdb files | -| **write** | Write a polygon mesh, points or level set as a obj, ply, stl, off, abc or vdb file | -| **vdb2points** | Extracts points from a VDB grid | -| **mesh2ls** | Convert a polygon mesh to a narrow-band level set | -| **points2ls** | Convert points into a narrow-band level set | -| **points2vdb** | Converts points into a VDB PointDataGrid | -| **iso2ls** | Convert an iso-surface of a scalar field into a level set | -| **ls2fog** | Convert a level set into a fog volume | -| **segment** | Segment level set and float grids into its disconnected parts | -| **sphere** | Create a narrow-band level set of a sphere | -| **platonic** | Create a narrow-band level set of a tetrahedron(4), cube(6), octahedron(8), dodecahedron(12) or icosahedron(2) | -| **dilate** | Dilate a level set surface | -| **erode** | Erode a level set surface | -| **open** | Morphological opening of a level set surface | -| **close** | Morphological closing of a level set surface | -| **gauss** | Gaussian convolution of a level set surface, i.e. surface smoothing | -| **mean** | Mean-value filtering of a level set surface | -| **median** | Median-value filtering of a level set surface | -| **union** | Union of two narrow-band level sets | -| **intersection** | Intersection of two narrow-band level sets | -| **difference** | Difference of two narrow-band level sets | -| **prune** | Prune the VDB tree of a narrow-band level set | -| **flood** | Signed flood-fill of a narrow-band level set | -| **cpt** | Closest point transform of a narrow-band level set | -| **grad**| Gradient vector of a scalar VDB | -| **curl** | Curl of a vector VDB | -| **div** | Compute the divergence of a vector VDB | -| **curvature** | Mean curvature of a scalar VDB | -| **length** | Compute the magnitude of a vector VDB | -| **min** | Composite two grid by means of min | -| **max** | Composite two grid by means of max | -| **sum** | Composite two grid by means of sum | -| **multires** | Compute multi-resolution grids | -| **enright** | Advects a level set in a periodic and divergence-free velocity field. Primarily intended for benchmarks | -| **expand** | Expand the narrow band of a level set | -| **resample** | Re-sample a scalar VDB grid | -| **transform** | Apply affine transformations to VDB grids | -| **ls2mesh** | Convert a level set surface into an adaptive polygon mesh surface | -| **clip** | Clips one VDB grid with another VDB grid or a bbox or frustum | -| **render**| Render and save an image of a level set or fog VDB | -| **clear** | Deletes cached VDB grids and geometry from memory | -| **print** | Print information about the cached geometries and VDBs | +|---|---| +| **calc** | calculate string expression | +| **case** | case branch inside a -switch scope. Body runs only if key matches the parent -switch's selector. Use key=* or key=default for a catch-all that fires when no earlier case matched. Closed by -end. | +| **clear** | Deletes geometry, VDB grids and local variables | +| **clip** | Clip a VDB grid against another grid, a bbox or frustum | +| **close** | morphological closing, i.e. dilation followed by erosion, of level set surface by a fixed radius | +| **config** | Import and process one or more configuration files | +| **cpt** | generate a vector grid with the closest-point-transform to a level set surface | +| **curl** | generate a vector grid with the curl of another vector grid | +| **curvature** | generate scalar grid with the mean curvature of a level set surface | +| **debug** | print debugging information to the terminal | +| **default** | define default values to be used by subsequent actions | +| **difference** | CSG difference of two level sets surfaces | +| **dilate** | dilate level set surface by a fixed radius | +| **div** | generate a scalar grid with the divergence of a vector grid | +| **each** | start of each-loop over a user-defined loop variable and list of values. | +| **end** | marks the end scope of "-for, -each, -files, -if, -switch and -case" control actions | +| **enright** | Performs Enright advection benchmark test on a level set | +| **erode** | erode level set surface by a fixed radius | +| **errorOnWarning** | stop on warnings, i.e. treat warnings as errors | +| **eval** | evaluate string expression | +| **examples** | print examples to the terminal and terminate | +| **expand** | expand narrow band of level set | +| **files** | start of files-loop in a directory. | +| **flood** | signed-flood filling of a level set VDB | +| **fog2mesh** | Convert a fog volume to an adaptive polygon mesh | +| **for** | start of for-loop over a user-defined loop variable and range. | +| **forAllValues** | Applied a simple computational kernel to ALL values in a grid. | +| **forOffValues** | Applied a simple computational kernel to OFF values in a grid. | +| **forOnValues** | Applied a simple computational kernel to ON values in a grid. | +| **gauss** | gaussian convolution of a level set surface | +| **grad** | generate a vector grid with the gradient of a scalar grid | +| **help** | Print documentation for one, multiple or all available actions | +| **if** | start of if-scope. If the value of its option, named test, evaluates to false the entire scope is skipped | +| **intersection** | CSG intersection of two level sets surfaces | +| **iso2ls** | Convert an iso-surface of a scalar field into a level set (i.e. SDF) | +| **length** | generate a scalar grid with the magnitude of a vector grid | +| **log** | enable logging to file | +| **ls2fog** | Convert a level set VDB into a VDB with a fog volume, i.e. normalized density. | +| **ls2mesh** | Convert a level set to an adaptive polygon mesh | +| **max** | Given grids A and B, compute max(a, b) per voxel | +| **mean** | mean value filtering of a level set surface | +| **median** | median value filtering of a level set surface | +| **mesh2ls** | Convert a watertight polygon surface into a narrow-band level set, i.e. a narrow-band signed distance to a polygon mesh | +| **min** | Given grids A and B, compute min(a, b) per voxel | +| **movie** | Convert image and movie files to mpeg or animated gif files | +| **multires** | construct a LoD sequences of VDB trees with powers of two refinements | +| **open** | morphological opening, i.e. erosion followed by dilation, of a level set surface by a fixed radius | +| **platonic** | Create a level set shape with the specified number of polygon faces | +| **points2ls** | Convert geometry points into a narrow-band level set | +| **points2vdb** | Encode geometry points into a VDB grid | +| **print** | prints information to the terminal about the current stack of VDB grids and Geometry | +| **prune** | prune away inactive values in a VDB grid | +| **quad2tri** | Convert all quads in mesh to triangles, assuming they are both planar and convex | +| **quiet** | disable printing to the terminal | +| **read** | Read one or more geometry or VDB files from disk or STDIN. | +| **render** | ray-tracing of level set surfaces and volume rendering of fog volumes | +| **resample** | resample one VDB grid into another VDB grid or a transformation of the input grid | +| **scatter** | Scatter point into the active values of an input VDB grid | +| **sdf2udf** | Converts a signed distance field into an unsigned distance field, i.e. performs the Abs of all values and changes GridClass to UNKNOWN. | +| **segment** | segment an input VDB into a list if topologically disconnected VDB grids | +| **slice** | Generate images of slices of a VDB grid | +| **soup2ls** | Convert a polygon soup into a narrow-band level set, i.e. a narrow-band signed distance to a polygon mesh | +| **soup2offset** | Convert a polygon soup into an offset narrow-band level set, i.e. a narrow-band signed distance to a polygon mesh | +| **soup2udf** | Convert a polygon soup into a to a unsigned distance field with an symmetrical narrow band | +| **sphere** | Create a level set sphere, i.e. a narrow-band signed distance to a sphere | +| **sum** | Given grids A and B, compute sum(a, b) per voxel | +| **switch** | start of switch-scope. The selector value (on=) is compared against each enclosed -case's key; only the matching case body runs (or the '*'/'default' case if nothing else matched). Closed by -end. | +| **transform** | apply affine transformations (uniform scale -> rotation -> translation) to a VDB grids and geometry | +| **union** | CSG union of two level sets surfaces | +| **vdb2points** | Extract points encoded in a VDB to points in a geometry format | +| **verbose** | print timing information to the terminal | +| **version** | write timing information to the terminal | +| **vol2mesh** | Convert a scalar volume to an adaptive polygon mesh | +| **write** | Write list of geometry, VDB or config files to disk or STDOUT | + For support, bug-reports or ideas for improvements please contact ken.museth@gmail.com @@ -62,9 +91,12 @@ For support, bug-reports or ideas for improvements please contact ken.museth@gma | obj | read and write | ASCII OBJ mesh files with triangles, quads or points | | ply | read and write | Binary and ASCII PLY mesh files with triangles, quads or points | | stl | read and write | Binary STL mesh files with triangles | -| off | read and write | ASCI OFF mesh files with triangles, quads or points | +| off | read and write | ASCII OFF mesh files with triangles, quads or points | +| xyz | read and write | ASCII XYZ files with x y z coordinates, | | pts | read | ASCII PTS points files with one or more point clouds | | abc | optional read and write | Alembic binary mesh files | +| usd, usda, usdc, usdz | optional read | OpenUSD scene files; reads UsdGeomMesh and UsdGeomPoints prims | +| gltf, glb | optional read | glTF 2.0 and binary glTF mesh files via tinygltf (POSITION + indices, TRIANGLES mode) | | nvdb| optional read and write | NanoVDB file with voxels or points | | txt | read and write | ASCII configuration file for this tool | | ppm | write | Binary PPM image file | @@ -82,14 +114,320 @@ Note that this tool maintains two stacks of primitives, namely geometry (i.e. po # Stack-based string expressions -This tool supports its own light-weight stack-oriented programming language that is (very loosely) inspired by Forth. Specifically, it uses Reverse Polish Notation (RPN) to define instructions that are evaluated during paring of the command-line arguments (options to be precise). All such expressions start with the character "{", ends with "}", and arguments are separated by ":". Variables starting with "\$" are substituted by its (previously) defined values, and variables starting with "@" are stored in memory. So, "{1:2:+:@x}" is conceptually equivalent to "x = 1 + 2". Conversely, "{\$x:++}" is conceptually equivalent "2 + 1 = 3" since "x=2" was already saved to memory. This is especially useful in combination loops, e.g. "-quiet -for i=1,3,1 -eval {\$i:++} -end" will print 2 and 3 to the terminal. Branching is also supported, e.g. "radius={$x:1:>:if(0.5:sin?0.3:cos)}" is conceptually equal to "if (x>1) radius=sin(0.5) else radius=cos(0.3)". See the root-searching example below or run vdb_tool -eval help="*" to see a list of all instructions currently supported by this scripting language. Note that since this language uses characters that are interpreted by most shells it is necessary to use single quotes around strings! This is of course not the case when using config files. +This tool supports its own light-weight stack-oriented programming language that is (very loosely) inspired by Forth. Specifically, it uses Reverse Polish Notation (RPN) to define instructions that are evaluated during paring of the command-line arguments (options to be precise). All such expressions start with the character "{", ends with "}", and arguments are separated by ":". Variables starting with "\$" are substituted by its (previously) defined values, and variables starting with "@" are stored in memory. So, "{1:2:+:@x}" is conceptually equivalent to "x = 1 + 2". Conversely, "{\$x:++}" is conceptually equivalent "2 + 1 = 3" since "x=2" was already saved to memory. This is especially useful in combination with loops, e.g. "-quiet -for i=1,3,1 -eval {\$i:++} -end" will print 2 and 3 to the terminal. Branching is also supported, e.g. "radius={$x:1:>:if(0.5:sin?0.3:cos)}" is conceptually equal to "if (x>1) radius=sin(0.5) else radius=cos(0.3)". See the root-searching example below or run vdb_tool -eval help="*" to see a list of all instructions currently supported by this scripting language. Note that since this language uses characters that are interpreted by most shells it is necessary to use single quotes around strings! This is of course not the case when using config files. + +# Configuration file format + +vdb_tool can read and write configuration files (any extension is accepted, but the convention is `.txt`) that capture an entire action pipeline for replay or sharing. Run a saved config with `vdb_tool -config `; produce one from the current command line by piping it to `-write file.txt`. + +The format is intentionally tiny and line-oriented: + +1. **The first line must be a version header**: `vdb_tool MAJOR.MINOR.PATCH` (e.g. `vdb_tool 10.8.0`). Loading fails if it's missing or the major version doesn't match the running tool. +2. **One action per line**. The first non-whitespace token on each line is the action name; the leading `-` used on the command line is **implicit and must be omitted**. +3. **Subsequent tokens on the same line are that action's options/values**, separated by whitespace. E.g. `sphere r=2 voxel=0.05` is a single action with two options. +4. **Comments**: any text from `#` or `%` to the end of the line is stripped. A line whose first non-whitespace character is `#` or `%` is treated as a full-line comment. +5. **Leading and trailing whitespace are ignored**, so indenting nested control-flow scopes (`-for` / `-each` / `-files` / `-if` / `-switch` / `-case`) for readability has no effect on behavior. +6. **No shell quoting is needed** — the line is parsed verbatim. Characters that would otherwise need escaping on the command line (`*`, `{`, `}`, `$`, `(`, `)` etc.) are written plain. +7. **Blank lines are skipped.** + +Example: the [switch-statement example above](#switch-statement-to-pick-one-of-n-branches) written as a config file demonstrates all of these rules — one action per line, no leading `-`, nested scopes indented, and `key=*` unquoted. + +# Standalone calculator (-calc) + +The `-calc` action runs a single math expression through the same compiler used by the per-voxel kernels (see next section), but at command-line scope: input variables are read from the Processor's string memory (the same `{...}` namespace described above), and outputs (intermediate slot values and the trailing-LHS name) are written back to that memory. The numeric result is printed **only when the final statement is a plain expression** (no trailing `=`); a kernel that ends in an assignment is silent, since its outputs are already accessible via memory. + +The expression can be supplied either as a bare positional argument (`-calc 'x=1+2'`) or via the explicit option syntax (`-calc kernel='x=1+2'`); the two are equivalent. The bare form is supported because `-calc`'s single option is registered with `Action::kAnonymousGreedy`, so the parser accepts tokens that contain `=` without trying to interpret the prefix as an option name. + +Examples: + +```bash +# Plain expression: result is echoed. +vdb_tool -calc '1+2+3' # prints 6 + +# Single assignment: silent on -calc; the trailing LHS stores the result +# in memory. Retrieval via {$x} comes from the stack-based expression +# language above. +vdb_tool -calc 'x=1+2' -eval str='{$x}' # prints 3.000000 + +# Multi-statement: intermediate slots persist into the Processor memory +# too. The trailing assignment is silent. +vdb_tool -calc 'a=1+2; b=a*3' -eval str='a={$a} b={$b}' +# prints: a=3.000000 b=9.000000 + +# Inspect everything written to memory with -print mem=1. +vdb_tool -calc 'a=1+2;b=a+3' -print mem=1 +# prints (no leading number; -calc was silent because the kernel ended in +# an assignment): +# ... -print's "Variables" section: +# a=3.000000 +# b=6.000000 + +# Drive -for's start, stop, step from values computed by -calc. +vdb_tool -calc 'a=1;b=5;c=1' -for x='{$a},{$b},{$c}' -end +# prints: +# Processing: x = 1.000000, counter #x = 0 +# Processing: x = 2.000000, counter #x = 1 +# Processing: x = 3.000000, counter #x = 2 +# Processing: x = 4.000000, counter #x = 3 + +# Feed values into -calc from prior -eval set operations. The final +# statement is a plain expression, so the result is echoed. +vdb_tool -eval str='{2:@x}' -calc '3*sin(x)+1' # prints 3*sin(2)+1 ≈ 3.727 + +# Control flow: 'if(cond, then, else)' is a 3-arg expression. Both branches +# are evaluated eagerly; the result is selected. +vdb_tool -calc 'if(2>1, 10, 20)' # prints 10 + +# Combine if() with multi-statement to compute |x|, set in memory: +vdb_tool -eval '{-5:@x}' -calc 'abs_x = if(x>=0, x, -x)' -eval str='|x|={$abs_x}' +# prints: |x|=5.000000 + +# Signed square-root in a single kernel (no memory needed; x is a local slot). +# Final statement is a plain expression, so the result is echoed. +vdb_tool -calc 'x=-9; if(x>=0, sqrt(x), -sqrt(-x))' # prints -3 + +# Variadic switch(selector, k1, v1, ..., kN, vN, default): pick the value +# for the first ki that equals the selector, else default. +vdb_tool -eval '{2:@mode}' -calc 'switch(mode, 0, 100, 1, 200, 2, 300, -1)' +# prints: 300 + +# Classify each loop iteration with nested if(): +vdb_tool -for n=-2,3,1 -calc 'label = if(n<0, -1, if(n==0, 0, 1))' \ + -eval str='{$n}: label={$label}' -end +# prints: +# Processing: n = -2, ... -2: label=-1.000000 +# Processing: n = -1, ... -1: label=-1.000000 +# Processing: n = 0, ... 0: label=0.000000 +# Processing: n = 1, ... 1: label=1.000000 +# Processing: n = 2, ... 2: label=1.000000 +``` + +A few rules: + +- **Undefined variables are errors.** If the kernel reads a name that isn't in the Processor's memory, `-calc` throws with a message naming it. Set it first with `-eval str='{:@}'` (or via an earlier `-calc`). Reading a memory entry that exists but isn't a valid float (e.g. set by the typo `{n:@n}`) produces a diagnostic naming the variable and suggesting `{0:@n}`. +- **Reads don't rewrite memory.** A pure input read like `-calc n` leaves `mem["n"]` untouched, preserving the original string representation. This matters because `-for n=0,2,1` stores `n` as the int string `"0"`, which would break downstream int comparators if `-calc` rewrote it to `"0.000000"`. Only outputs (slots and the trailing-LHS) are written back. +- **Floats round-trip via `std::to_string`** (6 decimals). This is fine for casual chaining; for higher-precision pipelines, do all the math in one kernel and read only the final result. +- **Shell quoting.** Always single-quote the kernel value so `*`, `(`, `$`, `;`, and `=` aren't interpreted by the shell. + +# Per-voxel math kernels (forAllValues / forOnValues / forOffValues) + +The actions `-forAllValues`, `-forOnValues`, and `-forOffValues` apply a user-defined math expression to every value, every active value, or every inactive value in a `FloatGrid`. The expression is supplied via the `kernel` option, compiled once into a compact bytecode, and then evaluated in parallel across the grid — no JIT, no extra dependencies, no per-voxel string parsing. + +The reserved variable `v` is bound to the current voxel value. Any other identifier in the expression is looked up once in the Processor's string memory (the same `{...}` namespace used by `-eval` and `-calc`) and bound as a per-voxel constant; a name that isn't in memory triggers an error before any voxels are touched. This lets a kernel pull scalars set by an earlier `-eval '{2:@scale}'` or `-calc 'scale=1.5'` and combine them with the voxel value, e.g. `-forOnValues 'scale*v + bias'`. + +The voxel-variable name is configurable via the `use=` option (default `v`); for example `-forOnValues 'sin(x)+1' use=x` reads better if you prefer `x`, and is equivalent to `-forOnValues 'sin(v)+1'` — the chosen name is treated as the per-voxel input and excluded from the Processor-memory lookup performed for every other identifier. + +#### Stencil kernels: voxel-neighbor access + +When the kernel calls the voxel-variable as a function with three integer-literal offsets, e.g. `v(1, 0, 0)`, the call expands to a relative neighbor read at index-space coordinate `(i+dx, j+dy, k+dz)` where `(i, j, k)` is the current voxel. `v(0, 0, 0)` is equivalent to bare `v` (the center). Neighbor reads go through a per-thread `ConstAccessor` for cache locality, and the grid is internally deep-copied before iteration so reads come from a stable snapshot of the original state — parallel writes to the iterator's grid don't race with neighbor reads. + +```bash +# Finite-difference x-derivative. +vdb_tool -read in.vdb -forOnValues 'v(1,0,0) - v(-1,0,0)' -write out.vdb + +# 6-point discrete Laplacian. +vdb_tool -read in.vdb -forOnValues 'v(1,0,0)+v(-1,0,0)+v(0,1,0)+v(0,-1,0)+v(0,0,1)+v(0,0,-1) - 6*v' -write out.vdb + +# Jacobi smoothing of a cube: average each voxel with its 6 face neighbors. +vdb_tool -platonic faces=6 -forOnValues '(v + v(1,0,0)+v(-1,0,0)+v(0,1,0)+v(0,-1,0)+v(0,0,1)+v(0,0,-1)) / 7' -write smooth.vdb +``` + +Constraints and caveats: + +- Offsets must be **integer literals** (or unary-negated integer literals); a runtime expression like `v(dx, 0, 0)` where `dx` is a variable is rejected at compile time. This keeps each neighbor reference resolvable to a single PushVar opcode with no per-voxel arithmetic. +- The renamed voxel variable participates: `-forOnValues '...' use=x` makes `x(1, 0, 0)` the +x neighbor. +- Reads at the boundary of the active region return the grid's background value. There's no `boundary=clamp|...` option (yet); structure the kernel to tolerate it or pre-pad the active region. +- The implicit deep-copy doubles memory for the duration of the action when any non-zero neighbor offset is used. + +#### Multi-grid kernels + +`use=` and `vdb=` accept comma-separated lists so the kernel can read from more than one grid in a single pass. The first entry is the **output** grid (iterated and written); the rest are **read-only inputs**. Each name becomes a kernel-side handle for that grid: + +```bash +# Pointwise difference: write A - B into A. +vdb_tool -read a.vdb b.vdb -forOnValues 'a - b' use=a,b vdb=0,1 -write diff.vdb + +# Cross-grid finite difference: write a result that depends on a neighbor of A +# and a neighbor of B. +vdb_tool -read a.vdb b.vdb -forOnValues 'a(1,2,3) + b(0,1,2)' use=a,b vdb=0,1 -write out.vdb + +# Three-grid average. +vdb_tool -read a.vdb b.vdb c.vdb \ + -forOnValues '(a + b + c) / 3' use=a,b,c vdb=0,1,2 -write avg.vdb +``` + +Rules: + +- `use=` and `vdb=` must have the same length (errors out otherwise). +- Iteration topology is the **output grid's** active voxels; reads from input grids at those coords return the input grid's stored value (or its background if inactive there). +- Only the output grid is deep-copied (and only if the kernel reads non-zero offsets from it). Inputs are read-only, no snapshot needed. +- Each input grid gets its own per-thread `ConstAccessor`, cached across sequential voxel reads. +- Duplicate names in `use=` are rejected — kernels must be unambiguous. + +The same expression can be written in any of three equivalent syntaxes: + +| Syntax | Example | +|---|---| +| **Infix** (familiar to math users) | `'sin(v) + 2*v*v'` | +| **RPN** (same language as the rest of vdb_tool's expressions) | `'$v:sin:$v:pow2:2:*:+'` | +| **Infix multi-statement** (with assignment and reusable locals) | `'t = v*v; t + sin(t)'` | + +All three compile to identical-shape bytecode. The compiler dispatches on the markers it sees: `=` or `;` → multi-statement infix; otherwise `:` or `$` → RPN; otherwise plain infix. + +The kernel can be supplied either as a bare positional argument (`-forOnValues 'sin(v)+1'`) or via the explicit `kernel='...'` form (`-forOnValues kernel='sin(v)+1'`); the two are equivalent. Other options of the same action (e.g. `keep=true`, `class=ls`) parse normally regardless of which form you use, because the greedy fallback only kicks in for tokens whose `name=` prefix isn't a recognized option. + +### Operators (infix) + +| Op | Precedence | Associativity | +|----|-----------|---------------| +| unary `-` / `+` / `!` | 8 | right (unary `+` is a no-op, `!` is logical NOT) | +| `^` (power) | 7 | right | +| `*` `/` `%` | 6 | left | +| `+` `-` (binary) | 5 | left | +| `<` `>` `<=` `>=` | 4 | left (return 1.0/0.0) | +| `==` `!=` | 3 | left (return 1.0/0.0) | +| `&&` | 2 | left (return 1.0/0.0) | +| `||` | 1 | left (return 1.0/0.0) | + +In RPN, the punctuation operators have word-form aliases: `mod`, `lt`/`gt`/`le`/`ge`/`eq`/`ne`, `and`/`or`/`not`. + +### Functions + +| Unary | `neg` `abs` `inv` `sqrt` `sin` `cos` `tan` `asin` `acos` `atan` `sinh` `cosh` `tanh` `asinh` `acosh` `atanh` `exp` `ln` `log` `floor` `ceil` `pow2` `pow3` `sign` `round` `trunc` `not` | +|---------|---| +| **Binary** | `pow(a, b)` (also `a^b`), `min(a, b)`, `max(a, b)`, `atan2(y, x)`, `hypot(a, b)`, `step(edge, x)`, `mod(a, b)` / `fmod(a, b)` | +| **Ternary** | `clamp(x, lo, hi)`, `lerp(a, b, t)` (alias `mix`), `smoothstep(e0, e1, x)`, `if(cond, then, else)` / `select(cond, then, else)` | +| **Variadic** | `switch(selector, k1, v1, ..., kN, vN, default)` | + +`step(edge, x)` follows GLSL conventions: returns 1 when `x >= edge`, else 0. `lerp(a, b, t)` is `a*(1-t) + b*t`. `smoothstep` clamps to `[0,1]` then applies the Hermite polynomial `t*t*(3-2*t)`. `if`/`select` evaluates both branches eagerly — they're plain ternary, not short-circuit. `switch(s, k1, v1, ..., kN, vN, d)` returns `vi` for the first `ki == s` (exact equality), else `d`; like `if`, all case bodies are eagerly evaluated. The arg count must be even and at least 4. + +### Constants + +`pi`, `tau` (=2π), `e`, `phi` (golden ratio), `inf`, and `nan` are recognized as named literals in all three syntaxes. None of them can be the target of an assignment. + +### Multi-statement programs + +Multi-statement kernels are separated by `;`. Each statement except the last must be an assignment `name = `, declaring a *local slot* whose value is reused by subsequent statements. The final statement may be either a plain expression or an assignment; either way its right-hand side is the value written back to the voxel. A trailing semicolon is fine. + +```bash +# Reuse a squared subexpression instead of recomputing it. +vdb_tool -read in.vdb -forAllValues 't = v*v; t + sin(t)' -write out.vdb + +# Multiple intermediate slots; the final assignment's LHS is documentation. +vdb_tool -read in.vdb -forOnValues 'a = sin(v); b = cos(v); v = a*a + b*b' -write out.vdb + +# Pull scalar inputs from memory and combine with the voxel value: scale and +# bias were set earlier by -eval (or -calc) and applied uniformly to every +# active voxel. +vdb_tool -read in.vdb -eval '{2:@scale}' -eval '{0.5:@bias}' -forOnValues 'scale*v + bias' -write out.vdb +``` + +A slot name shadows any input variable of the same name from the point of its first assignment, mirroring ordinary scripting-language scoping. So `'v = v*2; v + 1'` reads the input `v` once on the right-hand side of the first statement, then reads the slot for every subsequent reference. + +### Example commands + +```bash +# Quadratic remap: y = sin(v) + 2*v^2 +vdb_tool -read in.vdb -forAllValues 'sin(v) + 2*v*v' -write out.vdb + +# Clamp negative values to zero (rectifier / ReLU-style): +vdb_tool -read in.vdb -forOnValues 'max(v, 0)' -write out.vdb + +# Take the absolute value: +vdb_tool -read in.vdb -forAllValues 'abs(v)' -write out.vdb + +# Smooth-step style mapping using pi: +vdb_tool -read in.vdb -forOnValues '0.5 - 0.5*cos(pi*v)' -write out.vdb + +# Same kernel in RPN, for users who prefer the existing vdb_tool language: +vdb_tool -read in.vdb -forOnValues '0.5:0.5:$pi:$v:*:cos:*:-' -write out.vdb + +# Combined with another option of the same action: the bare kernel still +# works because the greedy fallback only catches tokens whose `name=` prefix +# isn't a recognized option. +vdb_tool -read in.vdb -forOnValues 'max(v, 0)' keep=true -print +``` + +### Notes + +- **Compile-time validation.** A typo such as `'sin(v'` (mismatched paren), `'1:2:3'` (leaves three values on the stack), or `'v + 1; v + 2'` (intermediate plain expression strands a value) is rejected before the grid is touched, with a clear error message identifying the offending token or statement. +- **Undefined-variable errors throw before any voxel is touched.** Compilation accepts arbitrary identifiers; the action then resolves every variable other than `v` against the Processor's string memory and throws with the offending name (`forValues: kernel references undefined variable "scale" …`) if the lookup fails. Set the value first via `-eval '{:@}'` or `-calc '='`. +- **Thread safety.** A compiled `kernel` is evaluated in parallel via TBB. The bytecode evaluator allocates its working stack — including the slot buffer used by multi-statement kernels — on the C stack at each call, so a single compiled kernel is safely shared across all worker threads. +- **Mixing syntaxes.** `=` and `;` require pure infix; combining them with `$` or `:` is rejected by the dispatcher. +- **Shell quoting.** Always single-quote the kernel value so the shell doesn't interpret `*`, `(`, `$`, `;`, etc. + +### Advanced features + +The Calculator that drives `-calc` and the per-voxel kernels also supports several optimizations and language features beyond the basics above. + +#### Lazy `if(...)` (short-circuit semantics) + +`if(cond, then, else)` evaluates **only the taken branch**. The other branch is skipped at runtime, so kernels can guard against divisions by zero, square-roots of negatives, etc. without first evaluating the problematic expression: + +```bash +vdb_tool -calc 'if(1, 42, 1/0)' # prints 42 — 1/0 is never evaluated +vdb_tool -calc 'x=-9; if(x>=0, sqrt(x), -sqrt(-x))' # prints -3 — sqrt(-9) is never evaluated +vdb_tool -calc 'def safe_inv(x) = if(x==0, 0, 1/x); \ + safe_inv(0) + safe_inv(2)' # prints 0.5 — 1/0 never runs +``` + +Nested `if()` calls and `if()` inside user-defined function bodies also short-circuit correctly. `switch(...)` is currently eager (all case values are computed); use nested `if()` if you need lazy semantics for many cases. + +#### User-defined functions (`def`) + +A `def name(params) = body` statement registers a function. Subsequent calls to it inline the body's bytecode with the arguments bound to the parameters — no runtime call overhead. Functions can call other previously-defined functions, but **recursion is not supported** (a function referencing itself fails with "unknown function"); free variables in the body (anything not in the parameter list) are also rejected at compile time. + +```bash +# Single-parameter function +vdb_tool -calc 'def sq(x) = x*x; sq(3) + sq(4)' # prints 25 + +# Two parameters +vdb_tool -calc 'def hyp(a, b) = sqrt(a*a + b*b); hyp(3, 4)' # prints 5 + +# Composition: `cu` uses `sq` defined earlier +vdb_tool -calc 'def sq(x) = x*x; def cu(x) = x*sq(x); cu(3)' # prints 27 + +# Use a `def` inside a voxel kernel for readability: +vdb_tool -sphere -forOnValues 'def step01(t) = clamp(t, 0, 1); step01(v + 0.5)' -print +``` + +The `def` itself emits no bytecode; only call sites do. Therefore a `def` statement cannot be the final statement of a program (it has no return value). + +#### Constant folding + +Literal-only subexpressions are folded at compile time: + +```bash +vdb_tool -calc '1 + 2 + 3' # bytecode: PushLit 6 (single instruction) +vdb_tool -calc '2*pi + sqrt(16) + abs(-3)' # collapses to one literal +``` + +The fold pass runs after the parser and before lazy `if` rewriting; combined with the parser, a kernel like `kernel='sin(pi/4)*2 + a*v'` compiles down to two instructions: one `PushLit` (the precomputed `sin(pi/4)*2`) plus the per-voxel `a*v` chain. + +#### Diagnostics: column-aware error messages + +Calculator's tokenizer points at exactly where it stopped: + +``` +Calculator: unexpected character '@' in expression + 1 + @ + 2 + ^ (column 5) +``` + +#### Batched evaluation in C++ + +`Calculator::eval_n(in, out, n, varName="x")` applies a single-variable kernel across an array, suitable for vector-style transforms in tests or programmatic call-sites. + +#### Bytecode inspection + +`Calculator::disassemble()` returns a multi-line, human-readable dump of the compiled bytecode — useful when debugging kernel behavior or comparing the effect of the optimization passes. # Building this tool This tool is using CMake for build on Linux and Windows. -The only mandatory dependency of is [OpenVDB](http://www.openvdb.org). Optional dependencies include NanoVDB, libpng, libjpeg, OpenEXR, and Alembic. To enable them use the `-DUSE_=ON` flags. See the CMakeLists.txt for details. +The only mandatory dependency is [OpenVDB](http://www.openvdb.org). Optional dependencies include NanoVDB, libpng, libjpeg, OpenEXR, Alembic, PDAL, [OpenUSD](https://openusd.org), and [tinygltf](https://github.com/syoyo/tinygltf) (the latter is fetched at configure time, no system install required). To enable them use the `-DOPENVDB_TOOL_USE_=ON` flags (e.g. `-DOPENVDB_TOOL_USE_USD=ON` for USD support, `-DOPENVDB_TOOL_USE_GLTF=ON` for glTF read support, or `-DOPENVDB_TOOL_USE_ALL=ON` to enable everything). See the CMakeLists.txt for details. -The included unit test are using Gtest. Add `-DOPENVDB_BUILD_VDB_TOOL_UNITTESTS=ON` to the cmake command line to build it. +The included unit tests are using Gtest. Add `-DOPENVDB_BUILD_VDB_TOOL_UNITTESTS=ON` to the cmake command line to build it. ## Building OpenVDB @@ -140,6 +478,7 @@ vcpkg install libpng:x64-windows vcpkg install libjpeg-turbo:x64-windows vcpkg install openexr:x64-windows vcpkg install alembic:x64-windows +vcpkg install usd:x64-windows ``` ### Building @@ -153,6 +492,65 @@ cmake --build . --config Release --parallel 2 To build `vdb_tool` with NanoVDB support, pass in the `-DOPENVDB_BUILD_NANOVDB=ON` argument. +## Installing OpenUSD (optional) + +USD read support (`.usd`, `.usda`, `.usdc`, `.usdz`) requires linking against [OpenUSD](https://openusd.org/release/index.html). After installing the library, enable it at configure time with `-DOPENVDB_TOOL_USE_USD=ON` (or `-DOPENVDB_TOOL_USE_ALL=ON`) and make sure CMake can locate the `pxrConfig.cmake` file shipped by OpenUSD — typically by adding the install root to `CMAKE_PREFIX_PATH` or by setting `-Dpxr_DIR=/lib/cmake/pxr`. At runtime you may also need to point `LD_LIBRARY_PATH` (Linux), `DYLD_LIBRARY_PATH` (macOS), or `PATH` (Windows) at `/lib` so the shared libraries are found. + +### Linux / macOS (from source — recommended) + +OpenUSD is not packaged in homebrew-core and is rarely packaged in distro repositories, so the most reliable install path on both Linux and macOS is to build it from source. The official build script bootstraps all third-party dependencies and produces a self-contained install (allow ~15–30 minutes on first build): + +```bash +git clone https://github.com/PixarAnimationStudios/OpenUSD.git +python3 OpenUSD/build_scripts/build_usd.py \ + --no-imaging --no-usdview --no-alembic --no-draco --no-openimageio \ + --no-tutorials --no-examples ~/dev/src/openusd +cmake -DOPENVDB_TOOL_USE_USD=ON -Dpxr_DIR=$HOME/local/openusd/lib/cmake/pxr .. +``` + +For a slimmer build (skips Alembic, Draco, OpenImageIO, materials, imaging, etc., cutting build time substantially), pass `--no-imaging --no-alembic --no-draco --no-openimageio` to `build_usd.py`. vdb_tool only needs the core USD libraries (`usd`, `usdGeom`, `sdf`, `gf`, `vt`, `tf`). + +### Windows (vcpkg) +```bash +vcpkg install usd:x64-windows +``` +The vcpkg toolchain file already added to your CMake invocation will make OpenUSD discoverable; no extra `-Dpxr_DIR` is needed. + +vcpkg also works on Linux and macOS if you prefer it over building from source — the package name is the same (`usd`), and you supply the appropriate triplet (e.g. `arm64-osx`, `x64-linux`). + +### Verifying the install +```bash +vdb_tool -read scene.usda -print +``` +should list the imported geometry on the stack. Per-prim world transforms are baked into the vertex positions; instancing, subdivision schemes, and animation are intentionally not handled by this minimal reader. + + +## Enabling glTF support (optional) + +glTF read support (`.gltf`, `.glb`) is provided by [tinygltf](https://github.com/syoyo/tinygltf), a single-header BSD-3 reader. Unlike OpenUSD, no system install is required — CMake's `FetchContent` pulls a pinned release at configure time. Simply enable the option: +```bash +cmake -DOPENVDB_TOOL_USE_GLTF=ON .. +``` +(or pass `-DOPENVDB_TOOL_USE_ALL=ON` to enable every optional component, including glTF). The first configure downloads tinygltf into `/_deps/tinygltf-src/`; subsequent configures reuse the cached checkout. + +vdb_tool uses tinygltf in header-only mode (`TINYGLTF_HEADER_ONLY`), with image decoding disabled (`TINYGLTF_NO_STB_IMAGE` / `TINYGLTF_NO_STB_IMAGE_WRITE`) since vdb_tool only consumes mesh geometry — textures referenced by the glTF are silently skipped. + +### What's imported +- Vertex positions (POSITION attribute) and indices (UBYTE / USHORT / UINT) from every mesh primitive. +- Both indexed and non-indexed primitives. +- Only TRIANGLES mode; POINTS / LINES / STRIPS / FANS are skipped with a warning when `-verbose` is on. + +### What's not imported +- Node-graph transforms — meshes load in their local space. +- Materials, normals, UVs, vertex colors, animation, and skinning. + +### Verifying the install +```bash +vdb_tool -read model.glb -print +``` +should list the imported geometry on the stack. + + # Examples ## Getting help on all actions and their options @@ -165,12 +563,25 @@ vdb_tool -help vdb_tool -help read write ``` -## Getting help on all instructions +## Searching for an action by keyword +Lists every action whose name or documentation contains the (case-insensitive) keyword, so you can discover the right action without scrolling the full help. Add `brief=true` for a compact, one-line-per-action listing. +``` +vdb_tool -help search=mesh +``` + +## Did-you-mean suggestions for mistyped actions +A mistyped action name — whether invoked directly or queried via `-help` — reports the closest known names instead of just failing: +``` +vdb_tool -help mesh2sl # -> Did you mean: "-mesh2ls" or "-mesh2sdf"? +vdb_tool -sphre # -> Did you mean: "-sphere"? +``` + +## Getting help on all actions ``` vdb_tool -eval help="*" ``` -## Getting help on specific instructions +## Getting help on specific actions ``` vdb_tool -eval help=if,switch ``` @@ -192,26 +603,44 @@ Convert a polygon mesh file into a narrow-band level and save it to a file vdb_tool -read mesh.obj -mesh2ls -write level_set.vdb ``` -## Read multiple files +## Converting all quads in a mesh into triangles +Convert an obj file with n-gons into a ply file with only triangles +``` +vdb_tool -read mesh.obj -quad2tri -write mesh.ply +``` + +## Generate image files from slices through a VDB grid +Generates a level set of a sphere and loops over multiple slices (in the yz plane) each generating an image files +``` +vdb_tool -sphere -for x=0,1,0.01 -slice X='{$x}' -end +``` + +## Convert multiple images to a movie file +Reads multiple image files and converts them to an mpeg file +``` +vdb_tool -img2mpeg input="slice_*.ppm" output=slices.mp4 +``` + +## Read multiple specific files Convert a polygon mesh file into a narrow-band level with a transform that matches a reference vdb ``` vdb_tool -read mesh.obj,reference.vdb -mesh2ls vdb=0 -write level_set.vdb ``` ## Convert a sequence of files -Convert 5 polygon mesh files, "mesh_00{1,2,3,4,5}.obj", into separate narrow-band levels and save them to the files "level_set_0{1,2,3,4,5}.vdb". Note that the value of loop variables is accessible with a preceding "$" character and that the end of the for-loop (here 6) is exclusive.The instruction "pad0" add zero-padding and takes two arguments, the string to pad and the desired length after padding. +Convert 5 polygon mesh files, "mesh_00{1,2,3,4,5}.obj", into separate narrow-band levels and save them to the files "level_set_0{1,2,3,4,5}.vdb". Note that the value of loop variables is accessible with a preceding "$" character and that the end of the for-loop (here 6) is exclusive.The instruction "pad0" adds zero-padding and takes two arguments, the string to pad and the desired length after padding. ``` vdb_tool -for n=1,6 -read mesh_'{$n:3:pad0}'.obj -mesh2ls -write level_set_'{$n:2:pad0}'.vdb -end ``` ## Loop over specific files -Convert 5 polygon mesh files, "bunny.obj,teapot.ply,car.stl", into the Alembic files "mesh_0{1,2,3,4,5}.vdb". Note that all loop variables have a matching counter defined with a preceding "#" character. +Convert 3 polygon mesh files, "bunny.obj,teapot.ply,car.stl", into the Alembic files "mesh_0{1,2,3}.abc". Note that all loop variables have a matching counter defined with a preceding "#" character. ``` vdb_tool -each file=bunny.obj,teapot.ply,car.stl -read '{$file}' -write mesh_'{$#file:1:+:2:pad0}'.abc -end ``` ## Define voxel size from a loop-variable -Generate 5 sphere with different voxel sizes and save them all into a single vdb file +Generate 5 spheres with different voxel sizes and save them all into a single vdb file ``` vdb_tool -for v=0.01,0.06,0.01 -sphere voxel='{$v}' name=sphere_%v -end -write vdb="*" spheres.vdb ``` @@ -253,6 +682,48 @@ Read multiple grids, and render only level set grids vdb_tool -read boat_points.vdb -for v=0,'{gridCount}' -if '{$v:isLS}' -render vdb='{$v}' -end -end ``` +## Switch-statement to pick one of N branches +Loop over a few integer class labels and use `-switch` / `-case` to choose which primitive to create. The final case uses `key=*` (alternatively `key=default`) as a catch-all that fires only when none of the earlier cases matched. + +``` +vdb_tool -for c=0,4 \ + -switch on='{$c}' \ + -case key=0 -sphere -end \ + -case key=1 -platonic f=4 -end \ + -case key=2 -platonic f=8 -end \ + -case 'key=*' -platonic f=20 -end \ + -end \ + -write 'shape_{$c}.vdb' \ +-end +``` + +Each `-case` opens its own scope closed by `-end`, and the outer `-switch` is closed by the final `-end`. Numeric keys are compared by value (so `key=1` matches a selector of `1.0`); string keys are compared verbatim. The selector can be a `{...}` template expression — e.g. `-switch on='{$x:1:>}' ...` to branch on whether `x > 1`. + +The same example as a configuration file (`switch.txt`, run with `vdb_tool -config switch.txt`): + +``` +vdb_tool 10.8.0 +for c=0,4 + switch {$c} + case 0 + sphere + end + case 1 + platonic f=4 + end + case 2 + platonic f=8 + end + case * + platonic f=20 + end + end + write shape_{$c}.vdb +end +``` + +In config files, **each action goes on its own line** (the leading `-` is implicit and must be omitted), and any tokens following the action name on the same line are treated as that action's options/values. Indentation is purely cosmetic. No shell quoting is needed since the config file is parsed directly — e.g. `key=*` is written without the single quotes the shell would otherwise eat. + ## Use shell-script to define list of files Find and render thumbnails of all level sets in an entire directory structure ``` @@ -275,6 +746,7 @@ vdb_tool -i stdin.vdb -print < bunny.vdb cat bunny.vdb | vdb_tool -i stdin.vdb -print vdb_tool -sphere -o stdout.vdb | gzip > sphere.vdb.gz gzip -dc sphere.vdb.gz | vdb_tool -i stdin.vdb -print +vdb_tool -sphere -o stdout.vdb | vdb_view ``` ## Pipelining multiple instances of vdb_tool @@ -320,10 +792,94 @@ Generate adaptive meshes from a sequence of points files, points_0[200,299].vdb, vdb_tool -read mesh_mask.obj -mesh2ls voxel=0.1 width=3 -for n=200,300,1 -read points_{$n:4:pad0}.vdb -vdb2points -points2ls voxel=0.035 radius=2.142 width=3 -dilate radius=2.5 space=5 time=1 -gauss iter=2 space=5 time=1 size=1 -erode radius=2.5 space=5 time=1 -ls2mesh vdb=0 mask=1 adapt=0.005 -write mesh_{$n:4:pad0}.abc -end ``` -## Production example with complex math +## Example of a configuration file performing Particle-to-Mesh generation +``` +vdb_tool 10.8.0 + +# 1. LOAD A MASK (Optional) +# Used to clip the fluid so it doesn't leak out of the container +read collision_geo.obj +mesh2ls voxel=0.1 width=3 + +# 2. LOOP THROUGH PARTICLE SEQUENCE +# Processing frames 200 to 300 +for n=200,300,1 + + # Read the particle VDB for the current frame + read points_{$n:4:pad0}.vdb + + # Convert particles to a Level Set + # 'radius' is the particle size; 'voxel' is the grid resolution + points2ls voxel=0.035 radius=2.142 width=3 + + # SURFACE REFINEMENT + dilate radius=2.5 # Expand to merge gaps + gauss iter=2 # Smooth out the "blobby" look + erode radius=2.5 # Shrink back to original scale + + # 3. MESHING & CLIPPING + # Convert to adaptive mesh, clipped by our collision mask (vdb=1) + ls2mesh vdb=0 mask=1 adapt=0.005 + + # 4. EXPORT + write mesh_{$n:4:pad0}.abc + + # Clear the stack for the next frame to prevent memory bloat + clear +end +``` + +## Production example with complex math using RPN syntax Union 200 level set spheres scattered in a spiral pattern and ray-trace them into an image ``` vdb_tool -for n=0,200,1 -eval '{$n:137.5:*:@deg}' -eval '{$deg:d2r:@radian}' -eval '{$radian:cos:@x}' -eval '{$radian:sin:@y}' -eval '{$n:sqrt:@r}' -eval '{$r:5:+:@r_sum}' -eval '{$r_sum:0.25:pow:@pow_r}' -sphere voxel=0.1 radius='{$pow_r:0.5:*}' center='({$r:$x:*},{$r:$y:*},0)' -if '{$n:0:>}' -union -end -end -render spiral.ppm image=1024x1024 translate='(0,0,40)' ``` +## Production example with complex math using infix syntax +Union 200 level set spheres scattered in a spiral pattern and ray-trace them into an image +``` +vdb_tool -for n=0,200,1 -calc 'radian=137.5*n*pi/180; r=sqrt(n); x=r*cos(radian); y=r*sin(radian); pow_r=0.5*(5+r)^0.25' -sphere voxel=0.1 radius='{$pow_r}' center='({$x},{$y},0)' -if 'n > 0' -union -end -end -render spiral.ppm image=1024x1024 translate='(0,0,40)' +``` + +or as a config file: + +## Production example with complex math in a configuration file using infix syntax + +Same 200-sphere phyllotaxis spiral as the RPN config above, written with the more readable infix `-calc` syntax (one multi-statement kernel replaces seven sequential `-eval` calls). Notice how `x` and `y` already include the radial factor (`x = r*cos(a)`), so the sphere's `center=` doesn't need to multiply by `r` again as the RPN version does — the two examples are mathematically equivalent. +``` +vdb_tool 10.8.0 +for n=0,200,1 + # Multi-statement calc kernel. Reads `n` from for-loop memory; writes + # back a, r, x, y. The final assignment `r = 0.5*(5+r)^0.25` reuses + # the `r` slot: its right-hand side reads the OLD value (sqrt(n)), + # then overwrites the slot with the sphere radius for use below. + calc a = 137.5*n*pi/180; r=sqrt(n); x = r*cos(a); y = r*sin(a); r = 0.5*(5+r)^0.25 + sphere voxel=0.1 radius={$r} center=({$x},{$y},0) + if n > 0 # skip n==0: there's nothing to union with on the first iteration + union # CSG union of this sphere into the accumulator + end +end +render spiral.ppm image=1024x1024 translate=(0,0,40) +``` + +## Production example with complex math in a configuration file using RPN syntax +This example, based on -eval vs -calc, is only included for completion! The example above using -calc is much more user-friendly. +``` +vdb_tool 10.8.0 +for n=0,200,1 + eval {$n:137.5:*:@deg} # deg = 137.5 * n + eval {$deg:d2r:@radian} # radian = d2r(deg) + eval {$radian:cos:@x} # x = cos(radian) + eval {$radian:sin:@y} # y = sin(radian) + eval {$n:sqrt:@r} # r = sqrt(n) + eval {$r:5:+:@r_sum} # r_sum = 5 + r + eval {$r_sum:0.25:pow:@pow_r} # pow_r = pow(r_sum, 0.25) + sphere voxel=0.1 radius={$pow_r:0.5:*} center=({$r:$x:*},{$r:$y:*},0) # radius=0.5*pow_r center=(r*x, r*x,0) + if {$n:0:>} # skip n==0: there's nothing to union with on the first iteration + union # CSG union of this sphere into the accumulator + end +end +render spiral.ppm image=1024x1024 translate=(0,0,40) +``` --- + diff --git a/openvdb_cmd/vdb_tool/examples/EXAMPLES.md b/openvdb_cmd/vdb_tool/examples/EXAMPLES.md index fadc80f2c5..4e679b8b2c 100644 --- a/openvdb_cmd/vdb_tool/examples/EXAMPLES.md +++ b/openvdb_cmd/vdb_tool/examples/EXAMPLES.md @@ -1,61 +1,61 @@ # Documentation -All the configuration files in this directory can be executed as +All the configuration files in this directory can be executed as: ``` ./vdb_tool -read points_to_mesh.txt ``` # Examples -Print documentation to the terminal (and terminate) +Print documentation to the terminal (and terminate): ``` ./vdb_tool --help ``` -Convert a polygon mesh file into to another polygon mesh file +Convert one polygon mesh file into another polygon mesh file: ``` ./vdb_tool --read mesh.[obj|ply|stl] --write mesh.[obj|ply|stl] ``` -Convert multiple obj files (mesh_{00-09}.obj) into multiple ply files (mesh_{00-09}.ply) +Convert multiple OBJ files (mesh_{00-09}.obj) into multiple PLY files (mesh_{00-09}.ply): ``` ./vdb_tool --for f=0,10,1 --read mesh_{$f:2:pad0}.obj --write mesh_{$f:2:pad0}.ply -end ``` -Convert a polygon mesh file into a narrow-band level and save it to a file +Convert a polygon mesh file into a narrow-band level set and save it to a file: ``` ./vdb_tool --read mesh.obj -mesh2ls --write mesh.vdb ``` -Convert a polygon mesh file into a narrow-band level of width 2 with maximum voxels dimension of 512 and save it to a file +Convert a polygon mesh file into a narrow-band level set with half-width 2 voxels and a maximum dimension of 512 voxels, then save it to a file: ``` ./vdb_tool --read mesh.obj --mesh2ls dim=512 width=2 --write mesh.vdb ``` -Converts input points in the file points.[ply|obj|stl|pts] to a level set, perform level set operations, and written to it the file surface.vdb +Convert input points in points.[ply|obj|stl|pts] into a level set, apply a sequence of level-set operations, and write the result to surface.vdb: ``` ./vdb_tool --read points.[obj|ply|stl|pts] --points2ls --dilate --gauss --erode --write surface.vdb ``` -Converts input points in the file points.vdb to a level set, perform level set operations, and written to it the file surface.vdb +Convert input points in points.vdb into a level set, apply a sequence of level-set operations, and write the result to surface.vdb: ``` ./vdb_tool -read points.vdb -vdb2points -points2ls -dilate -gauss -erode -write surface.vdb ``` -Converts input points in the file points.[ply|obj|stl|pts] to a level set, perform level set operations, extract a polygon mesh, and save the mesh to the file surface.[ply|obj|stl|abc|pts] +Convert input points in points.[ply|obj|stl|pts] into a level set, apply level-set operations, extract a polygon mesh, and save the mesh to surface.[ply|obj|stl|abc|pts]: ``` ./vdb_tool -read points.[ply|obj|stl|pts] -points2ls -dilate -gauss -erode -ls2mesh -write surface.[ply|obj|stl|abc|pts] ``` -This examples is more verbose and demonstrates how parameters of the level set operations are specified. Note that the dilation operation is using 5'th order (WENO) spatial discretization and 2'nd order (TVD-RK) time discretization. +This example is more verbose and demonstrates how parameters of the level-set operations are specified. Note that the dilation operation uses 5th-order (WENO) spatial discretization and 2nd-order (TVD-RK) temporal discretization: ``` ./vdb_tool -read points.[ply|obj|stl|pts] -points2ls dim=256 voxel=0.1 radius=0.2 width=3 -dilate radius=2 space=5 time=2 -gauss iter=1 width=1 -erode radius=2 -ls2mesh adapt=0.25 -write output.[ply|obj|stl|abc|pts] ``` -This examples show how to save operations to a config file +This example shows how to save a sequence of operations as a config file: ``` ./vdb_tool -read points.[ply|obj|stl|pts] -points2ls dim=256 voxel=0.1 radius=0.2 width=3 -dilate radius=2 space=5 time=2 -gauss iter=1 width=1 -erode radius=2 -ls2mesh adapt=0.25 -write output.[ply|obj|stl|abc|pts] -write conf.txt ``` -This examples shows how to read operations to a config file and perform them. The file can of course be modified and re-run! +This example shows how to read a config file and execute it. The file can of course be modified and re-run: ``` ./vdb_tool -read conf.txt ``` diff --git a/openvdb_cmd/vdb_tool/examples/demos.md b/openvdb_cmd/vdb_tool/examples/demos.md index e12507b257..6a1387cfa1 100644 --- a/openvdb_cmd/vdb_tool/examples/demos.md +++ b/openvdb_cmd/vdb_tool/examples/demos.md @@ -1,4 +1,4 @@ -# actions (-) and options (= except files) +# Actions (prefixed with `-`) and their options (`name=value`, except for file lists) ``` vdb_tool -sphere vdb_tool -sphere -print @@ -9,7 +9,7 @@ vdb_tool -sphere -render tmp.jpg vdb_tool -sphere -render shader=normal tmp.jpg ``` -# internal lists of VDBs and geometry and age +# Internal stacks of VDB grids and geometry, addressed by age ``` vdb_tool -sphere -platonic -print vdb_tool -sphere -platonic -read bunny.vdb bunny.ply -print @@ -20,14 +20,14 @@ vdb_tool -read teapot.ply -points2ls v=0.1 -dilate -gauss -erode -render tmp.jpg vdb_tool -config config.txt ``` -# keep +# The `keep` option (preserve the input on the stack) ``` vdb_tool -sphere -ls2mesh -print vdb_tool -sphere -ls2mesh keep=true -print vdb_tool -sphere -ls2mesh k=1 -write sphere.vdb sphere.ply ``` -# pipe +# Unix pipes: streaming VDB grids in and out of vdb_tool ``` vdb_tool -sphere -o stdout.vdb > sphere.vdb vdb_tool -i stdin.vdb -print < bunny.vdb @@ -35,29 +35,29 @@ cat bunny.vdb | vdb_tool -i stdin.vdb -print vdb_tool -sphere -o stdout.vdb | gzip > sphere.vdb.gz gzip -dc sphere.vdb.gz | vdb_tool -i stdin.vdb -print vdb_tool -sphere -o stdout.vdb | vdb_tool -i stdin.vdb -dilate > sphere.vdb -vdb_tool -sphere -dilate -o stdout.vdb >! sphere.vdb +vdb_tool -sphere -dilate -o stdout.vdb > sphere.vdb vdb_tool -sphere -dilate -o stdout.vdb | vdb_view wget -qO- https://artifacts.aswf.io/io/aswf/openvdb/models/bunny.vdb/1.0.0/bunny.vdb-1.0.0.zip | bsdtar -xvO | vdb_tool -i stdin.vdb -dilate -o stdout.vdb | vdb_view wget -qO- https://people.sc.fsu.edu/~jburkardt/data/ply/cow.ply | vdb_tool -read stdin.ply -mesh2ls -o stdout.vdb | vdb_view ``` -# non-linear workflows: for and each loops +# Non-linear workflows: for- and each-loops ``` vdb_tool -sphere -sphere c=0.5,0,0 -sphere c=-0.5,0,0 -union -union -debug -o stdout.vdb | vdb_view vdb_tool -sphere -for x=-0.5,1,1 -sphere c={$x},0,0 -union -end -o stdout.vdb | vdb_view vdb_tool -debug -sphere n=sphere_0 -for x=-0.5,1,1 -sphere n=sphere_{$#x} c={$x},0,0 -union -end -o stdout.vdb | vdb_view ``` - # example of double loop - ``` +# Nested (double) loops +``` vdb_tool -read ~/dev/data/mesh/teapot.ply -mesh2ls -print -render vdb_tool -read ~/dev/data/mesh/*.ply -for i=0,2,1 -mesh2ls -end -print vdb_tool -for v=0.5,2,0.5 -read ~/dev/data/mesh/teapot.ply -mesh2ls voxel={$v} -render test_{$v}.png -end vdb_tool -for v=0.5,2,0.5 -each s=teapot,bunny -read ~/dev/data/mesh/{$s}.ply -mesh2ls voxel={$v} -render {$s}_{$v}.png -end -end ``` -# Enright benchmark test +# Enright benchmark test (analytic divergence-free advection) ``` vdb_tool -sphere d=64 r=0.15 c=0.35,0.35,0.35 -enright -render test.jpg vdb_tool -sphere d=64 r=0.15 c=0.35,0.35,0.35 -for i=1,10,1 -enright dt=0.1 -render enright_{$i}.png k=1 -end -o stdout.vdb | vdb_view diff --git a/openvdb_cmd/vdb_tool/examples/dilate_level_set.txt b/openvdb_cmd/vdb_tool/examples/dilate_level_set.txt index 257dc3003c..d9c00a509f 100644 --- a/openvdb_cmd/vdb_tool/examples/dilate_level_set.txt +++ b/openvdb_cmd/vdb_tool/examples/dilate_level_set.txt @@ -1,17 +1,16 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to read a narrow-band level set -# from an OpenVDB file, dilating it, and writing out the level set -# to a different OpenVDB file. +# This example demonstrates how to read a narrow-band level set from an +# OpenVDB file, dilate it, and write the result to a different OpenVDB file. -# read level set from openvdb file +# Read level set from OpenVDB file read bunny.vdb -# Dilate level set -# radius of dilation is 5.0 voxels -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK +# Dilate level set: +# radius of dilation is 5.0 voxels +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK dilate radius=5 space=5 time=1 -# write level set to file +# Write level set to file write bunny_dilated.vdb diff --git a/openvdb_cmd/vdb_tool/examples/dilate_mesh.txt b/openvdb_cmd/vdb_tool/examples/dilate_mesh.txt index ac709b7b2a..861803bc29 100644 --- a/openvdb_cmd/vdb_tool/examples/dilate_mesh.txt +++ b/openvdb_cmd/vdb_tool/examples/dilate_mesh.txt @@ -1,27 +1,27 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to read a polygon mesh from a ply file, -# converting it into a narrow-band level set, dilating the level set, -# converting the level set into an adaptive polygon mesh and writing out -# the mesh to a binary ply file. +# This example demonstrates how to read a polygon mesh from a PLY file, +# convert it into a narrow-band level set, dilate the level set, +# convert the level set back into an adaptive polygon mesh, and write +# the result to a binary PLY file. -# read mesh +# Read mesh read bunny.ply -# Mesh to level set conversion -# dimension is 256 voxels -# narrow-band level set width is 3 voxel units +# Mesh to level set conversion: +# dimension is 256 voxels along the longest axis +# narrow-band half-width is 3 voxel units mesh2ls dim=256 width=3 -# Dilate level set -# radius of dilation is 5.0 voxels -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK +# Dilate level set: +# radius of dilation is 5.0 voxels +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK dilate radius=5 space=5 time=1 -# Level set to polygon mesh conversion -# Mesh adaptivity is 0.1 % +# Level set to polygon mesh conversion: +# mesh adaptivity is 0.1 % ls2mesh adapt=0.1 -# write polygon mesh to binary ply file +# Write polygon mesh to binary PLY file write bunny_dilated.ply diff --git a/openvdb_cmd/vdb_tool/examples/erode_level_set.txt b/openvdb_cmd/vdb_tool/examples/erode_level_set.txt index e2a14c475c..deed46c944 100644 --- a/openvdb_cmd/vdb_tool/examples/erode_level_set.txt +++ b/openvdb_cmd/vdb_tool/examples/erode_level_set.txt @@ -1,17 +1,16 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to read a narrow-band level set -# from an OpenVDB file, erode it, and the level set writing -# out to a different OpenVDB file. +# This example demonstrates how to read a narrow-band level set from an +# OpenVDB file, erode it, and write the result to a different OpenVDB file. -# read level set from openvdb file +# Read level set from OpenVDB file read bunny.vdb -# Erode level set -# radius of erosion is 5.0 voxels -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK +# Erode level set: +# radius of erosion is 5.0 voxels +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK erode radius=5 space=5 time=1 -# write level set to file -write bunny_erode.vdb +# Write level set to file +write bunny_eroded.vdb diff --git a/openvdb_cmd/vdb_tool/examples/erode_mesh.txt b/openvdb_cmd/vdb_tool/examples/erode_mesh.txt index 005af0566c..47c0d51f22 100644 --- a/openvdb_cmd/vdb_tool/examples/erode_mesh.txt +++ b/openvdb_cmd/vdb_tool/examples/erode_mesh.txt @@ -1,27 +1,27 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to read a polygon mesh from a ply file, -# converting it into a narrow-band level set, erode the level set, -# converting the level set into an adaptive polygon mesh and writing out -# the mesh to a binary ply file. +# This example demonstrates how to read a polygon mesh from a PLY file, +# convert it into a narrow-band level set, erode the level set, +# convert the level set back into an adaptive polygon mesh, and write +# the result to a binary PLY file. -# read mesh +# Read mesh read bunny.ply -# Mesh to level set conversion -# dimension is 256 voxels -# narrow-band level set width is 3 voxel units +# Mesh to level set conversion: +# dimension is 256 voxels along the longest axis +# narrow-band half-width is 3 voxel units mesh2ls dim=256 width=3 -# Erode level set -# radius of erosion is 5.0 voxels -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK +# Erode level set: +# radius of erosion is 5.0 voxels +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK erode radius=5 space=5 time=1 -# Level set to polygon mesh conversion -# Mesh adaptivity is 0.1 % +# Level set to polygon mesh conversion: +# mesh adaptivity is 0.1 % ls2mesh adapt=0.1 -# write polygon mesh to binary ply file -write bunny_erode.ply +# Write polygon mesh to binary PLY file +write bunny_eroded.ply diff --git a/openvdb_cmd/vdb_tool/examples/hello_world.txt b/openvdb_cmd/vdb_tool/examples/hello_world.txt index 70920318ec..0a3458a121 100644 --- a/openvdb_cmd/vdb_tool/examples/hello_world.txt +++ b/openvdb_cmd/vdb_tool/examples/hello_world.txt @@ -1,14 +1,14 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to create a narrow-band -# level set of a sphere and save it to an OpenVDB file. +# This example demonstrates how to create a narrow-band level set of a +# sphere and save it to an OpenVDB file. -# create a narrow-band level set of a sphere -# the dimension is 256 voxels (default value) -# the radius is 100 world units (default value) -# the half width of the narrow band is 3 voxels (default value) -# the sphere is centered at (0,0,0) in world (and voxel) space (default value) +# Create a narrow-band level set of a sphere: +# dimension is 256 voxels along the longest axis (default) +# radius is 100 world units (default) +# narrow-band half-width is 3 voxels (default) +# centered at (0,0,0) in world space (default) sphere dim=256 radius=100 width=3 center=(0,0,0) -# write level set to file +# Write level set to file write sphere.vdb diff --git a/openvdb_cmd/vdb_tool/examples/level_set_to_mesh.txt b/openvdb_cmd/vdb_tool/examples/level_set_to_mesh.txt index 907449796e..e355f4ac82 100644 --- a/openvdb_cmd/vdb_tool/examples/level_set_to_mesh.txt +++ b/openvdb_cmd/vdb_tool/examples/level_set_to_mesh.txt @@ -1,15 +1,15 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to read a narrow-band level set -# from an OpenVDB file and converting the level set into an adaptive -# polygon mesh and writing out the mesh to a binary ply file. +# This example demonstrates how to read a narrow-band level set from +# an OpenVDB file, convert it into an adaptive polygon mesh, and write +# the result to a binary PLY file. -# read OpenVDB file with level set +# Read OpenVDB file with level set read bunny.vdb -# Level set to polygon mesh conversion -# Mesh adaptivity is 0.1 % +# Level set to polygon mesh conversion: +# mesh adaptivity is 0.1 % ls2mesh adapt=0.1 -# write polygon mesh to binary ply file +# Write polygon mesh to binary PLY file write bunny.ply diff --git a/openvdb_cmd/vdb_tool/examples/mesh_to_level_set.txt b/openvdb_cmd/vdb_tool/examples/mesh_to_level_set.txt index 59663f11f5..b618d9cefe 100644 --- a/openvdb_cmd/vdb_tool/examples/mesh_to_level_set.txt +++ b/openvdb_cmd/vdb_tool/examples/mesh_to_level_set.txt @@ -1,16 +1,16 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to read a polygon mesh from a ply file, -# converting it into a narrow-band level set, and writing out the level set -# to an OpenVDB file. +# This example demonstrates how to read a polygon mesh from a PLY file, +# convert it into a narrow-band level set, and write the result to an +# OpenVDB file. -# read OpenVDB file with points +# Read polygon mesh from binary PLY file read bunny.ply -# Mesh to level set conversion -# dimension is 256 voxel -# narrow-band level set width is 3 voxel units +# Mesh to level set conversion: +# dimension is 256 voxels along the longest axis +# narrow-band half-width is 3 voxel units mesh2ls dim=256 width=3 -# write level set to an openvdb fie +# Write level set to an OpenVDB file write bunny.vdb diff --git a/openvdb_cmd/vdb_tool/examples/obj_to_ply.txt b/openvdb_cmd/vdb_tool/examples/obj_to_ply.txt index 5cc4b6c848..4734b45372 100644 --- a/openvdb_cmd/vdb_tool/examples/obj_to_ply.txt +++ b/openvdb_cmd/vdb_tool/examples/obj_to_ply.txt @@ -1,10 +1,10 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This example demonstrated how to convert an ascii -# obj mesh file to a binary ply mesh file. +# This example demonstrates how to convert an ASCII OBJ mesh file +# into a binary PLY mesh file. -# read obj ascii file with polygons +# Read ASCII OBJ file with polygons read bunny.obj -# write polygon mesh to binary ply file +# Write polygon mesh to binary PLY file write bunny.ply diff --git a/openvdb_cmd/vdb_tool/examples/ply_to_obj.txt b/openvdb_cmd/vdb_tool/examples/ply_to_obj.txt index b1d6abfdc3..b7d3ee77d5 100644 --- a/openvdb_cmd/vdb_tool/examples/ply_to_obj.txt +++ b/openvdb_cmd/vdb_tool/examples/ply_to_obj.txt @@ -1,10 +1,10 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This example demonstrated how to convert a -# binary ply mesh file into an ascii obj mesh file. +# This example demonstrates how to convert a binary PLY mesh file +# into an ASCII OBJ mesh file. -# read polygon mesh from binary ply file +# Read polygon mesh from binary PLY file read bunny.ply -# write polygon mesh to ascii obj file +# Write polygon mesh to ASCII OBJ file write bunny.obj diff --git a/openvdb_cmd/vdb_tool/examples/points_to_mesh.txt b/openvdb_cmd/vdb_tool/examples/points_to_mesh.txt index b21b1c998d..9cbf2b808c 100644 --- a/openvdb_cmd/vdb_tool/examples/points_to_mesh.txt +++ b/openvdb_cmd/vdb_tool/examples/points_to_mesh.txt @@ -1,49 +1,54 @@ -vdb_tool 10.6.1 - -# This examples demonstrates how points, e.g. from a fluid -# simulation, can be surfaced and converted into a mesh surface. -# The specific sequence of operation are: 1) read polygon mesh, -# 2) convert in into a level set, 3) dilate the level set, 4) -# smooth the level set, 5) erode the level set, 6) convert the -# level set into an adaptive mesh, and 7) finally write the mesh -# to a ply file. These operations have prove (from VFX production) -# to produce high-quality surfaces. See the following for details: +vdb_tool 10.8.0 + +# This example demonstrates how points, e.g. from a fluid simulation, +# can be surfaced and converted into a polygon mesh. The specific +# sequence of operations is: +# 1) read VDB points, +# 2) extract them as geometry points, +# 3) convert the points to a narrow-band level set, +# 4) dilate the level set, +# 5) smooth the level set (Gaussian filter), +# 6) erode the level set, +# 7) convert the level set into an adaptive mesh, and +# 8) write the mesh to a binary PLY file. +# These operations have been proven (in VFX production) to produce +# high-quality surfaces. See the following for details: # https://ken.museth.org/Publications_files/meis2013_abstract_museth.pdf -# read OpenVDB file with points -read /Users/ken/dev/data/vdb/fluid_points.0100.vdb +# Read an OpenVDB file with point data +read fluid_points.0100.vdb # Extract the points from the VDB grid vdb2points -# Convert point to a narrow-band level set -# dimension is 256 voxels -# particle radius is 2.0 voxels -# half-width of the narrow-band level set is 3 voxels +# Convert points to a narrow-band level set: +# dimension is 256 voxels along the longest axis +# particle radius is 2.0 voxels +# narrow-band half-width is 3 voxels points2ls dim=256 radius=2 width=3 -# Dilate level set -# radius of dilation is 1.0 voxels -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK +# Dilate level set: +# radius of dilation is 1.0 voxels +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK dilate radius=1 space=5 time=1 -# Gaussian filtering of level set -# number of iterations is 1 -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK -# size of the filter kernel is 1.0 voxels +# Gaussian filtering of level set: +# number of iterations is 1 +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK +# filter-kernel size is 1.0 voxels gauss iter=1 space=5 time=1 size=1 -# Erode level set -# radius of erosion is 1.0 voxels -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK +# Erode level set: +# radius of erosion is 1.0 voxels +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK erode radius=1 space=5 time=1 -# Level set to polygon mesh conversion -# Mesh adaptivity is 0.1 % +# Level set to polygon mesh conversion: +# mesh adaptivity is 0.1 % ls2mesh adapt=0.1 -# write polygon mesh to binary ply file +# Write polygon mesh to binary PLY file write surface.ply diff --git a/openvdb_cmd/vdb_tool/examples/process_multiple_files.txt b/openvdb_cmd/vdb_tool/examples/process_multiple_files.txt index df6b35fed4..59ae111d24 100644 --- a/openvdb_cmd/vdb_tool/examples/process_multiple_files.txt +++ b/openvdb_cmd/vdb_tool/examples/process_multiple_files.txt @@ -1,47 +1,50 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# Define frames over which to iterate. n is the number of frames and s is the stride -# See the comments associated with the "read" and "write" options below to -# understand the implications of the "frames" option. +# Define the range of frames over which to iterate. The for-loop variable +# 'n' takes the values 100, 101, ..., 109 (the upper bound is exclusive). +# See the read and write actions below for how 'n' is used to format +# zero-padded frame numbers in filenames. for n=100,110,1 -# Read the multiple frames of the OpenVDB files with points: -# "..._points.0100.vdb", "..._points.0101.vdb, ending with -# ""..._points.0109.vdb" -read /Users/ken/dev/data/vdb/fluid_points.{$n:4:pad0}.vdb +# Read each frame of the OpenVDB point file: +# "fluid_points.0100.vdb", "fluid_points.0101.vdb", ..., "fluid_points.0109.vdb" +read fluid_points.{$n:4:pad0}.vdb -# convert vdb point to geometry points +# Extract geometry points from the VDB grid vdb2points -# PointToLevelSet dimension=256 [voxels], radius=2 [voxel units], width=3 [voxel units] +# Convert points to a narrow-band level set: +# dimension is 256 voxels along the longest axis +# particle radius is 2.0 voxels +# narrow-band half-width is 3 voxel units points2ls dim=256 radius=2 width=3 -# Dilate level set -# radius of dilation is 1.0 voxels -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK +# Dilate level set: +# radius of dilation is 1.0 voxels +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK dilate radius=1 space=5 time=1 -# Gaussian filtering of level set -# number of iterations is 1 -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK -# width of the filter kernel is 1.0 voxels +# Gaussian filtering of level set: +# number of iterations is 1 +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK +# filter-kernel size is 1.0 voxels gauss iter=1 space=5 time=1 size=1 -# Erode level set -# radius of erosion is 1.0 voxels -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK +# Erode level set: +# radius of erosion is 1.0 voxels +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK erode radius=1 space=5 time=1 -# Level set to polygon mesh conversion -# Mesh adaptivity is 0.1 % +# Level set to polygon mesh conversion: +# mesh adaptivity is 0.1 % ls2mesh adapt=0.1 -# write polygon mesh to binary ply files: -# "surface-000.ply", "surface-100.ply, ending with "surface-109.ply" +# Write each frame as a binary PLY file: +# "surface-100.ply", "surface-101.ply", ..., "surface-109.ply" write surface-{$n:3:pad0}.ply -# end for loop +# End for-loop end diff --git a/openvdb_cmd/vdb_tool/examples/simplify_mesh.txt b/openvdb_cmd/vdb_tool/examples/simplify_mesh.txt index 4168dcb012..3046c27886 100644 --- a/openvdb_cmd/vdb_tool/examples/simplify_mesh.txt +++ b/openvdb_cmd/vdb_tool/examples/simplify_mesh.txt @@ -1,20 +1,22 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to read a polygon mesh from a ply file, -# converting it into a narrow-band level set, converting the level set -# into an adaptive polygon mesh and writing out the mesh to a binary ply file. +# This example demonstrates how to simplify a polygon mesh by round-tripping +# it through a narrow-band level set and re-meshing it with adaptive +# decimation. Reading from a PLY file, converting to a level set, then +# extracting an adaptive polygon mesh produces fewer, larger polygons in +# flat regions while preserving detail near high-curvature features. -# read OpenVDB file with points +# Read polygon mesh from binary PLY file read bunny.ply -# Mesh to level set conversion -# dimension is 256 voxels -# narrow-band level set width is 3 voxel units +# Mesh to level set conversion: +# dimension is 256 voxels along the longest axis +# narrow-band half-width is 3 voxel units mesh2ls dim=256 width=3 -# Level set to polygon mesh conversion -# Mesh adaptivity is 0.25 % +# Level set to polygon mesh conversion: +# mesh adaptivity is 0.25 % (higher value = more aggressive simplification) ls2mesh adapt=0.25 -# write polygon mesh to binary ply file -write bunny_smooth.ply +# Write polygon mesh to binary PLY file +write bunny_simplified.ply diff --git a/openvdb_cmd/vdb_tool/examples/smooth_level_set.txt b/openvdb_cmd/vdb_tool/examples/smooth_level_set.txt index 70bc141393..94accfc9a9 100644 --- a/openvdb_cmd/vdb_tool/examples/smooth_level_set.txt +++ b/openvdb_cmd/vdb_tool/examples/smooth_level_set.txt @@ -1,19 +1,18 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to read a polygon mesh from a ply file, -# converting it into a narrow-band level set, smooth the level set, -# converting the level set into an adaptive polygon mesh and writing out -# the mesh to a binary ply file. +# This example demonstrates how to read a narrow-band level set from an +# OpenVDB file, smooth it with a Gaussian filter, and write the result +# to a different OpenVDB file. -# read level set from openvdb file +# Read level set from OpenVDB file read bunny.vdb -# Gaussian filtering of level set -# number of iterations is 5 -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK -# size of the filter kernel is 1.0 voxels +# Gaussian filtering of level set: +# number of iterations is 5 +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK +# filter-kernel size is 1.0 voxels gauss iter=5 space=5 time=1 size=1 -# write level set to file +# Write smoothed level set to file write bunny_smooth.vdb diff --git a/openvdb_cmd/vdb_tool/examples/smooth_mesh.txt b/openvdb_cmd/vdb_tool/examples/smooth_mesh.txt index d8772ecc97..b4a467fff6 100644 --- a/openvdb_cmd/vdb_tool/examples/smooth_mesh.txt +++ b/openvdb_cmd/vdb_tool/examples/smooth_mesh.txt @@ -1,28 +1,28 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This examples demonstrates how to read a polygon mesh from a ply file, -# converting it into a narrow-band level set, smoothing the level set, -# converting the level set into an adaptive polygon mesh and writing out -# the mesh to a binary ply file. +# This example demonstrates how to read a polygon mesh from a PLY file, +# convert it into a narrow-band level set, smooth the level set with a +# Gaussian filter, convert the level set back into an adaptive polygon +# mesh, and write the result to a binary PLY file. -# read mesh +# Read mesh read bunny.ply -# Mesh to level set conversion -# dimension is 256 voxels -# narrow-band level set width is 3 voxel units +# Mesh to level set conversion: +# dimension is 256 voxels along the longest axis +# narrow-band half-width is 3 voxel units mesh2ls dim=256 width=3 -# Gaussian filtering of level set -# number of iterations is 5 -# spatial discretization is 5'th order WENO -# temporal discretization is 1'th order TVD-RK -# size of the filter kernel is 1.0 voxels +# Gaussian filtering of level set: +# number of iterations is 5 +# spatial discretization is 5th-order WENO +# temporal discretization is 1st-order TVD-RK +# filter-kernel size is 1.0 voxels gauss iter=5 space=5 time=1 size=1 -# Level set to polygon mesh conversion -# Mesh adaptivity is 0.1 % +# Level set to polygon mesh conversion: +# mesh adaptivity is 0.1 % ls2mesh adapt=0.1 -# write polygon mesh to binary ply file +# Write polygon mesh to binary PLY file write bunny_smooth.ply diff --git a/openvdb_cmd/vdb_tool/examples/vdb_points_to_ply.txt b/openvdb_cmd/vdb_tool/examples/vdb_points_to_ply.txt index b1176e7d5a..d58af04a6a 100644 --- a/openvdb_cmd/vdb_tool/examples/vdb_points_to_ply.txt +++ b/openvdb_cmd/vdb_tool/examples/vdb_points_to_ply.txt @@ -1,13 +1,13 @@ -vdb_tool 10.6.1 +vdb_tool 10.8.0 -# This example demonstrates how to convert points -# in an OpenVDB PointDataGrid to a binary ply file. +# This example demonstrates how to convert points stored in an OpenVDB +# PointDataGrid into a binary PLY point cloud. -# read OpenVDB file with points +# Read OpenVDB file with point data read points.vdb -# extract point from VDB grid +# Extract points from VDB grid vdb2points -# write points to binary ply file +# Write points to binary PLY file write points.ply diff --git a/openvdb_cmd/vdb_tool/include/Calculator.h b/openvdb_cmd/vdb_tool/include/Calculator.h new file mode 100644 index 0000000000..b60d958ec9 --- /dev/null +++ b/openvdb_cmd/vdb_tool/include/Calculator.h @@ -0,0 +1,2128 @@ +// Copyright Contributors to the OpenVDB Project +// SPDX-License-Identifier: Apache-2.0 + +//////////////////////////////////////////////////////////////////////////////// +/// +/// @author Ken Museth +/// +/// @file Calculator.h +/// +/// @brief A compact bytecode interpreter for user-supplied math expressions +/// over an arbitrary number of named float inputs. Compiled once, +/// then evaluated many times — like a pocket calculator, hence +/// the name. Used by vdb_tool's standalone `-calc` action and by the +/// per-voxel kernels of `-forAllValues` / `-forOnValues` / +/// `-forOffValues`. +/// +/// @details Accepts three syntaxes that compile to a single shared bytecode: +/// - Reverse Polish Notation (RPN), colon-separated, e.g. +/// "$x:sin:$x:pow2:2:*:+" +/// (the same language as the vdb_tool string Processor). +/// - A single infix expression, e.g. +/// "sin(x) + 2*x*x" +/// - A semicolon-separated infix program with assignments, e.g. +/// "t = x*x; t + sin(t)" +/// and optional user-defined functions, e.g. +/// "def hyp(a, b) = sqrt(a*a + b*b); hyp(3, 4)" +/// The compiler dispatches automatically: '=' or ';' → multi- +/// statement infix; ':' or '$' → RPN; otherwise single infix. +/// The eval() loop is shared across all three. No external dependencies. +/// +/// @details Beyond plain expression evaluation, the implementation includes: +/// - **Constant folding** — literal-only subexpressions are +/// collapsed at compile time. +/// - **Lazy `if(cond, then, else)`** — only the taken branch is +/// evaluated, via Jump / JumpIfFalse opcodes inserted by a +/// `lazifyBranches()` post-compile pass. `switch` is currently +/// evaluated eagerly. +/// - **User-defined functions** — `def name(params) = body` +/// statements register functions whose bodies are inlined at each +/// call site (no recursion). +/// - **Persistent memory** — `evalAndRemember()` snapshots +/// every input, intermediate slot, and named result into a +/// `std::unordered_map` for post-eval +/// inspection. Plain `eval()` remains `const` and thread-safe. +/// - **Batched evaluation** via `eval_n()` for vector-style transforms. +/// - **Diagnostics** — tokenizer errors include a source +/// pointer with column number, and `disassemble()` produces a +/// human-readable bytecode dump for debugging. +/// +//////////////////////////////////////////////////////////////////////////////// + +#ifndef VDB_TOOL_CALCULATOR_HAS_BEEN_INCLUDED +#define VDB_TOOL_CALCULATOR_HAS_BEEN_INCLUDED + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Util.h" + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif +#ifndef M_E +#define M_E 2.71828182845904523536 +#endif + +namespace openvdb { +OPENVDB_USE_VERSION_NAMESPACE +namespace OPENVDB_VERSION_NAME { +namespace vdb_tool { + +/// @brief Compiled, reusable bytecode representation of a math expression with +/// an arbitrary number of named float variables. Re-entrant and +/// thread-safe at eval() time (the per-call working stack lives on the +/// C stack); `evalAndRemember()` is the one non-const exception. +/// +/// @details Variable names are discovered automatically by compile(): any +/// identifier in the expression that is not a built-in constant +/// (`pi`, `tau`, `e`, `phi`, `inf`, `nan`) and not the name of a +/// known function (`sin`, `abs`, `clamp`, `if`, `switch`, ...) and +/// not a previously declared local slot or user-defined function +/// becomes an *input variable*. The full list, in order of first +/// appearance, is accessible via variables(). The general eval() +/// overload binds a parallel float buffer to variables(); other +/// overloads cover the common cases (single `x`, initializer_list, +/// by-name map, and the no-input form for constant expressions), +/// and `eval_n()` applies a single-variable kernel across an array. +/// +/// @details (Multi-statement, infix only.) The expression may also contain +/// semicolon-separated statements: +/// - **Assignment** — `t = x*x` declares a local slot `t`. +/// Slot names are local (not exposed by variables()), shadow any +/// input variable of the same name from that point on, and remain +/// available to subsequent statements. The final statement may be +/// a plain expression or an assignment; either way its right-hand +/// side is what eval() returns. Each intermediate (non-final) +/// statement must be an assignment, since a plain expression +/// would strand a value on the stack. +/// - **User-defined function** — `def name(p1, p2, ...) = body` +/// registers a function whose body is inlined at each call site. +/// No recursion: a function referencing itself fails with +/// "unknown function". Free variables in the body (anything not in +/// the parameter list) are rejected at compile time. A `def` +/// statement emits no caller-side bytecode and therefore cannot +/// be the final statement. +/// +/// @details Operators and built-ins (see README.md for the full table): +/// arithmetic `+ - * / % ^`; comparisons `< > <= >= == !=` +/// returning 0/1; logical `&& || !` returning 0/1; functions for +/// trig, hyperbolic, exp/log, rounding, sign, plus 2/3-arg helpers +/// `pow`, `min`, `max`, `atan2`, `hypot`, `step`, `clamp`, `lerp` +/// (alias `mix`), `smoothstep`, `if`/`select`, and the variadic +/// `switch(selector, k1, v1, ..., default)`. +/// +/// @details `if(cond, then, else)` is **lazy** — only the taken branch +/// executes at runtime, so kernels can write `if(x>=0, sqrt(x), 0)` +/// without first evaluating sqrt(-x). `switch` is currently eager. +/// Compile-time passes include constant folding (literal-only +/// subexpressions collapse to a single PushLit) and lazifyBranches +/// (rewrites eager IfThenElse opcodes into Jump-based bytecode). +/// +/// @details Diagnostics: tokenizer errors include a caret-and-column source +/// pointer, and disassemble() returns a human-readable bytecode +/// dump — useful when validating the effect of the optimization +/// passes or debugging kernels emitted by vdb_tool's `-calc` action +/// and the per-voxel kernels of `-forAllValues` / `-forOnValues` / +/// `-forOffValues`. +class Calculator +{ +public: + + /// @brief Compile an expression into bytecode. Replaces any previous + /// compilation result (and clears any registered user-defined + /// functions and persistent memory). + /// @param expr One of: + /// - an RPN expression with colon-separated tokens, e.g. + /// `"$x:sin:$x:pow2:2:*:+"` (single statement, no `=`/`;`); + /// - a single infix expression, e.g. `"sin(theta) + radius*radius"`; + /// - or an infix multi-statement program with `;` separators, + /// assignments and/or `def` declarations, e.g. + /// `"def sq(x) = x*x; sq(3) + sq(4)"` or + /// `"t = x*x; t + sin(t)"`. + /// Any identifier that is not one of the built-in constants + /// (`pi`, `tau`, `e`, `phi`, `inf`, `nan`), not a known function, + /// not a previously declared local slot, and not a previously + /// declared user-defined function becomes an input variable, + /// queryable via variables(). + /// @throw std::invalid_argument on syntax errors, unknown function names, + /// mismatched parentheses or argument counts, stack underflow, + /// an intermediate plain expression (which would strand a value), + /// a `def` body referencing free variables, a `def` as the final + /// statement, or a final stack depth other than 1. Tokenizer + /// errors include a source pointer with column number. + void compile(const std::string &expr); + + /// @brief Variable names referenced by the compiled expression, in the + /// order in which they first appear. The general eval() overload + /// binds values positionally to this list. + const std::vector& variables() const { return mVariables; } + + /// @brief Position of @a name in variables(), or -1 if the compiled + /// expression doesn't reference it. Intended for callers that + /// want to build the positional `values` buffer for the general + /// eval() overload without doing a linear scan each call — + /// look the indices up once after compile() and reuse them. + inline int variableIndex(const std::string &name) const; + + /// @brief Evaluate the compiled expression by binding @a values positionally + /// to variables(). The pointer must address at least + /// variables().size() floats; the buffer is not modified. + /// @note Re-entrant; the working stack is allocated on the C stack inside + /// eval(), so a single Calculator instance can be shared across threads. + inline float eval(const float* values) const; + + /// @brief Convenience overload for hand-written value lists. Throws when the + /// number of supplied values doesn't match variables().size(). + inline float eval(std::initializer_list values) const; + + /// @brief Convenience overload that binds variables by name. Slower per + /// call than the positional form (one map lookup per variable); + /// intended for non-hot-path use. Throws if any of the compiled + /// variables is missing from @a bindings. + inline float eval(const std::unordered_map &bindings) const; + + /// @brief Convenience overload for single-variable expressions. Throws if + /// the compiled expression references any variable other than `x`. + inline float eval(float x) const; + + /// @brief Convenience overload for constant expressions (no input + /// variables) such as `1+2+3`. Throws if the compiled expression + /// references any variable. + inline float eval() const; + + /// @brief Batched evaluation for single-variable expressions. Applies the + /// compiled kernel to @a n inputs and writes the results into + /// @a out. Requires the expression to reference exactly the one + /// variable named by @a varName (defaults to "x") — throws + /// otherwise. Useful for per-element transforms over an array + /// without paying repeated convenience-overload validation costs. + inline void eval_n(const float* in, float* out, size_t n, + const std::string &varName = "x") const; + + // --------------------------------------------------------------------- + // Persistent memory: evalAndRemember() + accessors + // --------------------------------------------------------------------- + // + // Identical bytecode and numeric result as eval(), but additionally + // snapshots into mMemory every input value, every intermediate slot + // value, and the final expression result keyed by the trailing LHS + // (if any). Intended for REPL/test/debug use where the caller wants to + // inspect named variables after the call. + // + // Mutates mMemory, so evalAndRemember() is NOT thread-safe and is not + // marked const. eval() remains const and lock-free; use it on the hot + // path (e.g. parallel forValues) where memory inspection isn't needed. + + /// @brief Evaluate and write every input + slot + named result into + /// mMemory; retrieve via get()/has()/memory(). Not thread-safe. + inline float evalAndRemember(const float* values); + + /// @brief Map-based binding form of evalAndRemember(). + inline float evalAndRemember(const std::unordered_map &bindings); + + /// @brief Value stored in mMemory under @a name (set by the most recent + /// evalAndRemember()). Throws if no such entry exists. + inline float get(const std::string &name) const; + + /// @brief True iff mMemory contains @a name. + bool has(const std::string &name) const { return mMemory.find(name) != mMemory.end(); } + + /// @brief Whole-memory accessor. + const std::unordered_map& memory() const { return mMemory; } + + /// @brief Name from a trailing `name = ...` assignment in the compiled + /// program, or "" if the final statement is a plain expression. + /// Useful in combination with memory()/get() to learn which entry + /// corresponds to the value returned by eval(). + const std::string& resultName() const { return mResultName; } + + /// @brief Designate @a name as a "neighbor function": when called with + /// three integer-literal arguments (e.g. `v(1, 0, 0)`), the call + /// is rewritten at compile time to a single input variable named + /// `name(dx,dy,dz)`. The Calculator itself doesn't know what that + /// variable means; the binding layer (e.g. Tool::forValues) is + /// expected to parse the synthesized name and provide neighbor + /// values. Call BEFORE compile(); pass "" (default) to disable. + void setNeighborFunction(const std::string &name) + { + mNeighborFns.clear(); + if (!name.empty()) mNeighborFns.insert(name); + } + /// @brief Multi-name variant: register every name in @a names as a + /// neighbor function. Used by -forValues with `use=x,y` to allow + /// a kernel to reference more than one grid via `x(...)`/`y(...)`. + void setNeighborFunctions(const std::vector &names) + { + mNeighborFns.clear(); + for (const std::string &n : names) if (!n.empty()) mNeighborFns.insert(n); + } + /// @brief Returns true if @a name is a registered neighbor function. + bool isNeighborFunction(const std::string &name) const + { + return mNeighborFns.find(name) != mNeighborFns.end(); + } + /// @brief Returns the configured neighbor-function names. + const std::set& neighborFunctions() const { return mNeighborFns; } + + /// @brief Returns true if compile() has not yet been called (or failed). + bool empty() const { return mCode.empty(); } + /// @brief Number of bytecode instructions; useful for tests and debugging. + size_t size() const { return mCode.size(); } + + /// @brief Multi-line, human-readable dump of the compiled bytecode. + /// Shows instruction index, opcode name, and any arg/constant/name + /// reference. Intended for debugging kernels and validating compile + /// passes such as constant folding. + inline std::string disassemble() const; + +private: + + /// @brief Opcode set. PushLit/PushVar/PushSlot push to the stack; Store + /// pops the top and writes it to a slot. The arithmetic ops are + /// grouped by stack arity: 1-arg pops 1 / pushes 1; 2-arg pops 2 / + /// pushes 1; 3-arg pops 3 / pushes 1. Comparison and logical ops + /// return 0.0 or 1.0. + enum class Op : uint8_t { + PushLit, PushVar, PushSlot, + Store, + // Control flow: unconditional / conditional jumps. The Instr::arg field + // stores the absolute target index in mCode. JumpIfFalse additionally + // pops one value (the condition) and only jumps if it equals 0.0. + Jump, JumpIfFalse, + // 2-arg arithmetic + Add, Sub, Mul, Div, Mod, Pow, + // 1-arg arithmetic + Neg, Abs, Inv, Sqrt, + Sin, Cos, Tan, Asin, Acos, Atan, + Sinh, Cosh, Tanh, Asinh, Acosh, Atanh, + Exp, Ln, Log, Floor, Ceil, + Pow2, Pow3, Sign, Round, Trunc, + // 2-arg arithmetic functions + Min, Max, Atan2, Hypot, Step, + // 2-arg comparison (return 0.0/1.0) + Lt, Gt, Le, Ge, Eq, Ne, + // 2-arg logical (return 0.0/1.0). Not is 1-arg. + And, Or, Not, + // 3-arg (pop three, push one) + Clamp, Lerp, Smoothstep, IfThenElse, + // Variadic: switch(selector, k1, v1, ..., kN, vN, default). + // The Instr::arg field stores N (the number of case pairs); the + // opcode pops (2*N + 2) values and pushes 1. + Switch + }; + + /// @brief A single bytecode instruction. 4 bytes; cache-friendly. + struct Instr { + Op op; + uint16_t arg; ///< Index into mConstants (PushLit), mVariables + ///< (PushVar), or mSlotNames (PushSlot/Store); + ///< case count N for Switch; unused otherwise. + }; + + /// @brief Hard limit on the number of distinct local slot names declared + /// by intermediate assignments AND parameter/locals introduced by + /// inlined user-defined function calls. Used to size the eval-time + /// slot buffer on the C stack. + static constexpr int kSlotsMax = 64; + + std::vector mCode; + std::vector mConstants; + std::vector mVariables; + std::vector mSlotNames; + + /// @brief A compiled user-defined function. The body's bytecode references + /// its parameters as PushVar opcodes indexed into @a params; at + /// each call site we inline a copy of @a code with parameter + /// references rewritten to PushSlot ops referring to caller-side + /// slots that hold the argument values. + struct FunctionDef { + std::vector params; + std::vector code; + std::vector constants; + std::vector slotNames; + }; + std::unordered_map mFunctions; + /// @brief Set of function names whose `name(dx, dy, dz)` calls are + /// rewritten to synthesized neighbor-variable references. + /// Set via setNeighborFunction(s)(); empty disables the feature. + /// Allows kernels to reference multiple grids via different names, + /// e.g. `x(1,0,0) + y(0,1,0)` with `setNeighborFunctions({"x","y"})`. + std::set mNeighborFns; + /// @brief Set of function names currently being compiled (recursion guard). + std::unordered_map mFunctionsInProgress; + /// @brief Trailing `name = ...` from the compiled program, if any. + /// @details Pure documentation in eval(), but evalAndRemember() uses + /// this name to record the program's return value in mMemory. + std::string mResultName; + /// @brief Populated by evalAndRemember(); never read by eval(). + std::unordered_map mMemory; + + void compileRPN(const std::string &expr); + void compileInfix(const std::string &expr); + void compileStatements(const std::string &expr); + void verify(); + void foldConstants();// peephole: fold PushLit-only subexpressions at compile time + void lazifyBranches();// rewrite eager IfThenElse/Switch into Jump-based lazy bytecode + void inlineCall(const FunctionDef &fd);// splice a UDF body into mCode at the current position + void emitConst(float v); + void emitName(const std::string &name); + uint16_t allocateSlot(const std::string &name); + + /// @brief Bytecode interpreter shared by all eval()/evalAndRemember() + /// forms. If @a slotsOut is non-null, the final values of all + /// named slots are copied into it (used to snapshot mMemory). + inline float evalImpl(const float* values, float* slotsOut) const; + + static bool isIdentifier(const std::string &s); + + /// @brief Format a single-line source-pointer error message of the form + /// `msg\n source\n ^` so users see exactly where the tokenizer + /// choked. Returns the concatenated string suitable for passing to + /// std::invalid_argument. + static inline std::string pointerError(const std::string &msg, + const std::string &source, + size_t column); + + /// @brief Build a "Did you mean: ...?" suggestion line for a typo'd + /// identifier @a name. Searches every known function and constant + /// (and the supplied @a extras list, e.g. user-defined function + /// names) ranked by Levenshtein edit distance, returning the up to + /// @a maxSuggest closest candidates with distance <= @a maxDist. + /// Empty string if no candidate is close enough. + std::string suggestNames(const std::string &name, + const std::vector &extras = {}, + size_t maxDist = 2, + size_t maxSuggest = 3) const; + + static const std::unordered_map& unaryOps(); + static const std::unordered_map& binaryOps(); + static const std::unordered_map& ternaryOps(); +}; + +// ==================================================================== +// Inline implementation +// ==================================================================== + +inline const std::unordered_map& Calculator::unaryOps() +{ + // Operations that consume one stack entry and replace it with the result. + static const std::unordered_map t = { + {"neg", Op::Neg }, {"abs", Op::Abs }, {"inv", Op::Inv }, + {"sqrt", Op::Sqrt}, + {"sin", Op::Sin }, {"cos", Op::Cos }, {"tan", Op::Tan }, + {"asin", Op::Asin}, {"acos", Op::Acos}, {"atan", Op::Atan}, + {"sinh", Op::Sinh}, {"cosh", Op::Cosh}, {"tanh", Op::Tanh}, + {"asinh", Op::Asinh}, {"acosh", Op::Acosh}, {"atanh", Op::Atanh}, + {"exp", Op::Exp }, {"ln", Op::Ln }, {"log", Op::Log }, + {"floor", Op::Floor}, {"ceil", Op::Ceil}, + {"pow2", Op::Pow2}, {"pow3", Op::Pow3}, + {"sign", Op::Sign}, {"round", Op::Round}, {"trunc", Op::Trunc}, + {"not", Op::Not } + }; + return t; +} + +inline const std::unordered_map& Calculator::binaryOps() +{ + // Operations that consume two stack entries and push one result. The + // infix-syntax operators are spelled with their punctuation; the + // word-spelled aliases let RPN use the same table. + static const std::unordered_map t = { + {"+", Op::Add}, {"-", Op::Sub}, {"*", Op::Mul}, {"/", Op::Div}, + {"%", Op::Mod}, {"mod", Op::Mod}, {"fmod",Op::Mod}, + {"^", Op::Pow}, {"pow", Op::Pow}, {"min", Op::Min}, {"max", Op::Max}, + {"atan2", Op::Atan2}, {"hypot", Op::Hypot}, {"step", Op::Step}, + // Comparisons return 0.0 / 1.0 + {"<", Op::Lt}, {">", Op::Gt}, {"<=", Op::Le}, {">=", Op::Ge}, + {"==", Op::Eq}, {"!=", Op::Ne}, + {"lt", Op::Lt}, {"gt", Op::Gt}, {"le", Op::Le}, {"ge", Op::Ge}, + {"eq", Op::Eq}, {"ne", Op::Ne}, + // Logical (operate on 0.0 / non-zero, return 0.0 / 1.0) + {"&&", Op::And}, {"||", Op::Or}, + {"and", Op::And}, {"or", Op::Or} + }; + return t; +} + +inline const std::unordered_map& Calculator::ternaryOps() +{ + // Three-argument functions (clamp(x,lo,hi), lerp(a,b,t) etc.). The + // shunting-yard handles commas and pushes the args in order; the opcode + // pops three and pushes one. + static const std::unordered_map t = { + {"clamp", Op::Clamp}, + {"lerp", Op::Lerp}, {"mix", Op::Lerp}, + {"smoothstep", Op::Smoothstep}, + {"if", Op::IfThenElse}, {"select", Op::IfThenElse} + }; + return t; +} + +inline void Calculator::emitConst(float v) +{ + mCode.push_back({Op::PushLit, static_cast(mConstants.size())}); + mConstants.push_back(v); +} + +inline void Calculator::emitName(const std::string &name) +{ + // A previously declared slot shadows any input of the same name from this + // point onward — match scripting-language scoping for `x = ...; x + 1`. + for (size_t i = 0; i < mSlotNames.size(); ++i) { + if (mSlotNames[i] == name) { + mCode.push_back({Op::PushSlot, static_cast(i)}); + return; + } + } + // Otherwise the name is an input variable (linear scan; the number of + // distinct names in any realistic expression is tiny). + for (size_t i = 0; i < mVariables.size(); ++i) { + if (mVariables[i] == name) { + mCode.push_back({Op::PushVar, static_cast(i)}); + return; + } + } + mVariables.push_back(name); + mCode.push_back({Op::PushVar, static_cast(mVariables.size() - 1)}); +} + +inline uint16_t Calculator::allocateSlot(const std::string &name) +{ + // Reuse an existing slot of the same name so `t = 1; t = t + 1; t` writes + // into the same storage and `mSlotNames` doesn't grow on reassignment. + for (size_t i = 0; i < mSlotNames.size(); ++i) { + if (mSlotNames[i] == name) return static_cast(i); + } + if (mSlotNames.size() >= static_cast(kSlotsMax)) { + throw std::invalid_argument( + "Calculator: too many distinct slot names (max " + + std::to_string(kSlotsMax) + ")"); + } + mSlotNames.push_back(name); + return static_cast(mSlotNames.size() - 1); +} + +inline bool Calculator::isIdentifier(const std::string &s) +{ + // C-style identifier: [A-Za-z_][A-Za-z0-9_]* + if (s.empty()) return false; + const unsigned char c0 = static_cast(s[0]); + if (!std::isalpha(c0) && c0 != '_') return false; + for (size_t i = 1; i < s.size(); ++i) { + const unsigned char c = static_cast(s[i]); + if (!std::isalnum(c) && c != '_') return false; + } + return true; +} + +inline std::string Calculator::pointerError(const std::string &msg, + const std::string &source, + size_t column) +{ + // Tabs throw the caret off; replace them with single spaces so the marker + // lines up. Truncate very long sources to avoid one-line errors that wrap. + std::string clean = source; + for (char &c : clean) { if (c == '\t' || c == '\n') c = ' '; } + constexpr size_t kMaxLen = 80; + size_t startCol = 0; + if (clean.size() > kMaxLen) { + // Center the caret in a 80-char window. + const size_t half = kMaxLen / 2; + if (column > half) startCol = column - half; + if (startCol + kMaxLen > clean.size()) startCol = clean.size() - kMaxLen; + clean = (startCol > 0 ? "..." : "") + clean.substr(startCol, kMaxLen) + + (startCol + kMaxLen < source.size() ? "..." : ""); + } + const size_t caretCol = (column >= startCol) ? (column - startCol + (startCol > 0 ? 3 : 0)) : 0; + std::string result = msg + "\n " + clean + "\n "; + result.append(caretCol, ' '); + result += "^ (column "; + result += std::to_string(column + 1); + result += ")"; + return result; +} + +inline std::string Calculator::suggestNames(const std::string &name, + const std::vector &extras, + size_t maxDist, + size_t maxSuggest) const +{ + // Build the candidate list: every unary/binary/ternary function name, the + // built-in constants, plus the caller-supplied `extras` (typically the + // names of user-defined functions and currently-discovered variables). + // Skip punctuation-only opcode names ("+", "-", "<=", etc.) — they aren't + // valid as function-call names and just noise the suggestion list. + std::vector candidates; + candidates.reserve(64); + auto addAlpha = [&](const std::string &c) { + if (!c.empty() && std::isalpha(static_cast(c[0]))) + candidates.push_back(c); + }; + for (const auto &kv : unaryOps()) addAlpha(kv.first); + for (const auto &kv : binaryOps()) addAlpha(kv.first); + for (const auto &kv : ternaryOps()) addAlpha(kv.first); + for (const char *c : {"pi","tau","e","phi","inf","nan","switch","if","select"}) + candidates.emplace_back(c); + for (const std::string &x : extras) addAlpha(x); + // Dedup: the binary-op table has both "+" and "add" pointing at the same + // opcode, and a name may also appear in both extras and the op tables. + std::sort(candidates.begin(), candidates.end()); + candidates.erase(std::unique(candidates.begin(), candidates.end()), candidates.end()); + + // Pure-Levenshtein ranking (no substring pass — substring matches would be + // noisy across a small symbol table like sin/sinh/asin/asinh). minCandLen=1 + // keeps every alpha-starting candidate eligible (the punctuation filter + // above already removed the ones we don't want). + const auto ranked = fuzzyMatch(name, candidates, maxDist, /*minCandLen=*/1, /*useSubstring=*/false); + if (ranked.empty()) return {}; + std::string out = "Did you mean: "; + const size_t n = std::min(ranked.size(), maxSuggest); + auto it = ranked.begin(); + for (size_t i = 0; i < n; ++i, ++it) { + if (i > 0) out += (i + 1 == n ? " or " : ", "); + out += '"'; + out += it->second; + out += '"'; + } + out += '?'; + return out; +} + +inline void Calculator::compile(const std::string &expr) +{ + mCode.clear(); + mConstants.clear(); + mVariables.clear(); + mSlotNames.clear(); + mFunctions.clear(); + mFunctionsInProgress.clear(); + mResultName.clear(); + mMemory.clear(); + if (expr.empty()) { + throw std::invalid_argument("Calculator: empty expression"); + } + // Dispatch: + // '=' or ';' present -> multi-statement infix (assignments / slots). + // Note: '=' may also be part of '==', '<=', '>=', + // or '!='; compileStatements handles that by + // searching for a *standalone* '='. + // ':' or '$' present -> classic RPN, single statement + // otherwise -> classic infix, single statement + const bool hasAssign = expr.find('=') != std::string::npos; + const bool hasSemicolon = expr.find(';') != std::string::npos; + const bool hasRPNMarker = expr.find(':') != std::string::npos || + expr.find('$') != std::string::npos; + if (hasAssign || hasSemicolon) { + if (hasRPNMarker) { + throw std::invalid_argument( + "Calculator: assignment ('=') and multi-statement (';') " + "require infix syntax; remove ':'/'$' or drop the assignment"); + } + this->compileStatements(expr); + } else if (hasRPNMarker) { + this->compileRPN(expr); + } else { + this->compileInfix(expr); + } + this->foldConstants();// must run BEFORE lazifyBranches: fold rewrites the + // bytecode without preserving jump-target offsets, so + // it cannot safely operate on lazy (jump-containing) code. + this->lazifyBranches();// rewrite eager IfThenElse into Jump-based bytecode + this->verify(); +} + +inline void Calculator::compileStatements(const std::string &expr) +{ + // Split on ';'. '(' / ')' never contain a ';' in this grammar, so a flat + // scan is sufficient — no nesting tracking needed. + auto trim = [](std::string s) -> std::string { + size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); + }; + + std::vector stmts; + { + size_t start = 0; + for (size_t i = 0; i <= expr.size(); ++i) { + if (i == expr.size() || expr[i] == ';') { + std::string s = trim(expr.substr(start, i - start)); + if (!s.empty()) stmts.push_back(std::move(s)); + start = i + 1; + } + } + } + if (stmts.empty()) { + throw std::invalid_argument("Calculator: no non-empty statements"); + } + + // Find the first standalone '=' (not part of '==', '<=', '>=', or '!=') + // in a string. Used by both `def`-statement parsing and the regular + // assignment-target check below. + auto findStandaloneEq = [](const std::string &str) -> size_t { + for (size_t i = 0; i < str.size(); ++i) { + if (str[i] != '=') continue; + if (i + 1 < str.size() && str[i+1] == '=') { ++i; continue; }// skip '==' + if (i > 0 && (str[i-1] == '<' || str[i-1] == '>' || + str[i-1] == '!' || str[i-1] == '=')) continue;// part of <=, >=, !=, or trailing of == + return i; + } + return std::string::npos; + }; + + for (size_t idx = 0; idx < stmts.size(); ++idx) { + const std::string &s = stmts[idx]; + const bool isLast = (idx + 1 == stmts.size()); + + // ----------- def name(params) = body ----------- + // A `def`-statement registers a user-defined function. It does NOT + // emit any caller-side bytecode (the body is inlined at each call + // site). `def` can only appear as a non-final intermediate statement, + // since it produces no value. + if (s.compare(0, 4, "def ") == 0 || s.compare(0, 4, "def\t") == 0) { + if (isLast) { + throw std::invalid_argument( + "Calculator: `def` cannot be the final statement (it produces no value)"); + } + // Find '(' and ')' in the LHS-equivalent portion (before the standalone '='). + const size_t eqPos = findStandaloneEq(s); + if (eqPos == std::string::npos) { + throw std::invalid_argument( + "Calculator: `def` statement requires `= body`"); + } + const std::string header = trim(s.substr(4, eqPos - 4));// skip "def " + const std::string body = trim(s.substr(eqPos + 1)); + const size_t lp = header.find('('); + const size_t rp = header.rfind(')'); + if (lp == std::string::npos || rp == std::string::npos || rp < lp) { + throw std::invalid_argument( + "Calculator: `def` must take the form `def name(p1, p2, ...) = body`"); + } + const std::string name = trim(header.substr(0, lp)); + const std::string paramList = header.substr(lp + 1, rp - lp - 1); + if (!isIdentifier(name)) { + throw std::invalid_argument( + "Calculator: invalid function name \"" + name + "\""); + } + if (mFunctions.count(name)) { + throw std::invalid_argument( + "Calculator: function \"" + name + "\" is already defined"); + } + // Reject overshadowing built-ins. + if (unaryOps().count(name) || binaryOps().count(name) || ternaryOps().count(name) || + name == "switch" || name == "pi" || name == "tau" || name == "e" || + name == "phi" || name == "inf" || name == "nan") { + throw std::invalid_argument( + "Calculator: cannot redefine built-in name \"" + name + "\""); + } + // Parse parameter list (comma-separated identifiers, possibly empty). + std::vector params; + { + std::string acc; + for (size_t i = 0; i <= paramList.size(); ++i) { + if (i == paramList.size() || paramList[i] == ',') { + std::string p = trim(acc); + if (!p.empty()) { + if (!isIdentifier(p)) { + throw std::invalid_argument( + "Calculator: invalid parameter \"" + p + + "\" in def \"" + name + "\""); + } + params.push_back(p); + } + acc.clear(); + } else { + acc += paramList[i]; + } + } + } + if (body.empty()) { + throw std::invalid_argument( + "Calculator: def body for \"" + name + "\" is empty"); + } + // Compile body in a sub-scope: save caller's compile state, install + // a fresh state pre-populated with params as the input variables, + // compile, then restore. + mFunctionsInProgress[name] = true; + std::vector saved_code; std::swap(saved_code, mCode); + std::vector saved_consts; std::swap(saved_consts, mConstants); + std::vector saved_vars; std::swap(saved_vars, mVariables); + std::vector saved_slots; std::swap(saved_slots, mSlotNames); + std::string saved_result; std::swap(saved_result, mResultName); + mVariables = params;// pre-bind so PushVar(i) inside the body refers to param i + + FunctionDef fd; + fd.params = params; + try { + this->compileInfix(body); + } catch (...) { + std::swap(saved_code, mCode); + std::swap(saved_consts, mConstants); + std::swap(saved_vars, mVariables); + std::swap(saved_slots, mSlotNames); + std::swap(saved_result, mResultName); + mFunctionsInProgress.erase(name); + throw; + } + // Free variables (not in params) are a hard error. + if (mVariables.size() != params.size()) { + std::vector body_vars = std::move(mVariables); + std::swap(saved_code, mCode); + std::swap(saved_consts, mConstants); + std::swap(saved_vars, mVariables); + std::swap(saved_slots, mSlotNames); + std::swap(saved_result, mResultName); + mFunctionsInProgress.erase(name); + throw std::invalid_argument( + "Calculator: def \"" + name + "\" references free variable \"" + + body_vars[params.size()] + "\"; only declared parameters are allowed"); + } + fd.code = std::move(mCode); + fd.constants = std::move(mConstants); + fd.slotNames = std::move(mSlotNames); + + // Restore caller's state. + mCode = std::move(saved_code); + mConstants = std::move(saved_consts); + mVariables = std::move(saved_vars); + mSlotNames = std::move(saved_slots); + mResultName = std::move(saved_result); + mFunctionsInProgress.erase(name); + mFunctions[name] = std::move(fd); + continue;// no bytecode emitted at the caller level + } + // ----------- end def parsing ----------- + + // Detect 'lhs = rhs'. findStandaloneEq is hoisted above the loop. + std::string lhs, rhs; + const size_t eq = findStandaloneEq(s); + if (eq != std::string::npos) { + lhs = trim(s.substr(0, eq)); + rhs = trim(s.substr(eq + 1)); + if (findStandaloneEq(rhs) != std::string::npos) { + throw std::invalid_argument( + "Calculator: chained '=' is not supported (statement: \"" + s + "\")"); + } + if (!isIdentifier(lhs)) { + throw std::invalid_argument( + "Calculator: invalid assignment target \"" + lhs + "\""); + } + if (lhs == "pi" || lhs == "e" || lhs == "tau" || + lhs == "phi" || lhs == "inf" || lhs == "nan") { + throw std::invalid_argument( + "Calculator: cannot assign to constant \"" + lhs + "\""); + } + } else { + rhs = s; + } + if (rhs.empty()) { + throw std::invalid_argument("Calculator: empty right-hand side"); + } + + // Compile the RHS as a single infix expression. compileInfix appends + // to mCode, so each statement's instructions land in order. + this->compileInfix(rhs); + + if (!lhs.empty() && !isLast) { + // Intermediate assignment: pop the value into the named slot. + const uint16_t slot = this->allocateSlot(lhs); + mCode.push_back({Op::Store, slot}); + } else if (lhs.empty() && !isLast) { + // A plain expression in non-final position would leave a value + // on the stack with no consumer, so verify() would reject it + // anyway; reporting the structural issue here is friendlier. + throw std::invalid_argument( + "Calculator: intermediate statement must be an assignment " + "(\"name = expression\"); plain expressions are only allowed " + "as the final statement"); + } else if (!lhs.empty() && isLast) { + // Final assignment: the RHS value stays on the stack (eval's + // return value). Record the LHS name so evalAndRemember() can + // expose the result under it in mMemory. No Store opcode is + // emitted — see the docstring on mResultName. + mResultName = lhs; + } + // Final plain expression: RHS value stays on the stack; nothing else. + } +} + +inline void Calculator::compileRPN(const std::string &expr) +{ + // Allow optional surrounding "{...}" for symmetry with the Processor's + // command-line syntax; the user usually omits them since options are + // already string-valued. + std::string s = expr; + if (!s.empty() && s.front() == '{') s.erase(0, 1); + if (!s.empty() && s.back() == '}') s.pop_back(); + + const auto tokens = tokenize(s, ":"); + const auto &unary = unaryOps(); + const auto &binary = binaryOps(); + const auto &ternary = ternaryOps(); + + for (const std::string &tok : tokens) { + if (tok.empty()) continue; + + // Numeric literal (a leading '$' rules this out automatically). + float v; + if (isFloat(tok, v)) { this->emitConst(v); continue; } + + // Operations are matched against the bare token (no '$' prefix + // allowed) so they cannot be shadowed by similarly-named variables. + auto u = unary.find(tok); + if (u != unary.end()) { mCode.push_back({u->second, 0}); continue; } + auto b = binary.find(tok); + if (b != binary.end()) { mCode.push_back({b->second, 0}); continue; } + auto tn = ternary.find(tok); + if (tn != ternary.end()) { mCode.push_back({tn->second, 0}); continue; } + + // Identifier: variable or named constant. Optional leading '$' is + // stripped to match the Processor's convention ($x, $pi, ...). + std::string name = tok; + if (!name.empty() && name[0] == '$') name.erase(0, 1); + if (!isIdentifier(name)) { + { + std::vector extras; + for (const auto &kv : mFunctions) extras.push_back(kv.first); + const std::string hint = this->suggestNames(name, extras); + std::string msg = "Calculator: unknown token \"" + tok + "\""; + if (!hint.empty()) msg += "\n " + hint; + throw std::invalid_argument(msg); + } + } + if (name == "pi" ) { this->emitConst(static_cast(M_PI)); continue; } + if (name == "tau" ) { this->emitConst(static_cast(2.0L * M_PI)); continue; } + if (name == "e" ) { this->emitConst(static_cast(M_E )); continue; } + if (name == "phi" ) { this->emitConst(1.6180339887498949f); continue; } + if (name == "inf" ) { this->emitConst(std::numeric_limits::infinity()); continue; } + if (name == "nan" ) { this->emitConst(std::numeric_limits::quiet_NaN()); continue; } + this->emitName(name); + } +} + +namespace calculator_detail { + enum class Kind { End, Number, Name, BinOp, UnaryOp, LParen, RParen, Comma }; + struct Token { + Kind kind = Kind::End; + std::string text; + float value = 0.0f; + int precedence = 0; + bool rightAssoc = false; + size_t col = 0; ///< Starting column in the source expression (for error reporting). + }; +} + +inline void Calculator::compileInfix(const std::string &expr) +{ + using namespace calculator_detail; + + // Tokenize. + std::vector tokens; + const size_t N = expr.size(); + Kind prev = Kind::End; + size_t i = 0; + while (i < N) { + const char c = expr[i]; + if (std::isspace(static_cast(c))) { ++i; continue; } + Token t; + t.col = i; + if (std::isdigit(static_cast(c)) || + (c == '.' && i+1 < N && std::isdigit(static_cast(expr[i+1])))) { + // Number literal, with optional decimal point and exponent. + size_t j = i; + while (j < N && (std::isdigit(static_cast(expr[j])) || expr[j] == '.')) ++j; + if (j < N && (expr[j] == 'e' || expr[j] == 'E')) { + ++j; + if (j < N && (expr[j] == '+' || expr[j] == '-')) ++j; + while (j < N && std::isdigit(static_cast(expr[j]))) ++j; + } + t.kind = Kind::Number; + t.text = expr.substr(i, j - i); + t.value = std::stof(t.text); + i = j; + } else if (std::isalpha(static_cast(c)) || c == '_') { + // Identifier: variable, named constant, or function call. + size_t j = i; + while (j < N && (std::isalnum(static_cast(expr[j])) || expr[j] == '_')) ++j; + t.kind = Kind::Name; + t.text = expr.substr(i, j - i); + i = j; + } else if (c == '(') { t.kind = Kind::LParen; ++i; } + else if (c == ')') { t.kind = Kind::RParen; ++i; } + else if (c == ',') { t.kind = Kind::Comma; ++i; } + else if (c == '+' || c == '-' || c == '*' || c == '/' || + c == '%' || c == '^') { + // Unary +/- is signalled by what came before: nothing, a binary op, + // another unary op, an opening paren, or a comma. + const bool isUnary = (c == '+' || c == '-') && + (prev == Kind::End || prev == Kind::BinOp || + prev == Kind::UnaryOp|| prev == Kind::LParen|| + prev == Kind::Comma); + t.text = std::string(1, c); + if (isUnary) { + t.kind = Kind::UnaryOp; + t.precedence = 8; + t.rightAssoc = true; + } else { + t.kind = Kind::BinOp; + switch (c) { + case '+': t.precedence = 5; break; + case '-': t.precedence = 5; break; + case '*': t.precedence = 6; break; + case '/': t.precedence = 6; break; + case '%': t.precedence = 6; break; + case '^': t.precedence = 7; t.rightAssoc = true; break; + } + } + ++i; + } else if (c == '<' || c == '>') { + // <, <=, >, >= + t.kind = Kind::BinOp; + t.precedence = 4; + if (i+1 < N && expr[i+1] == '=') { + t.text = (c == '<') ? "<=" : ">="; + i += 2; + } else { + t.text = std::string(1, c); + ++i; + } + } else if (c == '=') { + // Only '==' is valid in expressions; bare '=' is the statement + // separator handled at the multi-statement level. + if (i+1 < N && expr[i+1] == '=') { + t.kind = Kind::BinOp; + t.precedence = 3; + t.text = "=="; + i += 2; + } else { + throw std::invalid_argument(pointerError( + "Calculator: unexpected '=' in expression (use '==' for equality, " + "and only the top-level statement may assign)", expr, i)); + } + } else if (c == '!') { + // '!=' (binary) or unary logical NOT + if (i+1 < N && expr[i+1] == '=') { + t.kind = Kind::BinOp; + t.precedence = 3; + t.text = "!="; + i += 2; + } else { + t.kind = Kind::UnaryOp; + t.precedence = 8; + t.rightAssoc = true; + t.text = "!"; + ++i; + } + } else if (c == '&') { + // Only '&&' is valid. + if (i+1 < N && expr[i+1] == '&') { + t.kind = Kind::BinOp; + t.precedence = 2; + t.text = "&&"; + i += 2; + } else { + throw std::invalid_argument(pointerError( + "Calculator: unexpected '&' in expression (use '&&' for logical AND)", + expr, i)); + } + } else if (c == '|') { + // Only '||' is valid. + if (i+1 < N && expr[i+1] == '|') { + t.kind = Kind::BinOp; + t.precedence = 1; + t.text = "||"; + i += 2; + } else { + throw std::invalid_argument(pointerError( + "Calculator: unexpected '|' in expression (use '||' for logical OR)", + expr, i)); + } + } else { + throw std::invalid_argument(pointerError( + "Calculator: unexpected character '" + std::string(1, c) + "' in expression", + expr, i)); + } + tokens.push_back(t); + prev = t.kind; + } + + // Shunting-yard, emitting straight into mCode/mConstants. + std::vector opStack; + // commaCounts[i] counts the commas seen at the i-th currently-open + // paren scope (synchronized with the LParen entries on opStack). + // Used to determine the arity of variadic functions like switch(). + std::vector commaCounts; + const auto &unary = unaryOps(); + const auto &binary = binaryOps(); + const auto &ternary = ternaryOps(); + + auto emit = [&](const Token &op) { + if (op.kind == Kind::UnaryOp) { + if (op.text == "-") mCode.push_back({Op::Neg, 0}); + else if (op.text == "!") mCode.push_back({Op::Not, 0}); + // unary '+' is a no-op + return; + } + if (op.kind == Kind::BinOp) { + auto it = binary.find(op.text); + if (it == binary.end()) + throw std::invalid_argument("Calculator: unknown operator \"" + op.text + "\""); + mCode.push_back({it->second, 0}); + return; + } + if (op.kind == Kind::Name) { + auto u = unary.find(op.text); + if (u != unary.end()) { mCode.push_back({u->second, 0}); return; } + auto b = binary.find(op.text); + if (b != binary.end()) { mCode.push_back({b->second, 0}); return; } + auto tn = ternary.find(op.text); + if (tn != ternary.end()) { mCode.push_back({tn->second, 0}); return; } + { + std::vector extras; + for (const auto &kv : mFunctions) extras.push_back(kv.first); + const std::string hint = this->suggestNames(op.text, extras); + std::string msg = "Calculator: unknown function \"" + op.text + "\""; + if (!hint.empty()) msg += "\n " + hint; + throw std::invalid_argument(msg); + } + } + throw std::invalid_argument("Calculator: internal: cannot emit this token"); + }; + + for (size_t k = 0; k < tokens.size(); ++k) { + const Token &t = tokens[k]; + switch (t.kind) { + case Kind::Number: + this->emitConst(t.value); + break; + case Kind::Name: { + // If the next token is '(' this is a function call; otherwise a + // variable or named constant. Any identifier that is neither a + // built-in constant nor a function name becomes a variable, so + // the dispatch below recognizes only the constants explicitly. + const bool isFunction = (k + 1 < tokens.size() && tokens[k+1].kind == Kind::LParen); + if (isFunction) { + opStack.push_back(t); + } else if (t.text == "pi" ) { + this->emitConst(static_cast(M_PI)); + } else if (t.text == "tau") { + this->emitConst(static_cast(2.0L * M_PI)); + } else if (t.text == "e" ) { + this->emitConst(static_cast(M_E)); + } else if (t.text == "phi") { + this->emitConst(1.6180339887498949f); + } else if (t.text == "inf") { + this->emitConst(std::numeric_limits::infinity()); + } else if (t.text == "nan") { + this->emitConst(std::numeric_limits::quiet_NaN()); + } else { + this->emitName(t.text); + } + break; + } + case Kind::UnaryOp: + opStack.push_back(t); + break; + case Kind::BinOp: + while (!opStack.empty()) { + const Token &top = opStack.back(); + if (top.kind == Kind::LParen) break; + bool popTop; + if (top.kind == Kind::Name || top.kind == Kind::UnaryOp) { + popTop = true;// functions and unary ops bind tighter + } else { + popTop = (top.precedence > t.precedence) || + (top.precedence == t.precedence && !t.rightAssoc); + } + if (!popTop) break; + emit(top); + opStack.pop_back(); + } + opStack.push_back(t); + break; + case Kind::LParen: + opStack.push_back(t); + commaCounts.push_back(0);// new paren scope; track commas for variadic-arity dispatch + break; + case Kind::Comma: + while (!opStack.empty() && opStack.back().kind != Kind::LParen) { + emit(opStack.back()); + opStack.pop_back(); + } + if (opStack.empty() || commaCounts.empty()) + throw std::invalid_argument(pointerError( + "Calculator: ',' outside function call", expr, t.col)); + ++commaCounts.back(); + break; + case Kind::RParen: { + while (!opStack.empty() && opStack.back().kind != Kind::LParen) { + emit(opStack.back()); + opStack.pop_back(); + } + if (opStack.empty()) + throw std::invalid_argument(pointerError( + "Calculator: mismatched closing parenthesis", expr, t.col)); + const int commas = commaCounts.empty() ? 0 : commaCounts.back(); + if (!commaCounts.empty()) commaCounts.pop_back(); + opStack.pop_back();// discard the LParen + if (!opStack.empty() && opStack.back().kind == Kind::Name) { + const std::string fname = opStack.back().text; + const int argCount = commas + 1;// each comma separates two args; ≥1 if parens were non-empty + // Neighbor-function rewrite: f(dx, dy, dz) with integer- + // literal args -> a single PushVar with a synthesized name + // "f(dx,dy,dz)". The binding layer is expected to interpret + // the synthesized name as a relative neighbor lookup. + if (this->isNeighborFunction(fname)) { + if (argCount != 3) { + throw std::invalid_argument( + "Calculator: \"" + fname + "(...)\" denotes a voxel neighbor " + "and requires exactly 3 integer offsets, e.g. " + + fname + "(1, 0, 0) — got " + std::to_string(argCount) + + " argument(s)"); + } + // Pop each argument off the back of mCode. An argument + // is either a single PushLit (positive literal) or a + // pair PushLit-then-Neg (negative literal — unary '-' + // compiles to Neg in this codepath since constant + // folding hasn't run yet). Anything else means the + // user wrote a runtime expression and we reject it. + auto popOffset = [&](int argIdx) -> int { + if (mCode.empty()) { + throw std::invalid_argument( + "Calculator: \"" + fname + "(dx, dy, dz)\": missing " + "literal for arg " + std::to_string(argIdx)); + } + bool negate = false; + if (mCode.back().op == Op::Neg) { + negate = true; + mCode.pop_back(); + if (mCode.empty()) { + throw std::invalid_argument( + "Calculator: \"" + fname + "(dx, dy, dz)\": unary '-' " + "without literal for arg " + std::to_string(argIdx)); + } + } + if (mCode.back().op != Op::PushLit) { + throw std::invalid_argument( + "Calculator: \"" + fname + "(dx, dy, dz)\" requires " + "integer-literal offsets (got a runtime expression for " + "arg " + std::to_string(argIdx) + ")"); + } + const float f = mConstants[mCode.back().arg]; + if (std::isnan(f) || std::isinf(f) || f != std::floor(f)) { + throw std::invalid_argument( + "Calculator: \"" + fname + "(dx, dy, dz)\" requires " + "integer offsets (got " + std::to_string(f) + + " for arg " + std::to_string(argIdx) + ")"); + } + mCode.pop_back(); + const int v = static_cast(f); + return negate ? -v : v; + }; + // Args were pushed left-to-right, so we pop right-to-left. + const int dz_v = popOffset(3); + const int dy_v = popOffset(2); + const int dx_v = popOffset(1); + const int off[3] = {dx_v, dy_v, dz_v}; + // Synthesize a variable name encoding the offset. The + // form "name(dx,dy,dz)" mirrors what the user wrote; + // the parentheses make it unambiguous vs. ordinary names. + const std::string nbrName = fname + "(" + + std::to_string(off[0]) + "," + + std::to_string(off[1]) + "," + + std::to_string(off[2]) + ")"; + this->emitName(nbrName); + } else if (fname == "switch") { + if (argCount < 4 || (argCount % 2) != 0) { + throw std::invalid_argument( + "Calculator: switch() requires an even number of arguments " + ">= 4 (form: switch(selector, k1, v1, ..., kN, vN, default), " + "got " + std::to_string(argCount) + ")"); + } + const uint16_t cases = static_cast((argCount - 2) / 2); + mCode.push_back({Op::Switch, cases}); + } else { + // User-defined function call? Inline its body. + auto fnIt = mFunctions.find(fname); + if (fnIt != mFunctions.end()) { + const FunctionDef &fd = fnIt->second; + if (argCount != static_cast(fd.params.size())) { + throw std::invalid_argument( + "Calculator: function \"" + fname + "\" expects " + + std::to_string(fd.params.size()) + " argument(s), got " + + std::to_string(argCount)); + } + this->inlineCall(fd); + } else { + emit(opStack.back()); + } + } + opStack.pop_back(); + } + break; + } + break; + default: + throw std::invalid_argument("Calculator: internal: unexpected infix token"); + } + } + while (!opStack.empty()) { + const Token &top = opStack.back(); + if (top.kind == Kind::LParen) + throw std::invalid_argument(pointerError( + "Calculator: mismatched opening parenthesis", expr, top.col)); + emit(top); + opStack.pop_back(); + } +} + +inline void Calculator::inlineCall(const FunctionDef &fd) +{ + // The call site has just pushed `fd.params.size()` argument values onto + // the runtime stack (last param on top). Plan: + // 1. Allocate a unique slot in mSlotNames for each parameter. + // 2. Emit Store opcodes in REVERSE order (top-of-stack pops first into + // the LAST parameter's slot). + // 3. Splice in a copy of fd.code, rewriting: + // - PushLit: relocate the constant pool index. + // - PushVar i (body-side parameter reference): rewrite as PushSlot + // of the caller's slot for that parameter. + // - PushSlot/Store: relocate the slot-name index. + // - Jump/JumpIfFalse: relocate to absolute caller-side positions. + // + // The body has been compiled with body-level lazifyBranches/foldConstants + // already applied (compile() runs them on the sub-scope before this is + // called), so body's jumps are already encoded as absolute targets within + // the body's own bytecode coordinate frame; we just shift them by the + // pre-splice mCode.size(). + + // Step 1: per-call parameter slots. Use a fresh, uniquified name per call + // so nested calls don't collide. + static int call_counter = 0; + const int call_id = ++call_counter; + std::vector paramSlot(fd.params.size()); + for (size_t i = 0; i < fd.params.size(); ++i) { + paramSlot[i] = this->allocateSlot( + "__arg" + std::to_string(call_id) + "_" + fd.params[i]); + } + + // Step 2: store args in reverse (top-of-stack first). + for (size_t i = fd.params.size(); i-- > 0; ) { + mCode.push_back({Op::Store, paramSlot[i]}); + } + + // Pre-compute offsets used to rewrite the body. + const uint16_t constOffset = static_cast(mConstants.size()); + const uint16_t slotOffset = static_cast(mSlotNames.size()); + const size_t codeOffset = mCode.size(); + + // Append body's constants. + for (float c : fd.constants) mConstants.push_back(c); + // Append body's slot names (the body-local ones, distinct from param slots). + for (const std::string &n : fd.slotNames) { + mSlotNames.push_back("__local" + std::to_string(call_id) + "_" + n); + } + + // Step 3: copy the body's bytecode, rewriting indices. + for (const Instr &in : fd.code) { + Instr out = in; + switch (in.op) { + case Op::PushLit: + out.arg = static_cast(in.arg + constOffset); + break; + case Op::PushVar: + // Body's PushVar(i) refers to fd.params[i]. Rewrite as PushSlot + // of the caller's per-call parameter slot. + out.op = Op::PushSlot; + out.arg = paramSlot[in.arg]; + break; + case Op::PushSlot: case Op::Store: + out.arg = static_cast(in.arg + slotOffset); + break; + case Op::Jump: case Op::JumpIfFalse: + out.arg = static_cast(in.arg + codeOffset); + break; + default: + break; + } + mCode.push_back(out); + } +} + +inline void Calculator::lazifyBranches() +{ + // Rewrite eager Op::IfThenElse into Jump/JumpIfFalse-based lazy form so + // only the taken branch is evaluated. Op::Switch is left eager: lazy + // switch needs the selector duplicated across each case test, which would + // require a Dup opcode or anonymous slot — bigger surgery, deferred. + // + // Approach: forward-walk mCode, building newCode while tracking the + // start-position of each live stack value (origin stack). When we hit + // Op::IfThenElse, the three top origins point at the start of cond/then/ + // else spans inside newCode; splice in JumpIfFalse and Jump and patch + // any existing jump targets that get shifted by the insertions. + if (mCode.empty()) return; + + auto stackEffect = [](Op op, int arg) -> std::pair { + // returns {pops, pushes} + switch (op) { + case Op::PushLit: case Op::PushVar: case Op::PushSlot: return {0, 1}; + case Op::Store: return {1, 0}; + case Op::Jump: return {0, 0}; + case Op::JumpIfFalse: return {1, 0}; + case Op::Add: case Op::Sub: case Op::Mul: case Op::Div: + case Op::Mod: case Op::Pow: + case Op::Min: case Op::Max: case Op::Atan2: case Op::Hypot: case Op::Step: + case Op::Lt: case Op::Gt: case Op::Le: case Op::Ge: + case Op::Eq: case Op::Ne: + case Op::And: case Op::Or: return {2, 1}; + case Op::Neg: case Op::Abs: case Op::Inv: case Op::Sqrt: + case Op::Sin: case Op::Cos: case Op::Tan: + case Op::Asin: case Op::Acos: case Op::Atan: + case Op::Sinh: case Op::Cosh: case Op::Tanh: + case Op::Asinh: case Op::Acosh: case Op::Atanh: + case Op::Exp: case Op::Ln: case Op::Log: + case Op::Floor: case Op::Ceil: + case Op::Pow2: case Op::Pow3: + case Op::Sign: case Op::Round: case Op::Trunc: case Op::Not: + return {1, 1}; + case Op::Clamp: case Op::Lerp: case Op::Smoothstep: case Op::IfThenElse: + return {3, 1}; + case Op::Switch: return {2 * arg + 2, 1}; + } + return {0, 0}; + }; + + std::vector newCode; + newCode.reserve(mCode.size() + 8); + std::vector originStack; + originStack.reserve(16); + + // Shift any existing Jump/JumpIfFalse target STRICTLY greater than `from` + // by `delta`. Targets equal to `from` stay put: when inserting at position + // P, the new instruction occupies P and the old content moves to P+1, but + // jumps that pointed at "between P-1 and P" semantically want to land on + // the new instruction at P. Pointing at P itself (the inserted Jump/JIF) + // is the correct semantics for branch-end markers; we only shift jumps + // whose targets sat beyond P. + auto shiftTargets = [&newCode](size_t from, int delta) { + for (Instr &j : newCode) { + if ((j.op == Op::Jump || j.op == Op::JumpIfFalse) && j.arg > from) { + j.arg = static_cast(static_cast(j.arg) + delta); + } + } + }; + + for (size_t k = 0; k < mCode.size(); ++k) { + const Instr in = mCode[k]; + const auto [pops, pushes] = stackEffect(in.op, static_cast(in.arg)); + + if (in.op == Op::IfThenElse) { + // Origin stack top-3 are cond/then/else start positions in newCode. + const size_t cond_start = originStack[originStack.size() - 3]; + const size_t then_start = originStack[originStack.size() - 2]; + const size_t else_start = originStack[originStack.size() - 1]; + const size_t end_pos = newCode.size();// position AFTER else bytecode + (void)cond_start;// only used for clarity / documentation + + // Step 1: insert Jump (target patched later) right BEFORE else_start. + // After insertion, any jumps with target >= else_start shift by +1. + shiftTargets(else_start, +1); + const size_t jump_pos = else_start; + newCode.insert(newCode.begin() + jump_pos, Instr{Op::Jump, 0}); + const size_t new_else_start = else_start + 1; + const size_t new_end_pos = end_pos + 1; + + // Step 2: insert JumpIfFalse right BEFORE then_start. + shiftTargets(then_start, +1); + const size_t jif_pos = then_start; + newCode.insert(newCode.begin() + jif_pos, Instr{Op::JumpIfFalse, 0}); + const size_t new_jump_pos = jump_pos + 1; + const size_t new_new_else_start = new_else_start + 1; + const size_t new_new_end_pos = new_end_pos + 1; + + // Step 3: set targets. + newCode[jif_pos ].arg = static_cast(new_new_else_start); + newCode[new_jump_pos].arg = static_cast(new_new_end_pos); + + // Don't emit Op::IfThenElse — its job is done by the jumps. + // Origin stack: pop 3, push 1 (result origin = cond_start, the + // start of the entire branch construct). + originStack.pop_back(); + originStack.pop_back(); + // top is now cond_start; leave it as the merged origin. + // (It correctly identifies the start of this lazy if expression.) + continue; + } + + // Default: copy instruction; rewrite jump targets if it's a jump + // (preserving relative semantics — Jump targets in mCode are unused + // pre-lazify, but be safe in case re-running this pass). + newCode.push_back(in); + + // Update origin stack to mirror runtime stack effects. + const size_t resultOrigin = (pushes == 1 && pops >= 1) + ? originStack[originStack.size() - pops] + : newCode.size() - 1; + for (int p = 0; p < pops; ++p) originStack.pop_back(); + if (pushes == 1) originStack.push_back(resultOrigin); + } + + mCode = std::move(newCode); +} + +inline void Calculator::foldConstants() +{ + // Single pass: walk mCode left-to-right with a parallel "constant-stack" + // that records, for each value on the runtime stack, the position of the + // PushLit that produced it (or -1 if non-constant). When an arithmetic op + // sees only constants on its stack-top, we precompute the result and + // splice the producing instructions away, replacing them with one PushLit. + if (mCode.empty()) return; + // Track each stack slot's producer: -1 = non-constant, else index in mCode of the PushLit. + std::vector producer; + producer.reserve(32); + // Helper: evaluate a 1-arg op on a constant value. + auto eval1 = [](Op op, float a) -> float { + switch (op) { + case Op::Neg: return -a; + case Op::Abs: return std::fabs(a); + case Op::Inv: return 1.0f / a; + case Op::Sqrt: return std::sqrt(a); + case Op::Sin: return std::sin(a); + case Op::Cos: return std::cos(a); + case Op::Tan: return std::tan(a); + case Op::Asin: return std::asin(a); + case Op::Acos: return std::acos(a); + case Op::Atan: return std::atan(a); + case Op::Sinh: return std::sinh(a); + case Op::Cosh: return std::cosh(a); + case Op::Tanh: return std::tanh(a); + case Op::Asinh: return std::asinh(a); + case Op::Acosh: return std::acosh(a); + case Op::Atanh: return std::atanh(a); + case Op::Exp: return std::exp(a); + case Op::Ln: return std::log(a); + case Op::Log: return std::log10(a); + case Op::Floor: return std::floor(a); + case Op::Ceil: return std::ceil(a); + case Op::Pow2: return a * a; + case Op::Pow3: return a * a * a; + case Op::Sign: return (a > 0.0f) ? 1.0f : ((a < 0.0f) ? -1.0f : 0.0f); + case Op::Round: return std::round(a); + case Op::Trunc: return std::trunc(a); + case Op::Not: return (a != 0.0f) ? 0.0f : 1.0f; + default: return std::numeric_limits::quiet_NaN();// not a foldable unary + } + }; + auto eval2 = [](Op op, float a, float b) -> float { + switch (op) { + case Op::Add: return a + b; + case Op::Sub: return a - b; + case Op::Mul: return a * b; + case Op::Div: return a / b; + case Op::Mod: return std::fmod(a, b); + case Op::Pow: return std::pow(a, b); + case Op::Min: return std::fmin(a, b); + case Op::Max: return std::fmax(a, b); + case Op::Atan2: return std::atan2(a, b); + case Op::Hypot: return std::hypot(a, b); + case Op::Step: return (b < a) ? 0.0f : 1.0f; + case Op::Lt: return (a < b) ? 1.0f : 0.0f; + case Op::Gt: return (a > b) ? 1.0f : 0.0f; + case Op::Le: return (a <= b) ? 1.0f : 0.0f; + case Op::Ge: return (a >= b) ? 1.0f : 0.0f; + case Op::Eq: return (a == b) ? 1.0f : 0.0f; + case Op::Ne: return (a != b) ? 1.0f : 0.0f; + case Op::And: return ((a != 0.0f) && (b != 0.0f)) ? 1.0f : 0.0f; + case Op::Or: return ((a != 0.0f) || (b != 0.0f)) ? 1.0f : 0.0f; + default: return std::numeric_limits::quiet_NaN(); + } + }; + + auto isFoldableUnary = [](Op op) { + switch (op) { + case Op::Neg: case Op::Abs: case Op::Inv: case Op::Sqrt: + case Op::Sin: case Op::Cos: case Op::Tan: + case Op::Asin: case Op::Acos: case Op::Atan: + case Op::Sinh: case Op::Cosh: case Op::Tanh: + case Op::Asinh: case Op::Acosh: case Op::Atanh: + case Op::Exp: case Op::Ln: case Op::Log: + case Op::Floor: case Op::Ceil: case Op::Pow2: case Op::Pow3: + case Op::Sign: case Op::Round: case Op::Trunc: case Op::Not: + return true; + default: return false; + } + }; + auto isFoldableBinary = [](Op op) { + switch (op) { + case Op::Add: case Op::Sub: case Op::Mul: case Op::Div: + case Op::Mod: case Op::Pow: + case Op::Min: case Op::Max: case Op::Atan2: case Op::Hypot: case Op::Step: + case Op::Lt: case Op::Gt: case Op::Le: case Op::Ge: + case Op::Eq: case Op::Ne: case Op::And: case Op::Or: + return true; + default: return false; + } + }; + + // Rebuild mCode by emitting either the original instruction, or a folded + // PushLit when the top of the constant-stack admits it. We track producer + // indices into the OUTPUT code (newCode) so we can rewrite tail entries. + std::vector newCode; + newCode.reserve(mCode.size()); + + for (size_t k = 0; k < mCode.size(); ++k) { + const Instr in = mCode[k]; + if (in.op == Op::PushLit) { + newCode.push_back(in); + producer.push_back(static_cast(newCode.size()) - 1); + continue; + } + if (in.op == Op::PushVar || in.op == Op::PushSlot) { + newCode.push_back(in); + producer.push_back(-1); + continue; + } + if (in.op == Op::Store) { + // Pops one. Don't fold across stores; just emit. + newCode.push_back(in); + if (!producer.empty()) producer.pop_back(); + continue; + } + if (isFoldableUnary(in.op)) { + if (!producer.empty() && producer.back() >= 0) { + // Fold: pop the trailing PushLit, push a new PushLit with eval1. + const int litIdx = producer.back(); + const float a = mConstants[newCode[litIdx].arg]; + const float v = eval1(in.op, a); + // Replace newCode[litIdx] with a PushLit pointing at a new constant + // (we append to mConstants; the old constant becomes dead but + // harmless). Drop the unary op (don't append). + const uint16_t cidx = static_cast(mConstants.size()); + mConstants.push_back(v); + newCode[litIdx] = {Op::PushLit, cidx}; + // producer stack unchanged (still 1 constant on top). + continue; + } + newCode.push_back(in); + // producer top stays the same (1-arg ops don't change stack size). + // But the result is non-constant now. + if (!producer.empty()) producer.back() = -1; + continue; + } + if (isFoldableBinary(in.op)) { + const size_t N = producer.size(); + if (N >= 2 && producer[N-1] >= 0 && producer[N-2] >= 0) { + const int litA = producer[N-2]; + const int litB = producer[N-1]; + const float a = mConstants[newCode[litA].arg]; + const float b = mConstants[newCode[litB].arg]; + const float v = eval2(in.op, a, b); + // Replace litA with the folded literal; erase litB. Don't + // append the binary op. + const uint16_t cidx = static_cast(mConstants.size()); + mConstants.push_back(v); + newCode[litA] = {Op::PushLit, cidx}; + newCode.erase(newCode.begin() + litB); + producer.pop_back(); + producer.back() = litA;// the folded literal's index is still litA in newCode + continue; + } + newCode.push_back(in); + if (producer.size() >= 1) producer.pop_back(); + if (!producer.empty()) producer.back() = -1; + continue; + } + // 3-arg, Switch, IfThenElse, etc: don't fold (safe but more work). + // Update producer stack to reflect the net pop/push. + newCode.push_back(in); + switch (in.op) { + case Op::Clamp: case Op::Lerp: case Op::Smoothstep: case Op::IfThenElse: + if (producer.size() >= 3) { producer.pop_back(); producer.pop_back(); } + if (!producer.empty()) producer.back() = -1; + break; + case Op::Switch: { + const int n = static_cast(in.arg); + const int pops = 2 * n + 2; + for (int j = 0; j < pops - 1 && !producer.empty(); ++j) producer.pop_back(); + if (!producer.empty()) producer.back() = -1; + break; + } + default: + break; + } + } + mCode = std::move(newCode); +} + +inline void Calculator::verify() +{ + // Walk the bytecode with a virtual stack-depth counter so eval() doesn't + // have to check anything at runtime. Any well-formed expression must leave + // exactly one value on the stack; anything else is a compile-time error. + // Use index-based traversal (matching eval()) so we can correctly follow + // forward Jumps emitted by lazy if()/switch(). Strategy: + // - Unconditional Jump: follow it (ip = arg) — the not-taken branch + // converges at the target by construction. + // - JumpIfFalse: pop the condition, fall through (verifies the "then" + // path). The "else" path is trusted; the parser emits it with the + // same compile pass as "then" so structural arity is guaranteed. + int depth = 0; + constexpr int kStackMax = 32; + const size_t codeSize = mCode.size(); + for (size_t ip = 0; ip < codeSize; ) { + const Instr &i = mCode[ip]; + switch (i.op) { + case Op::Jump: + // arg == codeSize is valid: "jump to end" (loop terminates). + if (i.arg > codeSize) throw std::invalid_argument("Calculator: jump target out of range"); + ip = i.arg; + continue; + case Op::JumpIfFalse: + if (depth < 1) throw std::invalid_argument("Calculator: stack underflow on conditional jump"); + if (i.arg > codeSize) throw std::invalid_argument("Calculator: conditional jump target out of range"); + --depth; + break; + case Op::PushLit: case Op::PushVar: case Op::PushSlot: + ++depth; + break; + case Op::Store: + if (depth < 1) throw std::invalid_argument("Calculator: stack underflow on store"); + --depth; + break; + case Op::Neg: case Op::Abs: case Op::Inv: case Op::Sqrt: + case Op::Sin: case Op::Cos: case Op::Tan: + case Op::Asin: case Op::Acos: case Op::Atan: + case Op::Sinh: case Op::Cosh: case Op::Tanh: + case Op::Asinh: case Op::Acosh: case Op::Atanh: + case Op::Exp: case Op::Ln: case Op::Log: + case Op::Floor: case Op::Ceil: + case Op::Pow2: case Op::Pow3: + case Op::Sign: case Op::Round: case Op::Trunc: + case Op::Not: + if (depth < 1) throw std::invalid_argument("Calculator: stack underflow on unary op"); + break; + case Op::Add: case Op::Sub: case Op::Mul: case Op::Div: + case Op::Mod: case Op::Pow: + case Op::Min: case Op::Max: case Op::Atan2: case Op::Hypot: case Op::Step: + case Op::Lt: case Op::Gt: case Op::Le: case Op::Ge: + case Op::Eq: case Op::Ne: + case Op::And: case Op::Or: + if (depth < 2) throw std::invalid_argument("Calculator: stack underflow on binary op"); + --depth; + break; + case Op::Clamp: case Op::Lerp: case Op::Smoothstep: case Op::IfThenElse: + if (depth < 3) throw std::invalid_argument("Calculator: stack underflow on ternary op"); + depth -= 2; + break; + case Op::Switch: { + const int n = static_cast(i.arg);// number of case pairs + const int popN = 2 * n + 2; // selector + N*(key,val) + default + if (depth < popN) + throw std::invalid_argument("Calculator: stack underflow on switch"); + depth -= (popN - 1); + break; + } + } + if (depth > kStackMax) + throw std::invalid_argument("Calculator: stack overflow (depth > 32)"); + ++ip;// Jump/JumpIfFalse handle their own advancement via `continue`. + } + if (depth != 1) + throw std::invalid_argument( + "Calculator: expression must produce exactly one value (final depth=" + + std::to_string(depth) + ")"); +} + +inline float Calculator::eval(const float* values) const +{ + return this->evalImpl(values, nullptr); +} + +inline float Calculator::evalImpl(const float* values, float* slotsOut) const +{ + // 32 stack slots is far more than any reasonable expression needs; verify() + // enforces the bound at compile time so this loop performs zero checks. + // Callers must ensure @a values addresses at least mVariables.size() + // floats; if mVariables is empty, no PushVar opcode references it and + // a null pointer is safe. The slot buffer is sized to kSlotsMax (also + // enforced at compile time by allocateSlot()), so PushSlot/Store never + // run off the end. + constexpr int kStackMax = 32; + float s[kStackMax]; + float slots[kSlotsMax]; + int sp = 0; + const size_t codeSize = mCode.size(); + for (size_t ip = 0; ip < codeSize; ) { + const Instr &i = mCode[ip]; + switch (i.op) { + case Op::Jump: ip = i.arg; continue; + case Op::JumpIfFalse: if (s[--sp] == 0.0f) { ip = i.arg; continue; } break; + case Op::PushLit: s[sp++] = mConstants[i.arg]; break; + case Op::PushVar: s[sp++] = values[i.arg]; break; + case Op::PushSlot: s[sp++] = slots[i.arg]; break; + case Op::Store: --sp; slots[i.arg] = s[sp]; break; + case Op::Add: --sp; s[sp-1] += s[sp]; break; + case Op::Sub: --sp; s[sp-1] -= s[sp]; break; + case Op::Mul: --sp; s[sp-1] *= s[sp]; break; + case Op::Div: --sp; s[sp-1] /= s[sp]; break; + case Op::Mod: --sp; s[sp-1] = std::fmod(s[sp-1], s[sp]); break; + case Op::Pow: --sp; s[sp-1] = std::pow(s[sp-1], s[sp]); break; + case Op::Neg: s[sp-1] = -s[sp-1]; break; + case Op::Abs: s[sp-1] = std::fabs(s[sp-1]); break; + case Op::Inv: s[sp-1] = 1.0f / s[sp-1]; break; + case Op::Sqrt: s[sp-1] = std::sqrt(s[sp-1]); break; + case Op::Sin: s[sp-1] = std::sin(s[sp-1]); break; + case Op::Cos: s[sp-1] = std::cos(s[sp-1]); break; + case Op::Tan: s[sp-1] = std::tan(s[sp-1]); break; + case Op::Asin: s[sp-1] = std::asin(s[sp-1]); break; + case Op::Acos: s[sp-1] = std::acos(s[sp-1]); break; + case Op::Atan: s[sp-1] = std::atan(s[sp-1]); break; + case Op::Sinh: s[sp-1] = std::sinh(s[sp-1]); break; + case Op::Cosh: s[sp-1] = std::cosh(s[sp-1]); break; + case Op::Tanh: s[sp-1] = std::tanh(s[sp-1]); break; + case Op::Asinh: s[sp-1] = std::asinh(s[sp-1]); break; + case Op::Acosh: s[sp-1] = std::acosh(s[sp-1]); break; + case Op::Atanh: s[sp-1] = std::atanh(s[sp-1]); break; + case Op::Exp: s[sp-1] = std::exp(s[sp-1]); break; + case Op::Ln: s[sp-1] = std::log(s[sp-1]); break; + case Op::Log: s[sp-1] = std::log10(s[sp-1]); break; + case Op::Floor: s[sp-1] = std::floor(s[sp-1]); break; + case Op::Ceil: s[sp-1] = std::ceil(s[sp-1]); break; + case Op::Pow2: s[sp-1] *= s[sp-1]; break; + case Op::Pow3: { float v = s[sp-1]; s[sp-1] = v*v*v; }break; + case Op::Sign: { float v = s[sp-1]; s[sp-1] = (v > 0.0f) ? 1.0f : ((v < 0.0f) ? -1.0f : 0.0f); } break; + case Op::Round: s[sp-1] = std::round(s[sp-1]); break; + case Op::Trunc: s[sp-1] = std::trunc(s[sp-1]); break; + case Op::Not: s[sp-1] = (s[sp-1] != 0.0f) ? 0.0f : 1.0f; break; + case Op::Min: --sp; s[sp-1] = std::fmin(s[sp-1], s[sp]); break; + case Op::Max: --sp; s[sp-1] = std::fmax(s[sp-1], s[sp]); break; + case Op::Atan2: --sp; s[sp-1] = std::atan2(s[sp-1], s[sp]); break; + case Op::Hypot: --sp; s[sp-1] = std::hypot(s[sp-1], s[sp]); break; + case Op::Step: --sp; s[sp-1] = (s[sp] < s[sp-1]) ? 0.0f : 1.0f; break;// step(edge, x): x < edge ? 0 : 1 + case Op::Lt: --sp; s[sp-1] = (s[sp-1] < s[sp]) ? 1.0f : 0.0f; break; + case Op::Gt: --sp; s[sp-1] = (s[sp-1] > s[sp]) ? 1.0f : 0.0f; break; + case Op::Le: --sp; s[sp-1] = (s[sp-1] <= s[sp]) ? 1.0f : 0.0f; break; + case Op::Ge: --sp; s[sp-1] = (s[sp-1] >= s[sp]) ? 1.0f : 0.0f; break; + case Op::Eq: --sp; s[sp-1] = (s[sp-1] == s[sp]) ? 1.0f : 0.0f; break; + case Op::Ne: --sp; s[sp-1] = (s[sp-1] != s[sp]) ? 1.0f : 0.0f; break; + case Op::And: --sp; s[sp-1] = ((s[sp-1] != 0.0f) && (s[sp] != 0.0f)) ? 1.0f : 0.0f; break; + case Op::Or: --sp; s[sp-1] = ((s[sp-1] != 0.0f) || (s[sp] != 0.0f)) ? 1.0f : 0.0f; break; + // 3-arg: stack has [a, b, c] with c on top; pops 3, pushes 1. + case Op::Clamp: {// clamp(x, lo, hi) + const float hi = s[--sp]; const float lo = s[--sp]; float &x = s[sp-1]; + x = (x < lo) ? lo : ((x > hi) ? hi : x); + } break; + case Op::Lerp: {// lerp(a, b, t) -> a*(1-t) + b*t + const float t = s[--sp]; const float b = s[--sp]; float &a = s[sp-1]; + a = a + (b - a) * t; + } break; + case Op::Smoothstep: {// smoothstep(e0, e1, x) + const float x = s[--sp]; const float e1 = s[--sp]; float &e0 = s[sp-1]; + float u = (x - e0) / (e1 - e0); + u = (u < 0.0f) ? 0.0f : ((u > 1.0f) ? 1.0f : u); + e0 = u * u * (3.0f - 2.0f * u); + } break; + case Op::IfThenElse: {// if(c, t, e) -> c != 0 ? t : e + const float e = s[--sp]; const float t = s[--sp]; float &c = s[sp-1]; + c = (c != 0.0f) ? t : e; + } break; + case Op::Switch: {// switch(selector, k1, v1, ..., kN, vN, default) + // Stack layout (bottom-up): selector, k1, v1, ..., kN, vN, default. + // All branches are eagerly evaluated; we just pick one. + const int n = static_cast(i.arg); + const int total = 2 * n + 2; + const int base = sp - total; + const float selector = s[base]; + const float defaultVal = s[sp - 1]; + float result = defaultVal; + for (int k = 0; k < n; ++k) { + if (s[base + 1 + 2*k] == selector) { + result = s[base + 2 + 2*k]; + break; + } + } + sp = base + 1; + s[base] = result; + } break; + } + ++ip;// fall-through path advances; explicit Jump/JumpIfFalse used `continue` + } + if (slotsOut != nullptr) { + for (size_t i = 0; i < mSlotNames.size(); ++i) slotsOut[i] = slots[i]; + } + return s[0]; +} + +inline float Calculator::eval(std::initializer_list values) const +{ + if (values.size() != mVariables.size()) { + throw std::invalid_argument( + "Calculator::eval: expected " + std::to_string(mVariables.size()) + + " value(s), got " + std::to_string(values.size())); + } + return this->eval(values.begin()); +} + +inline int Calculator::variableIndex(const std::string &name) const +{ + for (size_t i = 0; i < mVariables.size(); ++i) { + if (mVariables[i] == name) return static_cast(i); + } + return -1; +} + +inline float Calculator::eval(const std::unordered_map &bindings) const +{ + // Realistic expressions have a handful of variables, so a small stack + // buffer suffices and avoids a heap allocation on every call. + constexpr size_t kSmall = 16; + float small[kSmall]; + std::vector heap; + float *values = small; + if (mVariables.size() > kSmall) { + heap.resize(mVariables.size()); + values = heap.data(); + } + for (size_t i = 0; i < mVariables.size(); ++i) { + const auto it = bindings.find(mVariables[i]); + if (it == bindings.end()) { + throw std::invalid_argument( + "Calculator::eval: missing binding for variable \"" + mVariables[i] + "\""); + } + values[i] = it->second; + } + return this->eval(values); +} + +inline float Calculator::eval(float x) const +{ + if (mVariables.empty()) return this->eval(&x);// @a x is ignored, but provides a valid address + if (mVariables.size() == 1 && mVariables[0] == "x") return this->eval(&x); + // Pick the first offending name for a useful error message. + for (const std::string &v : mVariables) { + if (v != "x") { + throw std::invalid_argument( + "Calculator::eval(x): expression references undefined variable \"" + v + "\""); + } + } + throw std::invalid_argument( + "Calculator::eval(x): expression references " + std::to_string(mVariables.size()) + + " variables, but only \"x\" is bindable"); +} + +inline float Calculator::eval() const +{ + if (!mVariables.empty()) { + throw std::invalid_argument( + "Calculator::eval(): expression references " + std::to_string(mVariables.size()) + + " variable(s) (first: \"" + mVariables[0] + "\"); use one of the bound-arg " + "overloads (eval(x), eval(values), eval(initializer_list), or eval(map))"); + } + // No variables → no PushVar opcodes → evalImpl never dereferences values. + // Pass a non-null dummy to be safe. + float dummy = 0.0f; + return this->eval(&dummy); +} + +inline void Calculator::eval_n(const float* in, float* out, size_t n, + const std::string &varName) const +{ + // Validate the binding shape once, outside the hot loop. Three accepted + // shapes: no variables (broadcast a constant), or exactly one variable + // matching @a varName, or — for backward compat — no variables and a + // null @a in pointer is allowed (caller can pass nullptr). + if (mVariables.empty()) { + // Broadcast: every output is the same constant value. + float dummy = 0.0f; + const float v = this->eval(&dummy); + for (size_t i = 0; i < n; ++i) out[i] = v; + return; + } + if (mVariables.size() != 1 || mVariables[0] != varName) { + throw std::invalid_argument( + "Calculator::eval_n: expression must reference only the variable \"" + + varName + "\" (got " + std::to_string(mVariables.size()) + " variable(s)" + + (mVariables.empty() ? "" : ", first: \"" + mVariables[0] + "\"") + ")"); + } + // Hot loop. Pass each input through evalImpl directly with a 1-slot buffer. + for (size_t i = 0; i < n; ++i) { + out[i] = this->eval(&in[i]); + } +} + +// --------------------------------------------------------------------- +// Persistent-memory variant +// --------------------------------------------------------------------- + +inline float Calculator::evalAndRemember(const float* values) +{ + float slots[kSlotsMax]; + const float result = this->evalImpl(values, slots); + // Snapshot: inputs + slots + named result. mMemory is rebuilt each call + // so stale entries from a previous run don't linger across compile() + // boundaries. + mMemory.clear(); + for (size_t i = 0; i < mVariables.size(); ++i) mMemory[mVariables[i]] = values[i]; + for (size_t i = 0; i < mSlotNames.size(); ++i) mMemory[mSlotNames[i]] = slots[i]; + if (!mResultName.empty()) mMemory[mResultName] = result; + return result; +} + +inline float Calculator::evalAndRemember(const std::unordered_map &bindings) +{ + // Build a positional values buffer; small expressions get a stack array, + // larger ones spill to the heap (same pattern as the const eval(map) overload). + constexpr size_t kSmall = 16; + float small[kSmall]; + std::vector heap; + float *values = small; + if (mVariables.size() > kSmall) { + heap.resize(mVariables.size()); + values = heap.data(); + } + for (size_t i = 0; i < mVariables.size(); ++i) { + const auto it = bindings.find(mVariables[i]); + if (it == bindings.end()) { + throw std::invalid_argument( + "Calculator::evalAndRemember: missing binding for variable \"" + + mVariables[i] + "\""); + } + values[i] = it->second; + } + return this->evalAndRemember(values); +} + +inline float Calculator::get(const std::string &name) const +{ + const auto it = mMemory.find(name); + if (it == mMemory.end()) { + throw std::invalid_argument( + "Calculator::get: no memory entry for \"" + name + + "\" (call evalAndRemember() first, or the name is neither an " + "input, a slot, nor the trailing result)"); + } + return it->second; +} + +inline std::string Calculator::disassemble() const +{ + auto opName = [](Op op) -> const char* { + switch (op) { + case Op::PushLit: return "PushLit"; + case Op::PushVar: return "PushVar"; + case Op::PushSlot: return "PushSlot"; + case Op::Store: return "Store"; + case Op::Add: return "Add"; + case Op::Sub: return "Sub"; + case Op::Mul: return "Mul"; + case Op::Div: return "Div"; + case Op::Mod: return "Mod"; + case Op::Pow: return "Pow"; + case Op::Neg: return "Neg"; + case Op::Abs: return "Abs"; + case Op::Inv: return "Inv"; + case Op::Sqrt: return "Sqrt"; + case Op::Sin: return "Sin"; + case Op::Cos: return "Cos"; + case Op::Tan: return "Tan"; + case Op::Asin: return "Asin"; + case Op::Acos: return "Acos"; + case Op::Atan: return "Atan"; + case Op::Sinh: return "Sinh"; + case Op::Cosh: return "Cosh"; + case Op::Tanh: return "Tanh"; + case Op::Asinh: return "Asinh"; + case Op::Acosh: return "Acosh"; + case Op::Atanh: return "Atanh"; + case Op::Exp: return "Exp"; + case Op::Ln: return "Ln"; + case Op::Log: return "Log"; + case Op::Floor: return "Floor"; + case Op::Ceil: return "Ceil"; + case Op::Pow2: return "Pow2"; + case Op::Pow3: return "Pow3"; + case Op::Sign: return "Sign"; + case Op::Round: return "Round"; + case Op::Trunc: return "Trunc"; + case Op::Not: return "Not"; + case Op::Min: return "Min"; + case Op::Max: return "Max"; + case Op::Atan2: return "Atan2"; + case Op::Hypot: return "Hypot"; + case Op::Step: return "Step"; + case Op::Lt: return "Lt"; + case Op::Gt: return "Gt"; + case Op::Le: return "Le"; + case Op::Ge: return "Ge"; + case Op::Eq: return "Eq"; + case Op::Ne: return "Ne"; + case Op::And: return "And"; + case Op::Or: return "Or"; + case Op::Clamp: return "Clamp"; + case Op::Lerp: return "Lerp"; + case Op::Smoothstep: return "Smoothstep"; + case Op::IfThenElse: return "IfThenElse"; + case Op::Switch: return "Switch"; + case Op::Jump: return "Jump"; + case Op::JumpIfFalse: return "JumpIfFalse"; + } + return "???"; + }; + std::ostringstream out; + for (size_t i = 0; i < mCode.size(); ++i) { + const Instr &in = mCode[i]; + out.width(4); out << i << " " << opName(in.op); + switch (in.op) { + case Op::PushLit: + out << " " << mConstants[in.arg] << " (#const " << in.arg << ")"; + break; + case Op::PushVar: + if (in.arg < mVariables.size()) out << " " << mVariables[in.arg] << " (#var " << in.arg << ")"; + break; + case Op::PushSlot: case Op::Store: + if (in.arg < mSlotNames.size()) out << " " << mSlotNames[in.arg] << " (#slot " << in.arg << ")"; + break; + case Op::Switch: + out << " cases=" << in.arg; + break; + case Op::Jump: case Op::JumpIfFalse: + out << " -> " << in.arg; + break; + default: + break; + } + out << "\n"; + } + if (!mVariables.empty()) { + out << "Variables: "; + for (size_t i = 0; i < mVariables.size(); ++i) + out << (i ? ", " : "") << mVariables[i]; + out << "\n"; + } + if (!mSlotNames.empty()) { + out << "Slots: "; + for (size_t i = 0; i < mSlotNames.size(); ++i) + out << (i ? ", " : "") << mSlotNames[i]; + out << "\n"; + } + if (!mResultName.empty()) out << "Result name: " << mResultName << "\n"; + return out.str(); +} + +} // namespace vdb_tool +} // namespace OPENVDB_VERSION_NAME +} // namespace openvdb + +#endif // VDB_TOOL_CALCULATOR_HAS_BEEN_INCLUDED diff --git a/openvdb_cmd/vdb_tool/include/Geometry.h b/openvdb_cmd/vdb_tool/include/Geometry.h index ebd497b3bb..57e92479f9 100644 --- a/openvdb_cmd/vdb_tool/include/Geometry.h +++ b/openvdb_cmd/vdb_tool/include/Geometry.h @@ -7,8 +7,16 @@ /// /// @file Geometry.h /// -/// @brief Class that encapsulates (explicit) geometry, i.e. vertices/points, -/// triangles and quads. It is used to represent points and polygon meshes +/// @brief Polygon-mesh / point-cloud container used by vdb_tool. Holds vertex +/// positions plus optional per-vertex RGB colors and any combination +/// of triangle / quad face lists, with a lazily-cached world-space +/// bounding box. Provides stream- and file-based readers and writers +/// for the OBJ, PLY, STL, OFF, GEO, PTS, and XYZ formats (binary and +/// ASCII where applicable), and optional read/write paths for +/// Alembic (.abc), OpenUSD (.usd / .usda / .usdc / .usdz), glTF +/// (.gltf / .glb), OpenVDB (.vdb) and NanoVDB (.nvdb) backends. Also +/// exposes a static fan-triangulate helper used by the readers to +/// convert convex N-gons into triangles. /// //////////////////////////////////////////////////////////////////////////////// @@ -45,6 +53,30 @@ #include #endif +#ifdef VDB_TOOL_USE_USD +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +#ifdef VDB_TOOL_USE_GLTF +// Pull in tinygltf in truly-header-only mode: TINYGLTF_HEADER_ONLY makes every +// function inline so this header can be included from multiple TUs without +// linker duplication. No images are loaded by vdb_tool, so disable the +// bundled stb_image / stb_image_write paths. +#define TINYGLTF_IMPLEMENTATION +#define TINYGLTF_HEADER_ONLY +#define TINYGLTF_NO_STB_IMAGE +#define TINYGLTF_NO_STB_IMAGE_WRITE +#include +#endif + #ifdef VDB_TOOL_USE_PDAL #include "pdal/pdal.hpp" #include "pdal/PipelineManager.hpp" @@ -66,113 +98,300 @@ OPENVDB_USE_VERSION_NAMESPACE namespace OPENVDB_VERSION_NAME { namespace vdb_tool { -#define MY_CLEAN_VERSION - /// @brief Class that encapsulates (explicit) geometry, i.e. vertices/points, -/// triangles and quads. It is used to represent points and polygon meshes +/// triangles and quads. It is used to represent points and polygon meshes. +/// @details A Geometry instance owns four parallel containers: vertex positions, +/// triangle indices, quad indices, and optional per-vertex RGB colors. +/// It is non-copyable and non-movable; use deepCopy() to duplicate an +/// instance explicitly. Geometry supports reading and writing several +/// common mesh formats (OBJ, PLY, STL, OFF, ABC) as well as a compact +/// native binary format (.geo) defined by the embedded Header struct. class Geometry { public: - using PosT = Vec3f; - using BBoxT = math::BBox; - using Ptr = std::shared_ptr; - struct Header; + using PosT = Vec3f; ///< Vertex position type (single-precision Vec3f). + using BBoxT = math::BBox; ///< Axis-aligned bounding box type over PosT. + using Ptr = std::shared_ptr; ///< Shared-pointer alias for heap-allocated Geometry. + struct Header; ///< Forward declaration of the native .geo file header. + /// @brief Default constructor: produces an empty Geometry with an invalid bbox. Geometry() = default; + + /// @brief Default destructor. ~Geometry() = default; - Geometry(const Geometry&) = delete;// disallow copy construction - Geometry(Geometry&&) = delete;// disallow move construction - Geometry& operator=(const Geometry&) = delete;// disallow assignment - Geometry& operator=(Geometry&&) = delete;// disallow move assignment + Geometry(const Geometry&) = delete; ///< Copy construction is disabled; use deepCopy(). + Geometry(Geometry&&) = delete; ///< Move construction is disabled. + Geometry& operator=(const Geometry&) = delete; ///< Copy assignment is disabled. + Geometry& operator=(Geometry&&) = delete; ///< Move assignment is disabled. - inline Ptr copyGeom() const; + /// @brief Explicitly produce a deep copy of this Geometry. + /// @return Shared pointer to a newly allocated Geometry containing duplicated + /// vertices, triangles, quads, colors, name, and bbox. + inline Ptr deepCopy() const; + /// @brief Read-only access to the vertex array. const std::vector& vtx() const { return mVtx; } + /// @brief Read-only access to the triangle index array. const std::vector& tri() const { return mTri; } + /// @brief Read-only access to the quad index array. const std::vector& quad() const { return mQuad; } + /// @brief Read-only access to the optional per-vertex RGB color array. const std::vector& rgb() const { return mRGB; } + /// @brief Mutable access to the vertex array. + /// @warning Modifying vertices invalidates the cached bbox; call clear() + /// or otherwise reset mBBox if downstream code relies on it. std::vector& vtx() { return mVtx; } + /// @brief Mutable access to the triangle index array. std::vector& tri() { return mTri; } + /// @brief Mutable access to the quad index array. std::vector& quad() { return mQuad; } + /// @brief Mutable access to the per-vertex RGB color array. std::vector& rgb() { return mRGB; } + /// @brief Returns the axis-aligned bounding box of the vertices. + /// @return Reference to the cached bbox. The bbox is computed lazily on the + /// first call (in parallel via TBB) and cached until clear() is invoked. const BBoxT& bbox() const; + /// @brief Returns the maximum extent (longest side) of the vertex bbox. + float maxLength() const; + + /// @brief Erases all vertices, triangles, quads, name, and invalidates the cached bbox. void clear(); - // Reads all the vertices in the file and treats them as Geometry - void write(const std::string &fileName) const; + /// @brief Write the geometry to file, dispatching on the filename extension. + /// @param fileName Output file path; extension selects the format (geo, obj, ply, stl, abc, off). + /// @param ascii If true and the format supports it (PLY), write in ASCII rather than binary. + /// @throw std::invalid_argument if the extension is not recognized. + void write(const std::string &fileName, bool ascii = false) const; + /// @brief Write the mesh as a Wavefront OBJ file. + /// @throw std::invalid_argument if the file cannot be opened for writing. void writeOBJ(const std::string &fileName) const; + /// @brief Write the mesh as an OFF (Object File Format) file. + /// @throw std::invalid_argument if the file cannot be opened for writing. void writeOFF(const std::string &fileName) const; - void writePLY(const std::string &fileName, bool binary = true) const; + /// @brief Write the mesh as a PLY file (binary by default, ASCII if @a ascii is true). + /// @throw std::invalid_argument if the file cannot be opened or if binary buffer allocation fails. + void writePLY(const std::string &fileName, bool ascii = false) const; + /// @brief Write the triangulated mesh as a binary STL file. + /// @throw std::invalid_argument if the file cannot be opened, the host is big-endian, + /// or the mesh contains quads (call triangulateQuads() first). void writeSTL(const std::string &fileName) const; + /// @brief Write the geometry in the native compact binary .geo format. + /// @throw std::invalid_argument if the file cannot be opened for writing. void writeGEO(const std::string &fileName) const; + /// @brief Write the mesh as an Alembic (.abc) file (requires VDB_TOOL_USE_ABC). + /// @throw std::runtime_error if Alembic support was not enabled at compile time. void writeABC(const std::string &fileName) const; - void writeOBJ(std::ostream &os) const; - void writeOFF(std::ostream &os) const; - void writePLY(std::ostream &os, bool binary = true) const; - void writeSTL(std::ostream &os) const; - - void read(const std::string &fileName); + /// @brief Stream version of writeGEO; serializes this Geometry to @a os. + /// @return Number of bytes written (matches Header::size()). + size_t writeGEO(std::ostream &os) const; + /// @brief Deprecated alias for writeGEO(std::ostream&). + OPENVDB_DEPRECATED size_t write(std::ostream &os) {return this->writeGEO(os);} + /// @brief Stream version of writeOBJ. + void writeOBJ(std::ostream &os) const; + /// @brief Stream version of writeOFF. + void writeOFF(std::ostream &os) const; + /// @brief Stream version of writePLY (binary or ASCII based on @a ascii). + /// @throw std::invalid_argument if binary buffer allocation fails. + void writePLY(std::ostream &os, bool ascii = false) const; + /// @brief Stream version of writeSTL. + /// @throw std::invalid_argument if the host is big-endian or the mesh contains quads. + void writeSTL(std::ostream &os) const; + + /// @brief Read geometry from file, dispatching on the filename extension. + /// @param fileName Input file path; extension selects the parser. + /// @param verbose Verbosity level for diagnostic output (0 = quiet). + /// @throw std::invalid_argument on unrecognized extension or I/O failure. + void read(const std::string &fileName, int verbose = 0); + /// @brief Read a Wavefront OBJ file. + /// @throw std::invalid_argument if the file cannot be opened. void readOBJ(const std::string &fileName); + /// @brief Read an OFF (Object File Format) file. + /// @throw std::invalid_argument if the file cannot be opened, the "OFF" header is missing, + /// or a face has more vertices than the supported maximum (n-gons beyond quads). void readOFF(const std::string &fileName); + /// @brief Read a PLY file (binary or ASCII auto-detected from the header). + /// @throw std::invalid_argument if the file cannot be opened, the header is malformed, + /// binary buffer allocation fails, or polygons exceed the supported maximum. void readPLY(const std::string &fileName); + /// @brief Read a binary or ASCII STL file (format auto-detected). + /// @throw std::runtime_error if the file cannot be opened or is unexpectedly empty. + /// @throw std::invalid_argument if the binary file is malformed, host is big-endian, + /// or the ASCII file contains unsupported n-gons. void readSTL(const std::string &fileName); + /// @brief Read a PTS point-cloud file (one or more clouds, ASCII). + /// @throw std::runtime_error if the file cannot be opened. + /// @throw std::invalid_argument on a malformed coordinate line. void readPTS(const std::string &fileName); + /// @brief Read a native .geo file (compact binary format). + /// @throw std::invalid_argument if the file cannot be opened. void readGEO(const std::string &fileName); + /// @brief Read an Alembic (.abc) file (requires VDB_TOOL_USE_ABC). + /// @throw std::runtime_error if Alembic support was not enabled at compile time + /// or if a polygon with more than 4 vertices is encountered. void readABC(const std::string &fileName); - void readPDAL(const std::string &fileName); + /// @brief Read a USD geometry file (.usd, .usda, .usdc, or .usdz). Traverses every + /// UsdGeomMesh and UsdGeomPoints prim and accumulates their points and faces + /// into this Geometry, baking each prim's world transform into the vertex + /// positions. + /// @throw std::runtime_error if USD support was not enabled at compile time. + /// @throw std::invalid_argument if the file cannot be opened as a USD stage. + /// @note Requires VDB_TOOL_USE_USD. Triangles and quads are preserved as-is; + /// n-gons (n>4) are fan-triangulated. UsdGeomPoints prims contribute only + /// vertex positions (no face data). Instancing, subdivision, animation, + /// and per-point widths are not supported by this minimal reader. + void readUSD(const std::string &fileName); + /// @brief Read a point cloud via PDAL (e.g. LAS/LAZ/E57). + /// @return true on success, false if PDAL could not parse the file. + /// @note Requires VDB_TOOL_USE_PDAL. + /// @throw std::runtime_error if PDAL support was not enabled at compile time + /// or if the underlying PDAL pipeline fails. + bool readPDAL(const std::string &fileName); + /// @brief Read a glTF / glb file. Walks every mesh primitive and appends + /// POSITION + index data to this Geometry. Non-indexed primitives + /// and primitives whose index buffers use UBYTE/USHORT/UINT are all + /// supported. Only TRIANGLES mode is consumed; other primitive + /// modes (POINTS, LINES, STRIP, FAN) are skipped with a warning. + /// @throw std::runtime_error if glTF support was not enabled at compile time. + /// @throw std::invalid_argument if the file cannot be parsed. + /// @note Requires VDB_TOOL_USE_GLTF. The node-graph transform stack is + /// currently NOT applied — meshes are loaded in their local space. + /// Materials, normals, UVs, vertex colors, animation, and skinning + /// are also ignored by this minimal reader. + void readGLTF(const std::string &fileName); + /// @brief Read an ASCII XYZ point file (x y z per line). + /// @throw std::invalid_argument if the file cannot be opened or a line is malformed. + void readXYZ(const std::string &fileName); + /// @brief Read points from an OpenVDB file (.vdb). void readVDB(const std::string &fileName); + /// @brief Read points from a NanoVDB file (.nvdb). Requires VDB_TOOL_USE_NANO. + /// @throw std::runtime_error if NanoVDB support was not enabled at compile time. void readNVDB(const std::string &fileName); - void readOBJ(std::istream &is); - void readOFF(std::istream &is); - void readPLY(std::istream &is); - + /// @brief Stream version of readGEO; deserializes from @a is. + /// @return Number of bytes consumed, or 0 if the magic header did not match + /// (in which case the stream is rewound to its start). + size_t readGEO(std::istream &is); + /// @brief Deprecated alias for readGEO(std::istream&). + OPENVDB_DEPRECATED size_t read(std::istream &is) {return this->readGEO(is);} + /// @brief Stream version of readOBJ. + void readOBJ(std::istream &is); + /// @brief Stream version of readOFF. + /// @throw std::invalid_argument on a malformed header or unsupported n-gons. + void readOFF(std::istream &is); + /// @brief Stream version of readPLY. + /// @throw std::invalid_argument on a malformed header, buffer-allocation failure, + /// or unsupported n-gons. + void readPLY(std::istream &is); + /// @brief Stream version of readXYZ. + /// @throw std::invalid_argument on a malformed coordinate line. + void readXYZ(std::istream &is); + + /// @brief Number of vertices in this Geometry. size_t vtxCount() const { return mVtx.size(); } + /// @brief Number of triangles. size_t triCount() const { return mTri.size(); } + /// @brief Number of quads. size_t quadCount() const { return mQuad.size(); } + /// @brief Total polygon count (triangles + quads). size_t polyCount() const { return mTri.size() + mQuad.size(); } + /// @brief Apply an affine transformation to every vertex in place. + /// @param xform OpenVDB transform whose indexToWorld mapping is applied to each vertex. + /// @note Invalidates the cached bbox. inline void transform(const math::Transform &xform); + /// @brief Triangulates each quad into two triangles, using the shortest diagonal. + /// @return Number of new triangles appended. + /// @note The quads are removed while the vertex list is unchanged. + size_t triangulateQuads(); + + /// @brief Returns true if this Geometry contains no vertices and no polygons. bool isEmpty() const { return mVtx.empty() && mTri.empty() && mQuad.empty(); } + /// @brief Returns true if this Geometry is a pure point cloud (vertices but no polygons). bool isPoints() const { return !mVtx.empty() && mTri.empty() && mQuad.empty(); } + /// @brief Returns true if this Geometry contains a polygon mesh (vertices plus tris and/or quads). bool isMesh() const { return !mVtx.empty() && (!mTri.empty() || !mQuad.empty()); } + /// @brief Returns this Geometry's user-assigned name (e.g. for stack display). const std::string getName() const { return mName; } + /// @brief Assigns a human-readable name to this Geometry. void setName(const std::string &name) { mName = name; } - void print(size_t n = 0, std::ostream& os = std::cout) const; - - size_t write(std::ostream &os) const; - size_t read(std::istream &is); + /// @brief Print a one-line summary of this Geometry to @a os. + /// @param n Optional stack index, printed alongside the summary for context. + /// @param os Output stream (defaults to std::clog). + void print(size_t n = 0, std::ostream& os = std::clog) const; + + /// @brief Static method to fan-triangulate a planar and convex N-gon. + /// @param nGon List of vertex indices for an N-gon. + /// @return Vector of triangles, as triplets of vertex indices, that make up the N-gon. + /// @warning The triangulation is naive (fan from @c nGon[0]) and assumes the input N-gon + /// is both planar and convex; non-convex polygons need an ear-clip routine instead. + static std::vector triangulate(const std::vector &nGon); + + /// @brief Append the fan-triangulation of an N-gon to an existing triangle vector. + /// @details Avoids the per-face allocation of the return-by-value overload, so it's the + /// right choice for use inside reader loops that triangulate many faces. + /// @param indices Pointer to the N-gon's vertex indices. + /// @param n Number of indices in the N-gon (n < 3 is a no-op). + /// @param out Destination vector; the N-2 triangles are appended. + /// @param indexOffset Constant added to every emitted index (e.g. -1 to convert + /// OBJ's 1-based indices to 0-based, or @c base to translate USD's + /// per-prim indices into Geometry-wide indices). + static void triangulate(const int *indices, std::size_t n, + std::vector &out, int indexOffset = 0); private: - std::vector mVtx; - std::vector mTri; - std::vector mQuad; - std::vector mRGB; - mutable BBoxT mBBox; - std::string mName; + /// @brief Use AD dot (AB cross AC) = 0 to test if all points of a quad are + /// in the same plane. + /// @param quad Quad to be tested. + /// @return true if all the points of the quad are coplanar. + bool isPlanar(const Vec4I &quad) const { + auto q = [&](int i)->const PosT&{ return mVtx[quad[i]]; }; + return math::isApproxZero((q(0)-q(3)).dot((q(0)-q(1)).cross(q(0)-q(2))), 1e-5f); + } + + std::vector mVtx; ///< Vertex positions in world space. + std::vector mTri; ///< Triangle indices (zero-based, three per triangle). + std::vector mQuad; ///< Quad indices (zero-based, four per quad). + std::vector mRGB; ///< Optional per-vertex RGB colors (not written to .geo file). + mutable BBoxT mBBox; ///< Lazily computed bbox of mVtx (not written to .geo file). + std::string mName; ///< User-assigned name (e.g. "bunny", "dragon"). + int mVerbose; ///< Verbosity flag (not written to .geo file). };// Geometry class +/// @brief Header record prepended to every native .geo file. +/// @details Begins with a magic number identifying the format and contains the +/// byte sizes of the variable-length payload that follows: name, vertex +/// array, triangle array, and quad array. The bbox is also serialized +/// immediately after the name. Used by Geometry::readGEO/writeGEO. struct Geometry::Header { - const static uint64_t sMagic = 0x7664625f67656f31UL;// "vdb_geo1" in hex - uint64_t magic, name, vtx, tri, quad; + /// @brief Magic number identifying a vdb_tool .geo file ("vdb_geo1" in ASCII). + const static uint64_t sMagic = 0x7664625f67656f31UL; + uint64_t magic; ///< Magic identifier; must equal sMagic on read. + uint64_t name; ///< Length in bytes of the geometry name that follows the header. + uint64_t vtx; ///< Number of vertices in the payload. + uint64_t tri; ///< Number of triangles in the payload. + uint64_t quad; ///< Number of quads in the payload. + + /// @brief Default constructor producing a valid magic but empty counts. Header() : magic(sMagic), name(0), vtx(0), tri(0), quad(0) {} + /// @brief Construct a header populated from the given Geometry. Header(const Geometry &g) : magic(sMagic), name(g.getName().size()), vtx(g.vtxCount()), tri(g.triCount()), quad(g.quadCount()) {} + /// @brief Total size in bytes of the header plus its payload on disk. uint64_t size() const { return sizeof(*this) + name + sizeof(BBoxT) + sizeof(PosT)*vtx + sizeof(Vec3I)*tri + sizeof(Vec4I)*quad;} };// Geometry::Header -size_t Geometry::write(std::ostream &os) const +size_t Geometry::writeGEO(std::ostream &os) const { Header header(*this);// followed by name, bbox, vtx, tri, quad os.write((const char*)&header, sizeof(Header)); @@ -184,7 +403,7 @@ size_t Geometry::write(std::ostream &os) const return header.size(); }// Geometry::write -size_t Geometry::read(std::istream &is) +size_t Geometry::readGEO(std::istream &is) { Header header; if (!is.read((char*)&header, sizeof(Header)) || header.magic != Header::sMagic) { @@ -233,7 +452,13 @@ const math::BBox& Geometry::bbox() const return mBBox; }// Geometry::bbox -void Geometry::write(const std::string &fileName) const +float Geometry::maxLength() const +{ + const math::BBox& bbox = this->bbox(); + return bbox.extents()[bbox.maxExtent()]; +} + +void Geometry::write(const std::string &fileName, bool ascii) const { switch (findFileExt(fileName, {"geo", "obj", "ply", "stl", "abc", "off"})) { case 1: @@ -243,7 +468,7 @@ void Geometry::write(const std::string &fileName) const this->writeOBJ(fileName); break; case 3: - this->writePLY(fileName); + this->writePLY(fileName, ascii); break; case 4: this->writeSTL(fileName); @@ -255,29 +480,29 @@ void Geometry::write(const std::string &fileName) const this->writeOFF(fileName); break; default: - throw std::invalid_argument("Geometry file \"" + fileName + "\" has an invalid extension"); + throw std::invalid_argument("Geometry::write: file \"" + fileName + "\" has an invalid extension"); } }// Geometry::write -void Geometry::writePLY(const std::string &fileName, bool binary) const +void Geometry::writePLY(const std::string &fileName, bool ascii) const { if (fileName == "stdout.ply") { //if (isatty(fileno(stdout))) throw std::invalid_argument("writePLY: stdout is not connected to the terminal!"); - this->writePLY(std::cout, binary); + this->writePLY(std::cout, ascii); } else { std::ofstream outfile(fileName, std::ios_base::binary); if (!outfile.is_open()) throw std::invalid_argument("Error writing to ply file \""+fileName+"\""); - this->writePLY(outfile, binary); + this->writePLY(outfile, ascii); } }// Geometry::writePLY -void Geometry::writePLY(std::ostream &os, bool binary) const +void Geometry::writePLY(std::ostream &os, bool ascii) const { os << "ply\nformat "; - if (binary) { - os << "binary_" << (isLittleEndian() ? "little" : "big") << "_endian 1.0\n"; - } else { + if (ascii) { os << "ascii 1.0\n"; + } else { + os << "binary_" << (isLittleEndian() ? "little" : "big") << "_endian 1.0\n"; } os << "comment created by vdb_tool" << std::endl; os << "element vertex " << mVtx.size() << std::endl; @@ -288,7 +513,11 @@ void Geometry::writePLY(std::ostream &os, bool binary) const os << "property list uchar int vertex_index\n"; os << "end_header\n"; static_assert(sizeof(Vec3s) == 3 * sizeof(float), "Unexpected sizeof(Vec3s)"); - if (binary) { + if (ascii) { + for (auto &v : mVtx) os << v[0] << " " << v[1] << " " << v[2] << "\n"; + for (auto &t : mTri) os << "3 " << t[0] << " " << t[1] << " " << t[2] << "\n"; + for (auto &q : mQuad) os << "4 " << q[0] << " " << q[1] << " " << q[2] << " " << q[3] << "\n"; + } else {// binary os.write((const char *)mVtx.data(), mVtx.size() * 3 * sizeof(float));// write x,y,z vertex coordinates auto writeFaces = [](std::ostream &os, const uint32_t *faces, size_t count, uint8_t n) { if (count==0) return; @@ -304,10 +533,6 @@ void Geometry::writePLY(std::ostream &os, bool binary) const }; writeFaces(os, (const uint32_t*)mTri.data(), mTri.size(), 3); writeFaces(os, (const uint32_t*)mQuad.data(), mQuad.size(), 4); - } else {// ascii - for (auto &v : mVtx) os << v[0] << " " << v[1] << " " << v[2] << "\n"; - for (auto &t : mTri) os << "3 " << t[0] << " " << t[1] << " " << t[2] << "\n"; - for (auto &q : mQuad) os << "4 " << q[0] << " " << q[1] << " " << q[2] << " " << q[3] << "\n"; } }// Geometry::writePLY @@ -366,7 +591,7 @@ void Geometry::writeSTL(const std::string &fileName) const void Geometry::writeSTL(std::ostream &os) const { if (!isLittleEndian()) throw std::invalid_argument("STL file only supports little endian, but this system is big endian"); - if (!mQuad.empty()) throw std::invalid_argument("STL file only supports triangles"); + if (!mQuad.empty()) throw std::invalid_argument("Binary STL files only supports triangles, but the mesh contains quads:. Hint: call quad2tri"); uint8_t buffer[80] = {0};// fixed-sized buffer initiated with zeros! os.write((const char*)buffer, 80);// write header as zeros const uint32_t nTri = static_cast(mTri.size()); @@ -388,17 +613,18 @@ void Geometry::writeGEO(const std::string &fileName) const { if (fileName == "stdout.geo") { //if (isatty(fileno(stdout))) throw std::invalid_argument("writeGEO: stdout is not connected to the terminal!"); - this->write(std::cout); + this->writeGEO(std::cout); } else { std::ofstream outfile(fileName, std::ios::out | std::ios_base::binary); if (!outfile.is_open()) throw std::invalid_argument("Error writing to geo file \""+fileName+"\""); - this->write(outfile); + this->writeGEO(outfile); } }// Geometry::writeGEO -void Geometry::read(const std::string &fileName) +void Geometry::read(const std::string &fileName, int verbose) { - switch (findFileExt(fileName, {"obj", "ply", "pts", "stl", "abc", "vdb", "nvdb", "geo", "off"})) { + mVerbose = verbose; + switch (findFileExt(fileName, {"obj", "ply", "pts", "stl", "abc", "vdb", "nvdb", "geo", "off", "xyz", "usd", "usda", "usdc", "usdz", "gltf", "glb"})) { case 1: this->readOBJ(fileName); break; @@ -426,18 +652,21 @@ void Geometry::read(const std::string &fileName) case 9: this->readOFF(fileName); break; + case 10: + this->readXYZ(fileName); + break; + case 11: case 12: case 13: case 14:// usd, usda, usdc, usdz + this->readUSD(fileName); + break; + case 15: case 16:// gltf, glb + this->readGLTF(fileName); + break; default: #if VDB_TOOL_USE_PDAL - pdal::StageFactory factory; - const std::string driver = factory.inferReaderDriver(fileName); - if (driver != "") { - this->readPDAL(fileName); - break; - } + if (this->readPDAL(fileName)) break;// note, this only reads vertices #endif throw std::invalid_argument("Geometry::read: File \""+fileName+"\" has an invalid extension"); - break; - } + }// end switch over file extensions }// Geometry::read void Geometry::readOBJ(const std::string &fileName) @@ -454,44 +683,51 @@ void Geometry::readOBJ(const std::string &fileName) void Geometry::readOBJ(std::istream &is) { - Vec3f p; + Vec3f p;// coordinates + Vec3s c;// color std::string line; while (std::getline(is, line)) { std::istringstream iss(line); std::string str; - iss >> str; + iss >> str;// "v", "vn" or "f" if (str == "v") { iss >> p[0] >> p[1] >> p[2]; mVtx.push_back(p); + if (iss >> c[0] >> c[1] >> c[2]) mRGB.push_back(c); } else if (str == "f") { std::vector v; - while (iss >> str) { - v.push_back(std::stoi(str.substr(0, str.find_first_of("/")))); - } - switch (v.size()) { - case 3: + while (iss >> str) v.push_back(std::stoi(str.substr(0, str.find_first_of("/")))); + const size_t nGon = v.size(); + if (nGon == 1) { + if (mVerbose) std::clog << "Geometry::readOBJ: ignoring point, i.e. a face with with a single vertex\n"; + } else if (nGon == 2) { + if (mVerbose) std::clog << "Geometry::readOBJ: ignoring line, i.e. a face with two vertices\n"; + } else if (nGon == 3) { mTri.emplace_back(v[0] - 1, v[1] - 1, v[2] - 1);// obj is 1-based - break; - case 4: + } else if (nGon == 4) { mQuad.emplace_back(v[0] - 1, v[1] - 1, v[2] - 1, v[3] - 1);// obj is 1-based - break; - default: - throw std::invalid_argument("Geometry::readOBJ: " + std::to_string(v.size()) + "-gons are not supported"); - break; + } else { + if (mVerbose) std::clog << "Geometry::readOBJ: triangulating " << nGon << "-gon\n"; + Geometry::triangulate(v.data(), v.size(), mTri, /*indexOffset=*/-1);// obj is 1-based } } } mBBox = BBoxT();//invalidate BBox }// Geometry::readOBJ -void Geometry::readPDAL(const std::string &fileName) +// Works with multiple file formats, e.g. ply, obj, stl, hdf, matlab, numpy, pts, ptx, e57, las, laz +// Note, currently it only reads vertices and optionally colors +bool Geometry::readPDAL(const std::string &fileName) { #if VDB_TOOL_USE_PDAL - if (!pdal::FileUtils::fileExists(fileName)) throw std::invalid_argument("Error opening file \""+fileName+"\" - it doesn't exist!"); + if (!pdal::FileUtils::fileExists(fileName)) { + throw std::invalid_argument("Geometry: Error opening file \""+fileName+"\"!"); + } pdal::StageFactory factory; - std::string type = factory.inferReaderDriver(fileName); - std::string pipelineJson = R"({ + const std::string type = factory.inferReaderDriver(fileName); + if (type.empty()) return false;// PDAL cannot read this file + const std::string pipelineJson = R"({ "pipeline" : [ { "type" : ")" + type + R"(", @@ -507,11 +743,10 @@ void Geometry::readPDAL(const std::string &fileName) std::stringstream s(pipelineJson); manager.readPipeline(s); manager.execute(pdal::ExecMode::Standard); - for (const std::shared_ptr& view : manager.views()) { - bool hasColor = false; - if (view->hasDim(pdal::Dimension::Id::Red) && view->hasDim(pdal::Dimension::Id::Green) && view->hasDim(pdal::Dimension::Id::Blue)) - hasColor = true; + const bool hasColor = view->hasDim(pdal::Dimension::Id::Red) && + view->hasDim(pdal::Dimension::Id::Green) && + view->hasDim(pdal::Dimension::Id::Blue); for (const pdal::PointRef& point : *view) { p[0] = point.getFieldAs(pdal::Dimension::Id::X); p[1] = point.getFieldAs(pdal::Dimension::Id::Y); @@ -524,8 +759,7 @@ void Geometry::readPDAL(const std::string &fileName) mRGB.push_back(rgb); } } - } - + }// loop over point views } catch (const pdal::pdal_error& e) { throw std::runtime_error("PDAL failed: " + std::string(e.what())); @@ -533,10 +767,11 @@ void Geometry::readPDAL(const std::string &fileName) catch (const std::exception& e) { throw std::runtime_error("Reading file failed: " + std::string(e.what())); } + mBBox = BBoxT(); //invalidate BBox #else throw std::runtime_error("Cannot read file \"" + fileName + "\". PDAL support is not enabled in this build, please recompile with PDAL support"); #endif - mBBox = BBoxT(); //invalidate BBox + return true; }// Geometry::readPDAL void Geometry::readOFF(const std::string &fileName) @@ -554,7 +789,7 @@ void Geometry::readOFF(std::istream &is) { // read header std::string line; - if (!std::getline(is, line) || line != "OFF") { + if (!std::getline(is, line) || (line != "OFF" && line != "NOFF")) {// NOFF includes normals after the x y z coordinates throw std::invalid_argument("Geometry::readOFF: expected header \"OFF\" but read \"" + line + "\""); } @@ -596,6 +831,37 @@ void Geometry::readOFF(std::istream &is) mBBox = BBoxT();//invalidate BBox }// Geometry::readOFF +void Geometry::readXYZ(const std::string &fileName) +{ + if (fileName == "stdin.xyz") { + this->readXYZ(std::cin); + } else { + std::ifstream infile(fileName); + if (!infile.is_open()) throw std::invalid_argument("Error opening Geometry file \""+fileName+"\""); + this->readXYZ(infile); + } +}// Geometry::readXYZ + +/* +xyz files are loosely defined as ascii files with x y z coordinates, possibly followed by rgb or normals +Empty lines and lines beginning with # ignored +*/ +void Geometry::readXYZ(std::istream &is) +{ + std::string line; + Vec3f p; + while (std::getline(is, line)) { + if (line.empty() || line[0] == '#') continue; + std::istringstream iss(line); + if (iss >> p[0] >> p[1] >> p[2]) { + mVtx.push_back(p); + } else { + throw std::invalid_argument("Error reading coordinates in xyz file from line \"" + line + "\""); + } + } + mBBox = BBoxT();//invalidate BBox +}// Geometry::readXYZ + void Geometry::readPLY(const std::string &fileName) { if (fileName == "stdin.ply") { @@ -626,9 +892,9 @@ void Geometry::readPLY(std::istream &is) return false; }; auto error = [&tokens](const std::string &msg){ - std::cerr << "Tokens: \""; - for (auto &t : tokens) std::cerr << t << " "; - std::cerr << "\"\n"; + std::clog << "Tokens: \""; + for (auto &t : tokens) std::clog << t << " "; + std::clog << "\"\n"; throw std::invalid_argument(msg); }; auto sizeOf = [test, error](int i){ @@ -760,18 +1026,19 @@ void Geometry::readPLY(std::istream &is) for (auto &v : mVtx) { tokens = tokenize_line(); if (int(tokens.size()) != vtxProps) error("vdb_tool::readPLY: error reading ascii vertex coordinates"); - for (int i = 0; i<3; ++i) v[i] = std::stof(tokens[xyz[0].id]); + for (int i = 0; i<3; ++i) v[i] = std::stof(tokens[xyz[i].id]); }// loop over vertices } // read polygon vertex lists - uint32_t vtx[4]; + static const int nGon = 10;// maximum allowed nGon + uint32_t vtx[nGon]; if (format) {// binary char *buffer = static_cast(std::malloc(faceSkip[0].bytes + 1));// uninitialized if (buffer==nullptr) throw std::invalid_argument("Geometry::readPLY: failed to allocate buffer"); for (size_t i=0; i unsigned int + const int n = (int)buffer[faceSkip[0].bytes];// char -> int switch (n) { case 3: is.read((char*)vtx, 3*sizeof(uint32_t)); @@ -784,7 +1051,11 @@ void Geometry::readPLY(std::istream &is) mQuad.emplace_back(vtx); break; default: - throw std::invalid_argument("Geometry::readPLY: binary " + std::to_string(n) + "-gons are not supported"); + if (n > nGon) throw std::invalid_argument("Geometry::readPLY: binary " + std::to_string(n) + "-gons are not supported"); + if (mVerbose) std::clog << "Geometry::readPLY: binary triangulating " << n << "-gon\n"; + is.read((char*)vtx, n*sizeof(uint32_t)); + if (reverseBytes) swapBytes(vtx, n); + for (int i = 0; i < n-2; ++i) mTri.emplace_back(vtx[0], vtx[i+1], vtx[i+2]); break; } is.ignore(faceSkip[1].bytes); @@ -795,12 +1066,15 @@ void Geometry::readPLY(std::istream &is) tokens = tokenize_line(); const std::string polySize = tokens[faceSkip[0].count]; const int n = std::stoi(polySize); - if (n!=3 && n!=4) throw std::invalid_argument("Geometry::readPLY: ascii " + polySize + "-gons are not supported"); + if ( n < 3 || n > nGon) throw std::invalid_argument("Geometry::readPLY: ascii " + polySize + "-gons are not supported"); for (int i = 0, j=1+faceSkip[0].count; i(std::stoll(tokens[j])); if (n==3) { mTri.emplace_back(vtx); - } else { + } else if (n==4) { mQuad.emplace_back(vtx); + } else { + if (mVerbose) std::clog << "Geometry::readPLY: ascii triangulating " << n << "-gon\n"; + for (int i = 0; i < n - 2; ++i) mTri.emplace_back(vtx[0], vtx[i+1], vtx[i+2]); } }// loop over polygons } @@ -811,11 +1085,11 @@ void Geometry::readGEO(const std::string &fileName) { if (fileName == "stdin.geo") { //if (isatty(fileno(stdin))) throw std::invalid_argument("readGEO: stdin is not connected to the terminal!"); - this->read(std::cin); + this->readGEO(std::cin); } else { std::ifstream infile(fileName, std::ios::in | std::ios_base::binary); if (!infile.is_open()) throw std::invalid_argument("Error opening geo file \""+fileName+"\""); - this->read(infile); + this->readGEO(infile); } }// Geometry::readGEO @@ -872,7 +1146,7 @@ void Geometry::readPTS(const std::string &fileName) if (!infile.is_open()) throw std::runtime_error("Error opening particle file \""+fileName+"\""); std::string line; std::istringstream iss; - bool readColor = false; + bool readColor = true; Vec3s rgb; while(std::getline(infile, line)) { const size_t n = mVtx.size(), m = std::stoi(line); @@ -886,15 +1160,12 @@ void Geometry::readPTS(const std::string &fileName) throw std::invalid_argument("Geometry::readPTS: error parsing line: \""+line+"\""); } if (readColor) { - if (!(iss >> i) ) { // converting intensity to a multiplier on rgb might be appropriate, but i can't find a good spec for it + int dummy;// intensity which is currently ignored + if (iss >> dummy >> rgb[0] >> rgb[1] >> rgb[2]) { + mRGB.push_back(rgb/255.0); + } else { readColor = false; - continue; } - if (!(iss >> rgb[0] >> rgb[1] >> rgb[2])) { - readColor = false; - continue; - } - mRGB.push_back(rgb/255.0); } }// loop over points @@ -908,32 +1179,42 @@ void Geometry::readSTL(const std::string &fileName) std::ifstream infile(fileName, std::ios::in | std::ios::binary); if (!infile.is_open()) throw std::runtime_error("Geometry::readSTL: Error opening STL file \""+fileName+"\""); PosT xyz; - char buffer[80] = "";// small fixed stack allocated buffer - if (!infile.read(buffer, 5)) throw std::invalid_argument("Geometry::readSTL: Failed to head header"); - if (strcmp(buffer, "solid") == 0) {//ASCII file + std::array buffer{}; + if (!infile.read(buffer.data(), buffer.size())) { + throw std::runtime_error("Geometry::readSTL: Failed to read 256B in \""+fileName+"\" so this must be an empty STL file"); + } + infile.clear(); + infile.seekg(0, std::ios_base::beg);// rewind + auto isAscii = [&]()->bool{ + std::string str(buffer.data(), infile.gcount()); + toLowerCase(str); + return contains(str, "solid") && contains(str, '\n') && contains(str, "facet") && contains(str, "normal"); + }; + if (isAscii()) {//ASCII file std::string line; - std::getline(infile, line);// read rest of the first line, which completes the header + std::getline(infile, line);// read the first line, which completes the header std::istringstream iss; while(std::getline(infile, line)) { std::string tmp = trim(line, " ");// remove leading (and trailing) white spaces if (tmp.compare(0, 5, "facet")==0) { while (std::getline(infile, line) && trim(line, " ").compare(0, 10, "outer loop")); - int nGone = 0; + int nGon = 0; while(std::getline(infile, line)) {// loop over vertices of the facet tmp = trim(line, " "); if (tmp.compare(0, 7, "endloop")==0) break; OPENVDB_ASSERT(tmp.compare(0, 6, "vertex")==0); iss.clear(); iss.str(tmp.substr(6)); - if (iss >> xyz[0] >> xyz[1] >> xyz[2]) { - mVtx.push_back(xyz); - ++nGone; + double p[3];// more robust to read ascii coordinates as double than float + if (iss >> p[0] >> p[1] >> p[2]) { + mVtx.emplace_back(float(p[0]), float(p[1]), float(p[2])); + ++nGon; } else { - throw std::invalid_argument("Geometry::readSTL ASCII: error parsing line: \""+line+"\""); + throw std::invalid_argument("Geometry::readSTL ASCII: error parsing line: \""+line+"\" in \""+fileName+"\""); } }// endloop const int vtx = static_cast(mVtx.size()) - 1; - switch (nGone){ + switch (nGon){ case 3: mTri.emplace_back(vtx - 2, vtx - 1, vtx); break; @@ -941,17 +1222,18 @@ void Geometry::readSTL(const std::string &fileName) mQuad.emplace_back(vtx - 3, vtx - 2, vtx - 1, vtx); break; default: - throw std::invalid_argument("Geometry::readSTL ASCII: " + std::to_string(nGone)+"-gons are not supported"); + // could be fixed as in readOBJ! + throw std::invalid_argument("Geometry::readSTL ASCII: " + std::to_string(nGon)+"-gons are not supported"); } } }// loop over lines in file } else {// binary file if (!isLittleEndian()) throw std::invalid_argument("Geometry::readSTL binary: STL file only supports little endian, but this system is big endian"); - if (!infile.read(buffer, 80 - 5)) throw std::invalid_argument("Geometry::readSTL binary: Failed to head header"); + if (!infile.read(buffer.data(), 80)) throw std::invalid_argument("Geometry::readSTL binary: Failed to read header in \""+fileName+"\""); uint32_t numTri; - if (!infile.read((char*)&numTri, sizeof(numTri))) throw std::invalid_argument("Geometry::readSTL binary: Failed to read triangle count"); + if (!infile.read((char*)&numTri, sizeof(numTri))) throw std::invalid_argument("Geometry::readSTL binary: Failed to read triangle count in \""+fileName+"\""); infile.seekg (0, infile.end); - if (infile.tellg() != 80 + 4 + 50*numTri) throw std::invalid_argument("Geometry::readSTL binary: Unexpected file size"); + if (infile.tellg() != 80 + 4 + 50*numTri) throw std::invalid_argument("Geometry::readSTL binary: Unexpected file size in \""+fileName+"\""); infile.seekg(80 + 4, infile.beg); uint32_t vtxBegin = static_cast(mVtx.size()), triBegin = static_cast(mTri.size()); mVtx.resize(vtxBegin + 3*numTri); @@ -959,8 +1241,8 @@ void Geometry::readSTL(const std::string &fileName) Vec3f *pV = mVtx.data() + vtxBegin; Vec3I *pT = mTri.data() + triBegin; for (uint32_t i = 0; i < numTri; ++i) {// loop over triangles - if (!infile.read(buffer, 50)) throw std::invalid_argument("Geometry::readSTL binary: error reading triangle #"+std::to_string(i)); - const float *p = 3 + reinterpret_cast(buffer);// ignore 3 vector components of normal + if (!infile.read(buffer.data(), 50)) throw std::invalid_argument("Geometry::readSTL binary: error reading triangle #"+std::to_string(i)+" in \""+fileName+"\""); + const float *p = 3 + reinterpret_cast(buffer.data());// ignore 3 vector components of normal for (int j=0; j<3; ++j) {// loop over vertices of triangle for (int k=0; k<3; ++k) xyz[k] = *p++;//loop over coordinates of vertex *pV++ = xyz; @@ -986,23 +1268,32 @@ void Geometry::readNVDB(const std::string &fileName) mVtx.resize(n + count); for (size_t i=n; i &points) -> int32_t { + const int32_t base = static_cast(mVtx.size()); + const pxr::GfMatrix4d xf = xformCache.GetLocalToWorldTransform(prim); + mVtx.reserve(mVtx.size() + points.size()); + for (const pxr::GfVec3f &p : points) { + const pxr::GfVec3d w = xf.Transform(pxr::GfVec3d(p)); + mVtx.emplace_back(float(w[0]), float(w[1]), float(w[2])); + } + return base; + }; + + for (const pxr::UsdPrim &prim : stage->Traverse()) { + // Mesh: positions + face topology. + pxr::UsdGeomMesh mesh(prim); + if (mesh) { + pxr::VtArray points; + pxr::VtArray faceCounts; + pxr::VtArray faceIndices; + if (!mesh.GetPointsAttr().Get(&points)) continue; + if (!mesh.GetFaceVertexCountsAttr().Get(&faceCounts)) continue; + if (!mesh.GetFaceVertexIndicesAttr().Get(&faceIndices)) continue; + if (points.empty() || faceCounts.empty()) continue; + + const int32_t base = appendPoints(prim, points); + const int *f = faceIndices.cdata(); + for (int count : faceCounts) { + if (count == 3) { + mTri.emplace_back(base + f[0], base + f[1], base + f[2]); + } else if (count == 4) { + mQuad.emplace_back(base + f[0], base + f[1], base + f[2], base + f[3]); + } else if (count > 4) { + if (mVerbose) std::clog << "Geometry::readUSD: fan-triangulating " << count << "-gon\n"; + Geometry::triangulate(f, static_cast(count), mTri, base); + }// counts < 3 (degenerate) are silently dropped + f += count; + } + continue; + } + + // Points: positions only (no face data). + pxr::UsdGeomPoints pts(prim); + if (pts) { + pxr::VtArray points; + if (!pts.GetPointsAttr().Get(&points)) continue; + if (points.empty()) continue; + (void)appendPoints(prim, points); + continue; + } + } + + mBBox = BBoxT();// invalidate cached bbox +}// Geometry::readUSD + +#else + +void Geometry::readUSD(const std::string&) +{ + throw std::runtime_error("USD read support was disabled during compilation!"); +} + +#endif// VDB_TOOL_USE_USD + +#ifdef VDB_TOOL_USE_GLTF + +void Geometry::readGLTF(const std::string &fileName) +{ + tinygltf::Model model; + tinygltf::TinyGLTF loader; + std::string err, warn; + + // Disable image decoding. We compiled tinygltf without stb_image, so its + // built-in PNG/JPG decoder is gone; without a substitute, any .gltf/.glb + // that references a texture fails to load with "No LoadImageData callback + // specified." vdb_tool only consumes geometry, so install a no-op loader + // that silently accepts every image and returns success. + loader.SetImageLoader( + [](tinygltf::Image*, const int, std::string*, std::string*, + int, int, const unsigned char*, int, void*) -> bool { return true; }, + nullptr); + + // Dispatch on the extension to pick the binary (.glb) vs ASCII (.gltf) + // path. tinygltf returns false on any parse error and populates `err`. + const std::string ext = toLowerCase(getExt(fileName)); + const bool ok = (ext == "glb") + ? loader.LoadBinaryFromFile(&model, &err, &warn, fileName) + : loader.LoadASCIIFromFile (&model, &err, &warn, fileName); + + if (!warn.empty() && mVerbose > 0) { + std::clog << "Geometry::readGLTF: " << warn << "\n"; + } + if (!ok) { + throw std::invalid_argument("readGLTF: " + + (err.empty() ? "failed to load \"" + fileName + "\"" : err)); + } + + // Pulls a flat array of indices regardless of the source componentType + // (glTF allows uint8 / uint16 / uint32). The returned vector is sized to + // match accessor.count. + auto readIndices = [&](const tinygltf::Accessor &acc) { + std::vector out; + out.reserve(acc.count); + const tinygltf::BufferView &bv = model.bufferViews[acc.bufferView]; + const tinygltf::Buffer &buf = model.buffers[bv.buffer]; + const unsigned char *base = &buf.data[bv.byteOffset + acc.byteOffset]; + switch (acc.componentType) { + case TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE: + for (size_t i = 0; i < acc.count; ++i) out.push_back(base[i]); + break; + case TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT: { + const auto *p = reinterpret_cast(base); + for (size_t i = 0; i < acc.count; ++i) out.push_back(p[i]); + break; + } + case TINYGLTF_COMPONENT_TYPE_UNSIGNED_INT: { + const auto *p = reinterpret_cast(base); + for (size_t i = 0; i < acc.count; ++i) out.push_back(static_cast(p[i])); + break; + } + default: + throw std::invalid_argument("readGLTF: unsupported index componentType " + + std::to_string(acc.componentType)); + } + return out; + }; + + // Walk every mesh / primitive. glTF allows multiple meshes per file; + // primitives within a mesh are independent vertex+index sets that share + // attributes only via accessor indices. + size_t skipped = 0; + for (const tinygltf::Mesh &mesh : model.meshes) { + for (const tinygltf::Primitive &prim : mesh.primitives) { + // Mode -1 is "unspecified" → triangles per spec; anything else + // that isn't TRIANGLES (POINTS, LINES, STRIPS, FANS) is skipped. + if (prim.mode != -1 && prim.mode != TINYGLTF_MODE_TRIANGLES) { + ++skipped; + continue; + } + const auto posIt = prim.attributes.find("POSITION"); + if (posIt == prim.attributes.end()) continue; + const tinygltf::Accessor &pAcc = model.accessors[posIt->second]; + if (pAcc.type != TINYGLTF_TYPE_VEC3 || + pAcc.componentType != TINYGLTF_COMPONENT_TYPE_FLOAT) { + throw std::invalid_argument("readGLTF: only Vec3 float POSITION accessors are supported"); + } + + // Append vertices and remember the offset so face indices below + // are translated from per-primitive space into Geometry-wide space. + const tinygltf::BufferView &pbv = model.bufferViews[pAcc.bufferView]; + const tinygltf::Buffer &pbuf = model.buffers[pbv.buffer]; + const size_t stride = pAcc.ByteStride(pbv); + const unsigned char *pbase = &pbuf.data[pbv.byteOffset + pAcc.byteOffset]; + const int baseIdx = static_cast(mVtx.size()); + mVtx.reserve(mVtx.size() + pAcc.count); + for (size_t i = 0; i < pAcc.count; ++i) { + const auto *p = reinterpret_cast(pbase + i * stride); + mVtx.emplace_back(p[0], p[1], p[2]); + } + + if (prim.indices < 0) { + // Non-indexed: consecutive vertex triples are triangles. + for (size_t i = 0; i + 2 < pAcc.count; i += 3) { + mTri.emplace_back(baseIdx + static_cast(i), + baseIdx + static_cast(i + 1), + baseIdx + static_cast(i + 2)); + } + } else { + const auto idx = readIndices(model.accessors[prim.indices]); + mTri.reserve(mTri.size() + idx.size() / 3); + for (size_t i = 0; i + 2 < idx.size(); i += 3) { + mTri.emplace_back(baseIdx + idx[i], + baseIdx + idx[i + 1], + baseIdx + idx[i + 2]); + } + } + } + } + if (skipped > 0 && mVerbose > 0) { + std::clog << "Geometry::readGLTF: skipped " << skipped + << " primitive(s) with non-TRIANGLES mode\n"; + } + mBBox = BBoxT();// invalidate cached bbox +}// Geometry::readGLTF + +#else + +void Geometry::readGLTF(const std::string&) +{ + throw std::runtime_error("glTF read support was disabled during compilation!"); +} + +#endif// VDB_TOOL_USE_GLTF + +Geometry::Ptr Geometry::deepCopy() const { Ptr other = std::make_shared(); other->mVtx = mVtx; @@ -1257,6 +1757,54 @@ void Geometry::transform(const math::Transform &xform) mBBox = BBoxT();//invalidate BBox }// Geometry::transform +size_t Geometry::triangulateQuads() +{ + const size_t quadCount = mQuad.size(); + if (quadCount == 0) return 0; + const size_t triCount = mTri.size(); + mTri.resize(triCount + 2*quadCount); + using RangeT = tbb::blocked_range; + tbb::parallel_for(RangeT(0, quadCount), [&](RangeT r){ + for (size_t i=r.begin(); i &out, int indexOffset) +{ + if (n < 3) return; + const std::size_t added = n - 2; + out.reserve(out.size() + added); + const int v0 = indices[0] + indexOffset; + for (std::size_t i = 0; i < added; ++i) { + out.emplace_back(v0, + indices[i + 1] + indexOffset, + indices[i + 2] + indexOffset); + } +} + +std::vector Geometry::triangulate(const std::vector &nGon) +{ + std::vector out; + triangulate(nGon.data(), nGon.size(), out); + return out; +} + } // namespace vdb_tool } // namespace OPENVDB_VERSION_NAME } // namespace openvdb diff --git a/openvdb_cmd/vdb_tool/include/Parser.h b/openvdb_cmd/vdb_tool/include/Parser.h index 08b08599f5..93539ae8af 100644 --- a/openvdb_cmd/vdb_tool/include/Parser.h +++ b/openvdb_cmd/vdb_tool/include/Parser.h @@ -7,8 +7,24 @@ /// /// @file Parser.h /// -/// @brief Defines various classes (Processor, Parser, Option, Action, Loop) -/// for processing of command-line arguments. +/// @brief Command-line argv parser and supporting infrastructure for the +/// action / option / expression / loop language used by vdb_tool. +/// Defines: +/// - Option, Action — declarative description of a registered action +/// (names, option list, init/run lambdas, anonymous-arg index, +/// greedy flag for actions whose value may contain spaces). +/// - Memory, Stack — the string-keyed variable store and the working +/// stack shared with -eval / -calc. +/// - Processor — RPN evaluator for "{...}" expressions embedded in +/// option values (e.g. "{1:2:+}" -> "3", "{$x:path}" -> dirname). +/// - BaseLoop and the derived ForLoop, EachLoop, FilesLoop, IfLoop — +/// control-flow scopes implementing -for, -each, -files, -if; +/// closed by -end and tracked via the openLoops stack. +/// - Parser — top-level orchestrator: registers actions via +/// addAction, parses argv, drives the run loop, and produces +/// "did you mean" suggestions for typo'd action/option names. +/// +/// @warning All prints are directed to cerr since cout is used for piping! /// //////////////////////////////////////////////////////////////////////////////// @@ -19,9 +35,11 @@ #include #include // for std::string, std::stof and std::stoi #include // std::sort +#include #include #include #include +#include // for map and multimap #include #include #include @@ -35,6 +53,7 @@ #include #include "Util.h" +#include "Calculator.h"// used by the -if action to evaluate infix/RPN test expressions namespace openvdb { OPENVDB_USE_VERSION_NAMESPACE @@ -43,61 +62,111 @@ namespace vdb_tool { // ============================================================================================================== -/// @brief This class defines string attributes for options, i.e. arguments for actions +/// @brief String attributes for a single option (i.e. an argument to an action). +/// @details Each option carries a name, its current value (possibly empty), +/// an example string used in help output, and a documentation string. struct Option { + /// @brief Append @a v to value, comma-separating if value is already non-empty. void append(const std::string &v) {value = value.empty() ? v : value + "," + v;} - std::string name, value, example, documentation; + std::string name; ///< Option name, e.g. "voxel" or "radius". + std::string value; ///< Current value as a string (may contain expressions). + std::string example; ///< Example value shown in usage/help output. + std::string documentation; ///< Human-readable description of the option. }; // ============================================================================================================== + +/// @brief Describes a single command-line action (e.g. "-sphere", "-read") +/// with its aliases, options, and init/run callbacks. +/// @details Actions are registered with the Parser via Parser::addAction. During +/// parsing, the init callback is invoked when the action is encountered; +/// during execution, the run callback is invoked to perform the work. struct Action { - /// @brief c-tor - Action(std::string _name, - std::string _alias, + /// @brief Constructor. + /// @param _names List of names/aliases for this action (e.g. {"read", "import", "load", "i"}). + /// @param _doc One-line documentation string shown in usage output. + /// @param _options Options accepted by this action. + /// @param _init Callback invoked during parsing (typically for syntax/state checks). + /// @param _run Callback invoked during execution to perform the action. + /// @param _anonymous Index of the option to which un-named option values are appended, + /// or size_t(-1) if anonymous values are disallowed. + /// @param _greedy When true, any "name=value" token whose prefix is not + /// a recognized option name is also appended (whole) to + /// the anonymous option, rather than throwing + /// "Invalid option". Used when the anonymous option's + /// value may itself contain '=', e.g. the kernel string + /// in -calc or the kernel of forAllValues / forOnValues + /// / forOffValues. Requires _anonymous to be a valid + /// option index. + Action(std::vector &&_names, std::string _doc, std::vector