diff --git a/.github/tsan.suppressions.ini b/.github/tsan.suppressions.ini index 35001261..1e513aab 100644 --- a/.github/tsan.suppressions.ini +++ b/.github/tsan.suppressions.ini @@ -11,5 +11,8 @@ race:openvdb::*::tools::mesh_to_volume_internal::ComputeIntersectingVoxelSign race:openvdb::*::tools::v2s_internal::UpdatePoints # See https://github.com/embree/embree/issues/469 race_top:embree::parallel_any_of +# Instant Meshes uses __sync_bool_compare_and_swap mixed with plain reads, and has other data races in its +# Optimizer class. These are all in the third-party instant-meshes-core library. +race:instant_meshes:: # Well... this one isn't gonna fix itself. race:^triangleinit diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index cd34b855..c104e7bd 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -12,6 +12,7 @@ concurrency: env: CTEST_OUTPUT_ON_FAILURE: ON CTEST_PARALLEL_LEVEL: 1 + SCCACHE_GHA_ENABLED: "true" jobs: #################### @@ -25,7 +26,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-24.04, macos-15-intel, macos-15] - config: [RelwithDebInfo, Debug] + config: [RelWithDebInfo, Debug] compiler: [gcc, apple, llvm] sanitizer: ["Address", "Thread"] # TODO: Add Memory+Undefined Sanitizer exclude: @@ -82,8 +83,7 @@ jobs: - name: Select build dir (macOS) if: runner.os == 'macOS' - run: - echo "build_dir=build" >> "$GITHUB_ENV" + run: echo "build_dir=build" >> "$GITHUB_ENV" - name: Checkout repository uses: actions/checkout@v4 @@ -109,18 +109,22 @@ jobs: echo "CC=clang" >> $GITHUB_ENV echo "CXX=clang++" >> $GITHUB_ENV fi - sudo apt-get install xorg-dev ccache + sudo apt-get install xorg-dev - name: Dependencies (macOS) if: runner.os == 'macOS' run: | - brew install ccache if [ "${{ matrix.compiler }}" = "llvm" ]; then brew install llvm@17 echo "CC='$(brew --prefix llvm@17)/bin/clang'" >> $GITHUB_ENV echo "CXX='$(brew --prefix llvm@17)/bin/clang++'" >> $GITHUB_ENV fi + - name: Ccache + uses: hendrikmuhs/ccache-action@v1.2 + with: + key: ${{ matrix.os }}-${{ matrix.compiler }}-${{ matrix.sanitizer }}-${{ matrix.config }} + - name: Setup Xcode version if: matrix.os == 'macos-15-intel' uses: maxim-lobanov/setup-xcode@v1 @@ -133,22 +137,22 @@ jobs: with: xcode-version: '26.0.1' + - name: Select embree isa (Linux) + if: runner.os == 'Linux' + run: echo "embree_max_isa=SSE2" >> $GITHUB_ENV + + - name: Select embree isa (macOS x64) + if: runner.os == 'macOS' && runner.arch == 'X64' + run: echo "embree_max_isa=DEFAULT" >> $GITHUB_ENV + + - name: Select embree isa (macOS arm64) + if: runner.os == 'macOS' && runner.arch == 'ARM64' + run: echo "embree_max_isa=NONE" >> $GITHUB_ENV + - name: Get number of CPU cores uses: SimenB/github-actions-cpu-cores@v1 id: cpu-cores - - name: Cache Build - id: cache-build - uses: actions/cache@v3 - with: - path: ~/.ccache - key: ${{ matrix.name }}-${{ matrix.config }}-cache - - - name: Prepare ccache - run: | - ccache --max-size=1.0G - ccache -V && ccache --show-stats && ccache --zero-stats - - name: Configure run: | cmake --version @@ -161,12 +165,11 @@ jobs: -DOPENVDB_CORE_STATIC=OFF \ -DUSE_EXPLICIT_INSTANTIATION=OFF \ -DLAGRANGE_POLYSCOPE_MOCK_BACKEND=ON \ + -DEMBREE_MAX_ISA=${{ env.embree_max_isa }} \ -DUSE_SANITIZER="${{ matrix.sanitizer }}" - name: Build - run: | - cmake --build ${{ env.build_dir }} -j ${{ steps.cpu-cores.outputs.count }} - ccache --show-stats + run: cmake --build ${{ env.build_dir }} -j ${{ steps.cpu-cores.outputs.count }} - name: Show disk space if: always() @@ -208,25 +211,20 @@ jobs: - name: Set env variable for sccache run: | echo "appdata=$env:LOCALAPPDATA" >> ${env:GITHUB_ENV} + # Disable idle timeout: embree AVX2 files take 12+ min each and can occupy all job slots, + # starving sccache of requests until the default 600s timeout kills the server. + echo "SCCACHE_IDLE_TIMEOUT=0" >> ${env:GITHUB_ENV} + + - name: Select embree isa (Windows) + if: runner.os == 'Windows' + run: echo "embree_max_isa=AVX2" >> ${env:GITHUB_ENV} - name: Get number of CPU cores uses: SimenB/github-actions-cpu-cores@v1 id: cpu-cores - - name: Cache build - id: cache-build - uses: actions/cache@v3 - with: - path: ${{ env.appdata }}\Mozilla\sccache - key: ${{ runner.os }}-${{ matrix.config }}-cache - - - name: Prepare sccache - run: | - iwr -useb 'https://raw.githubusercontent.com/scoopinstaller/install/master/install.ps1' -outfile 'install.ps1' - .\install.ps1 -RunAsAdmin - scoop install sccache --global - # Scoop modifies the PATH so we make it available for the next steps of the job - echo "${env:PATH}" >> ${env:GITHUB_PATH} + - name: Sccache + uses: mozilla-actions/sccache-action@v0.0.9 # We run configure + build in the same step, since they both need to call VsDevCmd # Also, cmd uses ^ to break commands into multiple lines (in powershell this is `) @@ -240,10 +238,16 @@ jobs: -DLAGRANGE_JENKINS=ON ^ -DLAGRANGE_ALL=ON ^ -DLAGRANGE_POLYSCOPE_MOCK_BACKEND=ON ^ + -DEMBREE_MAX_ISA=${{ env.embree_max_isa }} ^ -B "D:/build" ^ -S . cmake --build "D:/build" -j ${{ steps.cpu-cores.outputs.count }} + - name: Sccache stats + if: always() + shell: bash + run: ${SCCACHE_PATH} --show-stats + - name: Show disk space if: always() run: Get-PSDrive diff --git a/CMakeLists.txt b/CMakeLists.txt index ea054963..aeadf9b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,9 +130,9 @@ endif() # Detects whether internal libs are available if(IS_DIRECTORY "${PROJECT_SOURCE_DIR}/cmake/recipes/internal") - set(LAGRANGE_NO_INTERNAL OFF) + option(LAGRANGE_NO_INTERNAL "Disable internal dependencies" OFF) else() - set(LAGRANGE_NO_INTERNAL ON) + option(LAGRANGE_NO_INTERNAL "Disable internal dependencies" ON) endif() # Define this first since other options defaults depend on this @@ -189,8 +189,10 @@ endif() # When using vcpkg, use find_package() to find external libraries if(DEFINED VCPKG_INSTALLED_DIR) set(LAGRANGE_USE_FIND_PACKAGE_DEFAULT ON) + option(LAGRANGE_WITH_EMBREE_3 "Use Embree3 with Lagrange" ON) else() set(LAGRANGE_USE_FIND_PACKAGE_DEFAULT OFF) + option(LAGRANGE_WITH_EMBREE_3 "Use Embree3 with Lagrange" OFF) endif() if(EMSCRIPTEN) diff --git a/LagrangeOptions.cmake.sample b/LagrangeOptions.cmake.sample index f99bdea2..a05af14e 100644 --- a/LagrangeOptions.cmake.sample +++ b/LagrangeOptions.cmake.sample @@ -63,6 +63,7 @@ # option(LAGRANGE_MODULE_PRIMITIVE "Build module lagrange::primitive" ON) # option(LAGRANGE_MODULE_PYTHON "Build module lagrange::python" ON) # option(LAGRANGE_MODULE_RAYCASTING "Build module lagrange::raycasting" ON) +# option(LAGRANGE_MODULE_REMESHING_IM "Build module lagrange::remeshing_im" ON) # option(LAGRANGE_MODULE_SCENE "Build module lagrange::scene" ON) # option(LAGRANGE_MODULE_SOLVER "Build module lagrange::solver" ON) # option(LAGRANGE_MODULE_SUBDIVISION "Build module lagrange::subdivision" ON) diff --git a/cmake/lagrange/lagrange_download_data.cmake b/cmake/lagrange/lagrange_download_data.cmake index 66ed0b01..45edf104 100644 --- a/cmake/lagrange/lagrange_download_data.cmake +++ b/cmake/lagrange/lagrange_download_data.cmake @@ -29,7 +29,7 @@ function(lagrange_download_data) PREFIX "${FETCHCONTENT_BASE_DIR}/lagrange-test-data" SOURCE_DIR ${LAGRANGE_DATA_FOLDER} GIT_REPOSITORY https://github.com/adobe/lagrange-test-data.git - GIT_TAG 2a36e21810e5e4e16c34f0c3969b4fcffe8d02f2 + GIT_TAG a8331c8a1fd9c114abdcc3d5ed8ea5f7f8058c99 CONFIGURE_COMMAND "" BUILD_COMMAND "" INSTALL_COMMAND "" diff --git a/cmake/lagrange/lagrange_vcpkg_toolchain.cmake b/cmake/lagrange/lagrange_vcpkg_toolchain.cmake index 1ac4425b..727d2be7 100644 --- a/cmake/lagrange/lagrange_vcpkg_toolchain.cmake +++ b/cmake/lagrange/lagrange_vcpkg_toolchain.cmake @@ -44,6 +44,18 @@ CPMAddPackage( set(ENV{VCPKG_ROOT} "${vcpkg_SOURCE_DIR}") set(ENV{VCPKG_KEEP_ENV_VARS} "VCPKG_ROOT;$ENV{VCPKG_KEEP_ENV_VARS}") +if(WIN32) + CPMAddPackage( + NAME windowstoolchain + GIT_REPOSITORY https://github.com/MarkSchofield/WindowsToolchain.git + GIT_TAG v0.13.0 + QUIET + DOWNLOAD_ONLY ON + ) + FetchContent_GetProperties(windowstoolchain) + set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${windowstoolchain_SOURCE_DIR}/Windows.MSVC.toolchain.cmake") +endif() + # Since this file is included as a toolchain file via our CMake preset, we setup vcpkg.cmake # via CMAKE_PROJECT_TOP_LEVEL_INCLUDES instead. See the following threads for more information: # https://discourse.cmake.org/t/built-in-package-manager-for-cmake-modules/7513 diff --git a/cmake/recipes/external/CPM.cmake b/cmake/recipes/external/CPM.cmake index 8ee4f58f..ff4b772a 100644 --- a/cmake/recipes/external/CPM.cmake +++ b/cmake/recipes/external/CPM.cmake @@ -6,7 +6,7 @@ # accordance with the terms of the Adobe license agreement accompanying # it. # -set(CPM_DOWNLOAD_VERSION 0.40.8) +set(CPM_DOWNLOAD_VERSION 0.42.1) if(CPM_SOURCE_CACHE) set(CPM_DOWNLOAD_LOCATION "${CPM_SOURCE_CACHE}/cpm/CPM_${CPM_DOWNLOAD_VERSION}.cmake") diff --git a/cmake/recipes/external/OpenSubdiv.cmake b/cmake/recipes/external/OpenSubdiv.cmake index eed98c3f..2e560e02 100644 --- a/cmake/recipes/external/OpenSubdiv.cmake +++ b/cmake/recipes/external/OpenSubdiv.cmake @@ -80,11 +80,14 @@ block() endif() endforeach() - if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "AppleClang" OR - "${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") - target_compile_options(bfr_obj PRIVATE - "-Wno-unused-private-field" - ) - endif() + foreach(name IN ITEMS bfr_obj far_obj osd_cpu_obj osd_static_cpu sdc_obj vtr_obj) + if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "AppleClang" OR + "${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") + target_compile_options(${name} PRIVATE + "-Wno-unused-private-field" + "-Wno-unused-parameter" + ) + endif() + endforeach() endblock() diff --git a/cmake/recipes/external/embree.cmake b/cmake/recipes/external/embree.cmake index 3c6cf57b..a323f51c 100644 --- a/cmake/recipes/external/embree.cmake +++ b/cmake/recipes/external/embree.cmake @@ -23,17 +23,30 @@ set(EMBREE_TESTING_INTENSITY 0 CACHE STRING "Intensity of testing (0 = n set(EMBREE_TASKING_SYSTEM "TBB" CACHE STRING "Selects tasking system") option(EMBREE_IGNORE_CMAKE_CXX_FLAGS "When enabled Embree ignores default CMAKE_CXX_FLAGS." OFF) +# Set C++ namespace to ensure support for user-defined namespaces +# TODO: Not supported yet by other internal dependencies. +# set(EMBREE_API_NAMESPACE "embree" CACHE STRING "C++ namespace to put API symbols into.") + # The following options are necessary to ensure packed-ray support option(EMBREE_RAY_MASK "Enable the usage of mask for packed ray." ON) option(EMBREE_RAY_PACKETS "Enable the usage packed ray." ON) -if(APPLE) - set(EMBREE_MAX_ISA "NEON" CACHE STRING "Selects highest ISA to support.") -elseif(EMSCRIPTEN) +# Match embree's platform detection logic for arm +if(APPLE AND CMAKE_SYSTEM_NAME STREQUAL "Darwin" AND (CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" AND CMAKE_OSX_ARCHITECTURES STREQUAL "") OR ("arm64" IN_LIST CMAKE_OSX_ARCHITECTURES)) + set(EMBREE_ARM ON) +elseif(CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64" OR CMAKE_SYSTEM_PROCESSOR STREQUAL "ARM64") + set(EMBREE_ARM ON) +endif() + +if(EMSCRIPTEN) set(EMBREE_MAX_ISA "SSE2" CACHE STRING "Selects highest ISA to support.") set(FLAGS_SSE2 "-msse -msse2 -msimd128") # set to non-empty to prevent embree from using incorrect flags else() - set(EMBREE_MAX_ISA "DEFAULT" CACHE STRING "Selects highest ISA to support.") + if(APPLE AND NOT EMBREE_ARM) + set(EMBREE_MAX_ISA "DEFAULT" CACHE STRING "Selects highest ISA to support.") + else() + set(EMBREE_MAX_ISA "NONE" CACHE STRING "Selects highest ISA to support.") + endif() endif() # We want to compile Embree with TBB support, so we need to overwrite Embree's @@ -89,23 +102,29 @@ function(embree_import_target) lagrange_find_package(TBB CONFIG REQUIRED) ignore_package(TBB) get_target_property(TBB_INCLUDE_DIRS TBB::tbb INTERFACE_INCLUDE_DIRECTORIES) - add_library(TBB INTERFACE) - target_link_libraries(TBB INTERFACE TBB::tbb) + if(NOT TARGET TBB) + add_library(TBB INTERFACE) + target_link_libraries(TBB INTERFACE TBB::tbb) + endif() set(TBB_LIBRARIES TBB) # Ready to include embree's atrocious CMake include(CPM) - set(CMAKE_POLICY_VERSION_MINIMUM 3.5) + set(EMBREE_VERSION v4.4.0) + set(EMBREE_PATCHES "") + if(LAGRANGE_WITH_EMBREE_3) + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) + set(EMBREE_VERSION v3.13.5) + # Patch for emscripten compatibility. Fix available upstream in Embree 4+. + # https://github.com/RenderKit/embree/pull/365 + # https://github.com/RenderKit/embree/issues/486 + set(EMBREE_PATCHES PATCHES embree.patch) + endif() CPMAddPackage( NAME embree - GITHUB_REPOSITORY embree/embree - GIT_TAG v3.13.5 - - PATCHES - # Patch for emscripten compatibility. Fix available upstream in Embree 4+. - # https://github.com/RenderKit/embree/pull/365 - # https://github.com/RenderKit/embree/issues/486 - embree.patch + GITHUB_REPOSITORY RenderKit/embree + GIT_TAG ${EMBREE_VERSION} + ${EMBREE_PATCHES} ) unignore_package(TBB) diff --git a/cmake/recipes/external/geometry-central.cmake b/cmake/recipes/external/geometry-central.cmake index 034da5bd..0b5e6df9 100644 --- a/cmake/recipes/external/geometry-central.cmake +++ b/cmake/recipes/external/geometry-central.cmake @@ -43,3 +43,10 @@ install(DIRECTORY ${geometry-central_SOURCE_DIR} DESTINATION ${CMAKE_INSTALL_INC install(TARGETS geometry-central EXPORT GeometryCentral_Targets) install(EXPORT GeometryCentral_Targets DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/geometry-central NAMESPACE geometry-central::) + +if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "AppleClang" OR + "${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") + target_compile_options(geometry-central PRIVATE + "-Wno-deprecated-declarations" + ) +endif() diff --git a/cmake/recipes/external/gl3w.cmake b/cmake/recipes/external/gl3w.cmake index e04230e0..da564429 100644 --- a/cmake/recipes/external/gl3w.cmake +++ b/cmake/recipes/external/gl3w.cmake @@ -21,7 +21,7 @@ block() CPMAddPackage( NAME gl3w GITHUB_REPOSITORY adobe/lagrange-gl3w - GIT_TAG a9e41479e30266cecb72df413f4f6d71b0228a71 + GIT_TAG b31870049f562cfe9af52ccbedebfff0694864f2 ) endblock() diff --git a/cmake/recipes/external/instant-meshes-core.cmake b/cmake/recipes/external/instant-meshes-core.cmake index e7c7d8ac..f5766719 100644 --- a/cmake/recipes/external/instant-meshes-core.cmake +++ b/cmake/recipes/external/instant-meshes-core.cmake @@ -20,6 +20,9 @@ CPMAddPackage( NAME instant-meshes-core GITHUB_REPOSITORY qnzhou/instant-meshes-core GIT_TAG 7e2b804d533e10578a730bb9d06dee2a5418730d + PATCHES + # Fix memory leak: adj_sets buffer not freed in generate_adjacency_matrix_pointcloud. + instant-meshes-core.patch ) add_library(instant-meshes-core::instant-meshes-core ALIAS instant-meshes-core) diff --git a/cmake/recipes/external/instant-meshes-core.patch b/cmake/recipes/external/instant-meshes-core.patch new file mode 100644 index 00000000..2d4b5bf9 --- /dev/null +++ b/cmake/recipes/external/instant-meshes-core.patch @@ -0,0 +1,11 @@ +--- a/src/adjacency.cpp ++++ b/src/adjacency.cpp +@@ -320,6 +320,8 @@ AdjacencyMatrix generate_adjacency_matrix_pointcloud( + } + }); + ++ delete[] adj_sets; ++ + /* Use a heuristic to estimate some useful quantities for point clouds (this + is a biased estimate due to the kNN queries, but it's convenient and + reasonably accurate) */ diff --git a/cmake/recipes/external/spdlog.cmake b/cmake/recipes/external/spdlog.cmake index 38ac7358..c5cc035a 100644 --- a/cmake/recipes/external/spdlog.cmake +++ b/cmake/recipes/external/spdlog.cmake @@ -25,6 +25,7 @@ endif() set(CMAKE_INSTALL_DEFAULT_COMPONENT_NAME "spdlog") # Versions of fmt bundled with spdlog: +# - spdlog 1.17.0 -> fmt 12.1.0 # - spdlog 1.16.0 -> fmt 12.0.0 # - spdlog 1.15.3 -> fmt 11.2.0 # - spdlog 1.15.2 -> fmt 11.1.4 @@ -39,7 +40,7 @@ include(CPM) CPMAddPackage( NAME spdlog GITHUB_REPOSITORY gabime/spdlog - GIT_TAG v1.16.0 + GIT_TAG v1.17.0 ) set_target_properties(spdlog PROPERTIES POSITION_INDEPENDENT_CODE ON) diff --git a/cmake/recipes/external/ufbx.cmake b/cmake/recipes/external/ufbx.cmake index e4208361..7ac2c981 100644 --- a/cmake/recipes/external/ufbx.cmake +++ b/cmake/recipes/external/ufbx.cmake @@ -18,7 +18,7 @@ include(CPM) CPMAddPackage( NAME ufbx GITHUB_REPOSITORY ufbx/ufbx - GIT_TAG v0.21.2 + GIT_TAG v0.21.3 ) include(GNUInstallDirs) diff --git a/modules/bvh/include/lagrange/bvh/compute_mesh_distances.h b/modules/bvh/include/lagrange/bvh/compute_mesh_distances.h new file mode 100644 index 00000000..3ef83715 --- /dev/null +++ b/modules/bvh/include/lagrange/bvh/compute_mesh_distances.h @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include + +#include + +namespace lagrange::bvh { + +/// @addtogroup module-bvh +/// @{ + +/// +/// Options for compute_mesh_distances. +/// +struct MeshDistancesOptions +{ + /// Name of the output per-vertex scalar attribute written to the source mesh. + std::string output_attribute_name = "@distance_to_mesh"; +}; + +/// +/// Compute the distance from each vertex in @p source to the closest point on @p target, +/// and store the result as a per-vertex scalar attribute on @p source. +/// +/// Both meshes must have the same spatial dimension. @p target must be a triangle mesh. +/// +/// @param[in,out] source Mesh whose vertices are queried. The output attribute is added here. +/// @param[in] target Triangle mesh against which distances are computed. +/// @param[in] options Options controlling the name of the output attribute. +/// +/// @return AttributeId of the newly created (or overwritten) distance attribute on @p source. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_BVH_API AttributeId compute_mesh_distances( + SurfaceMesh& source, + const SurfaceMesh& target, + const MeshDistancesOptions& options = {}); + +/// +/// Compute the symmetric Hausdorff distance between @p source and @p target. +/// +/// The Hausdorff distance is the maximum of the two directed Hausdorff distances: +/// @f[ +/// H(A, B) = \max\!\left( +/// \max_{a \in A} \mathrm{dist}(a, B),\; +/// \max_{b \in B} \mathrm{dist}(b, A) +/// \right) +/// @f] +/// where @f$ \mathrm{dist}(v, M) @f$ is the distance from vertex @f$ v @f$ to the closest +/// point on mesh @f$ M @f$. +/// +/// Both meshes must have the same spatial dimension and must be triangle meshes. +/// +/// @param[in] source First mesh. +/// @param[in] target Second mesh. +/// +/// @return Hausdorff distance. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_BVH_API Scalar compute_hausdorff( + const SurfaceMesh& source, + const SurfaceMesh& target); + +/// +/// Compute the Chamfer distance between @p source and @p target. +/// +/// The Chamfer distance is defined as: +/// @f[ +/// C(A, B) = \frac{1}{|A|} \sum_{a \in A} \mathrm{dist}(a, B)^2 +/// + \frac{1}{|B|} \sum_{b \in B} \mathrm{dist}(b, A)^2 +/// @f] +/// where @f$ \mathrm{dist}(v, M) @f$ is the distance from vertex @f$ v @f$ to the closest +/// point on mesh @f$ M @f$. +/// +/// Both meshes must have the same spatial dimension and must be triangle meshes. +/// +/// @param[in] source First mesh. +/// @param[in] target Second mesh. +/// +/// @return Chamfer distance. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_BVH_API Scalar +compute_chamfer(const SurfaceMesh& source, const SurfaceMesh& target); + +/// @} + +} // namespace lagrange::bvh diff --git a/modules/bvh/python/src/bvh.cpp b/modules/bvh/python/src/bvh.cpp index af607c83..87e83d17 100644 --- a/modules/bvh/python/src/bvh.cpp +++ b/modules/bvh/python/src/bvh.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -369,6 +370,75 @@ void populate_bvh_module(nb::module_& m) - A NumPy array representing the closest point on the element. - The squared distance between the query point and the closest point.)"); + m.def( + "compute_mesh_distances", + [](MeshType& source, const MeshType& target, const std::string& output_attribute_name) { + bvh::MeshDistancesOptions opts; + opts.output_attribute_name = output_attribute_name; + return bvh::compute_mesh_distances(source, target, opts); + }, + "source"_a, + "target"_a, + "output_attribute_name"_a = std::string(bvh::MeshDistancesOptions{}.output_attribute_name), + R"(Compute the distance from each vertex in source to the closest point on target. + +The result is stored as a per-vertex scalar attribute on source. +Both meshes must have the same spatial dimension and target must be a triangle mesh. + +:param source: Mesh whose vertices are queried. The output attribute is added here. +:param target: Triangle mesh against which distances are computed. +:param output_attribute_name: Name of the output per-vertex attribute. + +:return: AttributeId of the newly created (or overwritten) distance attribute on source.)"); + + m.def( + "compute_hausdorff", + &bvh::compute_hausdorff, + "source"_a, + "target"_a, + R"(Compute the symmetric Hausdorff distance between two meshes. + +The Hausdorff distance is the maximum of the two directed Hausdorff distances: +.. math:: + + H(A, B) = \max \left( + \max_{a \in A} \text{dist}(a, B), \quad + \max_{b \in B} \text{dist}(b, A) + \right) + +where dist(v, M) is the distance from vertex v to the closest point on mesh M. + +Both meshes must have the same spatial dimension and must be triangle meshes. + +:param source: First mesh. +:param target: Second mesh. + +:return: Hausdorff distance.)"); + + m.def( + "compute_chamfer", + &bvh::compute_chamfer, + "source"_a, + "target"_a, + R"(Compute the Chamfer distance between two meshes. + +The Chamfer distance is defined as: +.. math:: + + \begin{aligned} + C(A, B) = &\frac{1}{\|A\|} \sum_{a \in A} \text{dist}(a, B)^2 \\ + &+ \frac{1}{\|B\|} \sum_{b \in B} \text{dist}(b, A)^2 + \end{aligned} + +where dist(v, M) is the distance from vertex v to the closest point on mesh M. + +Both meshes must have the same spatial dimension and must be triangle meshes. + +:param source: First mesh. +:param target: Second mesh. + +:return: Chamfer distance.)"); + m.def( "weld_vertices", [](MeshType& mesh, Scalar radius, bool boundary_only) { diff --git a/modules/bvh/python/tests/test_EdgeAABBTree.py b/modules/bvh/python/tests/test_EdgeAABBTree.py index 8f704bd5..ccbaa53d 100644 --- a/modules/bvh/python/tests/test_EdgeAABBTree.py +++ b/modules/bvh/python/tests/test_EdgeAABBTree.py @@ -13,7 +13,7 @@ import numpy as np import pytest -from .asset import cube, square +from .asset import cube, square # noqa: F401 class TestEdgeAABBTree: diff --git a/modules/bvh/python/tests/test_TriangleAABBTree.py b/modules/bvh/python/tests/test_TriangleAABBTree.py index b4343614..14510b27 100644 --- a/modules/bvh/python/tests/test_TriangleAABBTree.py +++ b/modules/bvh/python/tests/test_TriangleAABBTree.py @@ -13,7 +13,7 @@ import numpy as np import pytest -from .asset import cube, square +from .asset import cube, square # noqa: F401 class TestTriangleAABBTree: diff --git a/modules/bvh/python/tests/test_compute_mesh_distances.py b/modules/bvh/python/tests/test_compute_mesh_distances.py new file mode 100644 index 00000000..06a81281 --- /dev/null +++ b/modules/bvh/python/tests/test_compute_mesh_distances.py @@ -0,0 +1,162 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +import lagrange +import numpy as np +import pytest + +from .asset import cube # noqa: F401 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_parallel_squares(d: float): + """Two axis-aligned unit squares at z=0 and z=d, each made of 2 triangles. + Every vertex of sq_a is directly below a vertex of sq_b, so the + closest-point distance from each vertex to the opposite mesh is exactly d. + """ + sq_a = lagrange.SurfaceMesh() + sq_a.vertices = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32) + sq_a.facets = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.uint32) + + sq_b = lagrange.SurfaceMesh() + sq_b.vertices = np.array([[0, 0, d], [1, 0, d], [1, 1, d], [0, 1, d]], dtype=np.float32) + sq_b.facets = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.uint32) + + return sq_a, sq_b + + +def make_empty_mesh(): + """Triangle mesh with no vertices and no faces.""" + return lagrange.SurfaceMesh() + + +class TestComputeMeshDistances: + def test_self_all_distances_zero(self, cube): + attr_id = lagrange.bvh.compute_mesh_distances(cube, cube) + dist = cube.attribute(attr_id).data + assert len(dist) == cube.num_vertices + assert np.allclose(dist, 0.0, atol=1e-5) + + def test_parallel_squares_all_distances_equal_d(self): + d = 1.5 + sq_a, sq_b = make_parallel_squares(d) + attr_id = lagrange.bvh.compute_mesh_distances(sq_a, sq_b) + dist = sq_a.attribute(attr_id).data + assert len(dist) == sq_a.num_vertices + assert np.allclose(dist, d, atol=1e-5) + + def test_custom_attribute_name(self): + sq_a, sq_b = make_parallel_squares(1.0) + lagrange.bvh.compute_mesh_distances(sq_a, sq_b, output_attribute_name="my_dist") + assert sq_a.has_attribute("my_dist") + assert not sq_a.has_attribute("@distance_to_mesh") + + def test_default_attribute_name(self): + sq_a, sq_b = make_parallel_squares(1.0) + lagrange.bvh.compute_mesh_distances(sq_a, sq_b) + assert sq_a.has_attribute("@distance_to_mesh") + + def test_empty_source_no_crash(self): + _, sq_b = make_parallel_squares(1.0) + empty = make_empty_mesh() + attr_id = lagrange.bvh.compute_mesh_distances(empty, sq_b) + dist = empty.attribute(attr_id).data + assert empty.num_vertices == 0 + assert len(dist) == 0 + + def test_empty_target_all_distances_zero(self): + sq_a, _ = make_parallel_squares(1.0) + empty = make_empty_mesh() + attr_id = lagrange.bvh.compute_mesh_distances(sq_a, empty) + dist = sq_a.attribute(attr_id).data + assert np.allclose(dist, 0.0, atol=1e-6) + + def test_both_empty_no_crash(self): + empty_src = make_empty_mesh() + empty_tgt = make_empty_mesh() + attr_id = lagrange.bvh.compute_mesh_distances(empty_src, empty_tgt) + dist = empty_src.attribute(attr_id).data + assert empty_src.num_vertices == 0 + assert len(dist) == 0 + + +class TestComputeHausdorff: + def test_self_zero(self, cube): + h = lagrange.bvh.compute_hausdorff(cube, cube) + assert h == pytest.approx(0.0, abs=1e-5) + + def test_parallel_squares_equals_d(self): + d = 2.0 + sq_a, sq_b = make_parallel_squares(d) + h = lagrange.bvh.compute_hausdorff(sq_a, sq_b) + assert h == pytest.approx(d, abs=1e-5) + + def test_symmetric(self): + sq_a, sq_b = make_parallel_squares(1.0) + h_ab = lagrange.bvh.compute_hausdorff(sq_a, sq_b) + h_ba = lagrange.bvh.compute_hausdorff(sq_b, sq_a) + assert h_ab == pytest.approx(h_ba, abs=1e-5) + + def test_empty_source_zero(self): + _, sq_b = make_parallel_squares(1.0) + empty = make_empty_mesh() + h = lagrange.bvh.compute_hausdorff(empty, sq_b) + assert h == pytest.approx(0.0, abs=1e-6) + + def test_empty_target_zero(self): + sq_a, _ = make_parallel_squares(1.0) + empty = make_empty_mesh() + h = lagrange.bvh.compute_hausdorff(sq_a, empty) + assert h == pytest.approx(0.0, abs=1e-6) + + def test_both_empty_zero(self): + h = lagrange.bvh.compute_hausdorff(make_empty_mesh(), make_empty_mesh()) + assert h == pytest.approx(0.0, abs=1e-6) + + +class TestComputeChamfer: + def test_self_zero(self, cube): + c = lagrange.bvh.compute_chamfer(cube, cube) + assert c == pytest.approx(0.0, abs=1e-5) + + def test_parallel_squares_equals_2d_squared(self): + # Every vertex of sq_a is at distance d from sq_b and vice versa. + # Chamfer = (1/4)*4*d^2 + (1/4)*4*d^2 = 2*d^2. + d = 1.5 + sq_a, sq_b = make_parallel_squares(d) + c = lagrange.bvh.compute_chamfer(sq_a, sq_b) + assert c == pytest.approx(2.0 * d * d, abs=1e-4) + + def test_symmetric(self): + sq_a, sq_b = make_parallel_squares(1.0) + c_ab = lagrange.bvh.compute_chamfer(sq_a, sq_b) + c_ba = lagrange.bvh.compute_chamfer(sq_b, sq_a) + assert c_ab == pytest.approx(c_ba, abs=1e-4) + + def test_empty_source_zero(self): + _, sq_b = make_parallel_squares(1.0) + empty = make_empty_mesh() + c = lagrange.bvh.compute_chamfer(empty, sq_b) + assert c == pytest.approx(0.0, abs=1e-6) + + def test_empty_target_zero(self): + sq_a, _ = make_parallel_squares(1.0) + empty = make_empty_mesh() + c = lagrange.bvh.compute_chamfer(sq_a, empty) + assert c == pytest.approx(0.0, abs=1e-6) + + def test_both_empty_zero(self): + c = lagrange.bvh.compute_chamfer(make_empty_mesh(), make_empty_mesh()) + assert c == pytest.approx(0.0, abs=1e-6) diff --git a/modules/bvh/python/tests/test_remove_interior_shells.py b/modules/bvh/python/tests/test_remove_interior_shells.py index b6f01daa..b7cc50b7 100644 --- a/modules/bvh/python/tests/test_remove_interior_shells.py +++ b/modules/bvh/python/tests/test_remove_interior_shells.py @@ -10,9 +10,8 @@ # governing permissions and limitations under the License. # import lagrange -import pytest -from .asset import cube +from .asset import cube # noqa: F401 class TestRemoveInteriorShells: diff --git a/modules/bvh/src/compute_mesh_distances.cpp b/modules/bvh/src/compute_mesh_distances.cpp new file mode 100644 index 00000000..5b60eed9 --- /dev/null +++ b/modules/bvh/src/compute_mesh_distances.cpp @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include +#include +#include +#include + +namespace lagrange::bvh { + +namespace { + +/// +/// Compute the distance from each vertex in @p mesh to the closest point on @p tree, +/// writing the results into the pre-allocated output span @p out_distances. +/// +/// @pre out_distances.size() == mesh.get_num_vertices() +/// +template +void compute_vertex_distances( + const SurfaceMesh& mesh, + const TriangleAABBTree& tree, + span out_distances) +{ + const Index num_vertices = mesh.get_num_vertices(); + la_debug_assert(out_distances.size() == num_vertices); + + if (tree.empty()) { + std::fill(out_distances.begin(), out_distances.end(), Scalar(0)); + return; + } + + auto vertices = vertex_view(mesh); + using RowVector = typename TriangleAABBTree::RowVectorType; + + tbb::parallel_for(Index(0), num_vertices, [&](Index vi) { + RowVector closest_pt; + Index tri_id; + Scalar sq_dist; + tree.get_closest_point(vertices.row(vi), tri_id, closest_pt, sq_dist); + out_distances[vi] = std::sqrt(sq_dist); + }); +} + +} // namespace + +template +AttributeId compute_mesh_distances( + SurfaceMesh& source, + const SurfaceMesh& target, + const MeshDistancesOptions& options) +{ + la_runtime_assert( + source.get_dimension() == target.get_dimension(), + "Source and target meshes must have the same spatial dimension."); + la_runtime_assert(target.is_triangle_mesh(), "Target mesh must be a triangle mesh."); + + const AttributeId attr_id = internal::find_or_create_attribute( + source, + options.output_attribute_name, + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1, + internal::ResetToDefault::No); + + TriangleAABBTree tree(target); + + // Write directly into the attribute buffer — no temporary vector needed. + auto& attr = source.template ref_attribute(attr_id); + compute_vertex_distances(source, tree, attr.ref_all()); + + return attr_id; +} + +template +Scalar compute_hausdorff( + const SurfaceMesh& source, + const SurfaceMesh& target) +{ + la_runtime_assert( + source.get_dimension() == target.get_dimension(), + "Source and target meshes must have the same spatial dimension."); + la_runtime_assert(source.is_triangle_mesh(), "Source mesh must be a triangle mesh."); + la_runtime_assert(target.is_triangle_mesh(), "Target mesh must be a triangle mesh."); + + // Directed source → target. + TriangleAABBTree tree_target(target); + std::vector dist_fwd(source.get_num_vertices()); + compute_vertex_distances(source, tree_target, span(dist_fwd)); + Scalar d_fwd = + dist_fwd.empty() ? Scalar(0) : *std::max_element(dist_fwd.begin(), dist_fwd.end()); + + // Directed target → source. + TriangleAABBTree tree_source(source); + std::vector dist_bwd(target.get_num_vertices()); + compute_vertex_distances(target, tree_source, span(dist_bwd)); + Scalar d_bwd = + dist_bwd.empty() ? Scalar(0) : *std::max_element(dist_bwd.begin(), dist_bwd.end()); + + return std::max(d_fwd, d_bwd); +} + +template +Scalar compute_chamfer( + const SurfaceMesh& source, + const SurfaceMesh& target) +{ + la_runtime_assert( + source.get_dimension() == target.get_dimension(), + "Source and target meshes must have the same spatial dimension."); + la_runtime_assert(source.is_triangle_mesh(), "Source mesh must be a triangle mesh."); + la_runtime_assert(target.is_triangle_mesh(), "Target mesh must be a triangle mesh."); + + // Source → target distances. + TriangleAABBTree tree_target(target); + std::vector dist_fwd(source.get_num_vertices()); + compute_vertex_distances(source, tree_target, span(dist_fwd)); + + // Target → source distances. + TriangleAABBTree tree_source(source); + std::vector dist_bwd(target.get_num_vertices()); + compute_vertex_distances(target, tree_source, span(dist_bwd)); + + auto sum_squared = [](const std::vector& d) -> Scalar { + return tbb::parallel_reduce( + tbb::blocked_range(0, d.size()), + Scalar(0), + [&](const tbb::blocked_range& r, Scalar acc) { + for (size_t i = r.begin(); i < r.end(); ++i) acc += d[i] * d[i]; + return acc; + }, + std::plus{}); + }; + + Scalar chamfer = Scalar(0); + if (!dist_fwd.empty()) chamfer += sum_squared(dist_fwd) / static_cast(dist_fwd.size()); + if (!dist_bwd.empty()) chamfer += sum_squared(dist_bwd) / static_cast(dist_bwd.size()); + + return chamfer; +} + +// Explicit instantiations. +#define LA_X_compute_mesh_distances(_, Scalar, Index) \ + template LA_BVH_API AttributeId compute_mesh_distances( \ + SurfaceMesh&, \ + const SurfaceMesh&, \ + const MeshDistancesOptions&); \ + template LA_BVH_API Scalar compute_hausdorff( \ + const SurfaceMesh&, \ + const SurfaceMesh&); \ + template LA_BVH_API Scalar compute_chamfer( \ + const SurfaceMesh&, \ + const SurfaceMesh&); +LA_SURFACE_MESH_X(compute_mesh_distances, 0) + +} // namespace lagrange::bvh diff --git a/modules/bvh/tests/test_compute_mesh_distances.cpp b/modules/bvh/tests/test_compute_mesh_distances.cpp new file mode 100644 index 00000000..9a9e63c0 --- /dev/null +++ b/modules/bvh/tests/test_compute_mesh_distances.cpp @@ -0,0 +1,334 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include + +#include + +namespace { + +using namespace lagrange; +using Scalar = float; +using Index = uint32_t; + +// Two axis-aligned unit squares at z=0 and z=d, each made of 2 triangles. +// Every vertex of sq_a is directly below a vertex of sq_b, so the +// distance from each vertex to the opposite plane is exactly d. +std::pair, SurfaceMesh> make_parallel_squares(Scalar d) +{ + SurfaceMesh sq_a; + sq_a.add_vertex({0, 0, 0}); + sq_a.add_vertex({1, 0, 0}); + sq_a.add_vertex({1, 1, 0}); + sq_a.add_vertex({0, 1, 0}); + sq_a.add_triangle(0, 1, 2); + sq_a.add_triangle(0, 2, 3); + + SurfaceMesh sq_b; + sq_b.add_vertex({0, 0, d}); + sq_b.add_vertex({1, 0, d}); + sq_b.add_vertex({1, 1, d}); + sq_b.add_vertex({0, 1, d}); + sq_b.add_triangle(0, 1, 2); + sq_b.add_triangle(0, 2, 3); + + return {sq_a, sq_b}; +} + +// Concentric unit spheres at the origin with the given radii and tessellation density. +std::pair, SurfaceMesh> +make_concentric_spheres(Scalar r1, Scalar r2, size_t num_sections = 64) +{ + primitive::SphereOptions opts; + opts.triangulate = true; + opts.num_longitude_sections = num_sections; + opts.num_latitude_sections = num_sections; + + opts.radius = r1; + auto sphere_a = primitive::generate_sphere(opts); + + opts.radius = r2; + auto sphere_b = primitive::generate_sphere(opts); + + return {sphere_a, sphere_b}; +} + +} // namespace + +// --------------------------------------------------------------------------- +// compute_mesh_distances +// --------------------------------------------------------------------------- + +TEST_CASE("compute_mesh_distances", "[bvh][mesh_distances]") +{ + using namespace lagrange; + + SECTION("self: all distances are zero") + { + // Distance from every vertex of a mesh to the mesh itself must be 0. + primitive::SphereOptions opts; + opts.radius = 1.0f; + opts.triangulate = true; + opts.num_longitude_sections = 32; + opts.num_latitude_sections = 32; + auto mesh = primitive::generate_sphere(opts); + + auto attr_id = bvh::compute_mesh_distances(mesh, mesh); + auto dist = attribute_matrix_view(mesh, attr_id); + + for (Index vi = 0; vi < mesh.get_num_vertices(); ++vi) { + REQUIRE_THAT(dist(vi, 0), Catch::Matchers::WithinAbs(0.0f, 1e-5f)); + } + } + + SECTION("parallel squares: all distances are exactly d") + { + // Exact test: every vertex of sq_a is directly below a vertex of sq_b, + // so the closest-point distance is exactly d = 1.5. + const Scalar d = 1.5f; + auto [sq_a, sq_b] = make_parallel_squares(d); + + auto attr_id = bvh::compute_mesh_distances(sq_a, sq_b); + auto dist = attribute_matrix_view(sq_a, attr_id); + + for (Index vi = 0; vi < sq_a.get_num_vertices(); ++vi) { + REQUIRE_THAT(dist(vi, 0), Catch::Matchers::WithinAbs(d, 1e-5f)); + } + } + + SECTION("custom attribute name is used") + { + auto [sq_a, sq_b] = make_parallel_squares(1.0f); + + bvh::MeshDistancesOptions opts; + opts.output_attribute_name = "my_distances"; + bvh::compute_mesh_distances(sq_a, sq_b, opts); + + REQUIRE(sq_a.has_attribute("my_distances")); + REQUIRE(!sq_a.has_attribute("@distance_to_mesh")); + } + + SECTION("empty source: attribute created with zero entries") + { + // Source has no vertices — the attribute should be created but empty. + SurfaceMesh empty_source; + auto [_, sq_b] = make_parallel_squares(1.0f); + + auto attr_id = bvh::compute_mesh_distances(empty_source, sq_b); + auto dist = attribute_vector_view(empty_source, attr_id); + REQUIRE(empty_source.get_num_vertices() == 0); + REQUIRE(dist.size() == 0); + } + + SECTION("empty target: all source distances are zero") + { + // Target has no faces — the BVH tree is empty, so every source vertex + // gets distance 0 (closest point undefined; implementation returns 0). + auto [sq_a, _] = make_parallel_squares(1.0f); + SurfaceMesh empty_target; + + auto attr_id = bvh::compute_mesh_distances(sq_a, empty_target); + auto dist = attribute_matrix_view(sq_a, attr_id); + + for (Index vi = 0; vi < sq_a.get_num_vertices(); ++vi) { + REQUIRE_THAT(dist(vi, 0), Catch::Matchers::WithinAbs(0.0f, 1e-6f)); + } + } + + SECTION("both empty: no crash, attribute created with zero entries") + { + SurfaceMesh empty_source; + SurfaceMesh empty_target; + + auto attr_id = bvh::compute_mesh_distances(empty_source, empty_target); + auto dist = attribute_vector_view(empty_source, attr_id); + REQUIRE(empty_source.get_num_vertices() == 0); + REQUIRE(dist.size() == 0); + } +} + +// --------------------------------------------------------------------------- +// compute_hausdorff +// --------------------------------------------------------------------------- + +TEST_CASE("compute_hausdorff", "[bvh][mesh_distances]") +{ + using namespace lagrange; + + SECTION("self: Hausdorff distance is zero") + { + primitive::SphereOptions opts; + opts.radius = 1.0f; + opts.triangulate = true; + opts.num_longitude_sections = 32; + opts.num_latitude_sections = 32; + auto mesh = primitive::generate_sphere(opts); + + Scalar h = bvh::compute_hausdorff(mesh, mesh); + REQUIRE_THAT(h, Catch::Matchers::WithinAbs(0.0f, 1e-5f)); + } + + SECTION("parallel squares: Hausdorff = d (exact)") + { + // All 4 vertices of sq_a are at distance d from sq_b and vice versa, + // so the symmetric Hausdorff distance equals d exactly. + const Scalar d = 2.0f; + auto [sq_a, sq_b] = make_parallel_squares(d); + + Scalar h = bvh::compute_hausdorff(sq_a, sq_b); + REQUIRE_THAT(h, Catch::Matchers::WithinAbs(d, 1e-5f)); + } + + SECTION("concentric spheres: Hausdorff ≈ r2 - r1") + { + // Analytical value: every point on the inner sphere is at distance + // r2 - r1 from the outer sphere (and vice versa). The tessellated + // mesh approximates this; 64×64 sections give <0.1 % error. + const Scalar r1 = 1.0f, r2 = 2.0f; + const Scalar expected = r2 - r1; // = 1.0 + + auto [sphere_a, sphere_b] = make_concentric_spheres(r1, r2); + + Scalar h = bvh::compute_hausdorff(sphere_a, sphere_b); + REQUIRE_THAT(h, Catch::Matchers::WithinRel(expected, 0.02f)); // 2 % tolerance + } + + SECTION("symmetry: Hausdorff(A,B) == Hausdorff(B,A)") + { + auto [sphere_a, sphere_b] = make_concentric_spheres(1.0f, 1.5f); + + Scalar h_ab = bvh::compute_hausdorff(sphere_a, sphere_b); + Scalar h_ba = bvh::compute_hausdorff(sphere_b, sphere_a); + REQUIRE_THAT(h_ab, Catch::Matchers::WithinAbs(h_ba, 1e-5f)); + } + + SECTION("empty source: Hausdorff is zero") + { + // No forward distances (empty source) and the backward tree is empty + // (source has no faces), so both directed distances are 0. + SurfaceMesh empty_source; + auto [sq_b, _] = make_parallel_squares(1.0f); + + Scalar h = bvh::compute_hausdorff(empty_source, sq_b); + REQUIRE_THAT(h, Catch::Matchers::WithinAbs(0.0f, 1e-6f)); + } + + SECTION("empty target: Hausdorff is zero") + { + // Forward tree is empty → all source distances are 0. + // No backward distances (empty target). + auto [sq_a, _] = make_parallel_squares(1.0f); + SurfaceMesh empty_target; + + Scalar h = bvh::compute_hausdorff(sq_a, empty_target); + REQUIRE_THAT(h, Catch::Matchers::WithinAbs(0.0f, 1e-6f)); + } + + SECTION("both empty: Hausdorff is zero") + { + SurfaceMesh empty_a; + SurfaceMesh empty_b; + + Scalar h = bvh::compute_hausdorff(empty_a, empty_b); + REQUIRE_THAT(h, Catch::Matchers::WithinAbs(0.0f, 1e-6f)); + } +} + +// --------------------------------------------------------------------------- +// compute_chamfer +// --------------------------------------------------------------------------- + +TEST_CASE("compute_chamfer", "[bvh][mesh_distances]") +{ + using namespace lagrange; + + SECTION("self: Chamfer distance is zero") + { + primitive::SphereOptions opts; + opts.radius = 1.0f; + opts.triangulate = true; + opts.num_longitude_sections = 32; + opts.num_latitude_sections = 32; + auto mesh = primitive::generate_sphere(opts); + + Scalar c = bvh::compute_chamfer(mesh, mesh); + REQUIRE_THAT(c, Catch::Matchers::WithinAbs(0.0f, 1e-5f)); + } + + SECTION("parallel squares: Chamfer = 2 * d^2 (exact)") + { + // Every vertex of sq_a is at distance d from sq_b, and every vertex + // of sq_b is at distance d from sq_a. + // Chamfer = (1/4)*4*d^2 + (1/4)*4*d^2 = 2*d^2. + const Scalar d = 1.5f; + auto [sq_a, sq_b] = make_parallel_squares(d); + + Scalar c = bvh::compute_chamfer(sq_a, sq_b); + REQUIRE_THAT(c, Catch::Matchers::WithinAbs(2.0f * d * d, 1e-4f)); + } + + SECTION("concentric spheres: Chamfer ≈ 2 * (r2 - r1)^2") + { + // Analytical value: every vertex contributes (r2-r1)^2 to the + // mean squared distance, giving Chamfer = 2*(r2-r1)^2. + const Scalar r1 = 1.0f, r2 = 2.0f; + const Scalar expected = 2.0f * (r2 - r1) * (r2 - r1); // = 2.0 + + auto [sphere_a, sphere_b] = make_concentric_spheres(r1, r2); + + Scalar c = bvh::compute_chamfer(sphere_a, sphere_b); + REQUIRE_THAT(c, Catch::Matchers::WithinRel(expected, 0.02f)); // 2 % tolerance + } + + SECTION("symmetry: Chamfer(A,B) == Chamfer(B,A)") + { + auto [sphere_a, sphere_b] = make_concentric_spheres(1.0f, 1.5f); + + Scalar c_ab = bvh::compute_chamfer(sphere_a, sphere_b); + Scalar c_ba = bvh::compute_chamfer(sphere_b, sphere_a); + REQUIRE_THAT(c_ab, Catch::Matchers::WithinAbs(c_ba, 1e-4f)); + } + + SECTION("empty source: Chamfer is zero") + { + // No forward distances (empty source), backward tree is empty + // (source has no faces) → both sums are 0. + SurfaceMesh empty_source; + auto [sq_b, _] = make_parallel_squares(1.0f); + + Scalar c = bvh::compute_chamfer(empty_source, sq_b); + REQUIRE_THAT(c, Catch::Matchers::WithinAbs(0.0f, 1e-6f)); + } + + SECTION("empty target: Chamfer is zero") + { + // Forward tree is empty → all source distances are 0 → forward sum is 0. + // No backward distances (empty target). + auto [sq_a, _] = make_parallel_squares(1.0f); + SurfaceMesh empty_target; + + Scalar c = bvh::compute_chamfer(sq_a, empty_target); + REQUIRE_THAT(c, Catch::Matchers::WithinAbs(0.0f, 1e-6f)); + } + + SECTION("both empty: Chamfer is zero") + { + SurfaceMesh empty_a; + SurfaceMesh empty_b; + + Scalar c = bvh::compute_chamfer(empty_a, empty_b); + REQUIRE_THAT(c, Catch::Matchers::WithinAbs(0.0f, 1e-6f)); + } +} diff --git a/modules/core/include/lagrange/Attribute.h b/modules/core/include/lagrange/Attribute.h index 55d38f5c..81d6b20c 100644 --- a/modules/core/include/lagrange/Attribute.h +++ b/modules/core/include/lagrange/Attribute.h @@ -565,6 +565,14 @@ class Attribute : public AttributeBase /// lagrange::span get_all() const; + /// + /// Returns a read-only view of the full allocated buffer, including any padding entries added + /// via reserve_entries(). For external attributes, this is equivalent to get_all(). + /// + /// @return A read-only view of the full attribute buffer including padding. + /// + lagrange::span get_all_with_padding() const; + /// /// Returns a writable view of the buffer spanning num elements x num channels. The actual /// buffer may have a larger capacity (e.g. used for padding). diff --git a/modules/core/include/lagrange/AttributeFwd.h b/modules/core/include/lagrange/AttributeFwd.h index e5b3def0..97c7fb8b 100644 --- a/modules/core/include/lagrange/AttributeFwd.h +++ b/modules/core/include/lagrange/AttributeFwd.h @@ -194,6 +194,21 @@ enum class AttributeReorientPolicy : uint8_t { Reorient, ///< Reorient attribute values when flipping mesh facets. }; +/// +/// Policy for retrieving writable references to attribute buffers. By default, it is not allowed to +/// get a writable reference to the corner -> vertex id attribute when a mesh has edge/connectivity +/// information. This policy can be used to change that. +/// +enum class AttributeRefPolicy : uint8_t { + /// Throws an exception if the attribute is the corner -> vertex id attribute and the mesh has + /// edge/connectivity information. + Default, + + /// Allows retrieving writable references even if the attribute is the corner -> vertex id + /// attribute and the mesh has edge/connectivity information. + Force, +}; + /// /// Base handle for attributes. This is a common base class to allow for type erasure. /// diff --git a/modules/core/include/lagrange/SurfaceMesh.h b/modules/core/include/lagrange/SurfaceMesh.h index b6a27fb7..d60b53d4 100644 --- a/modules/core/include/lagrange/SurfaceMesh.h +++ b/modules/core/include/lagrange/SurfaceMesh.h @@ -1927,14 +1927,17 @@ class SurfaceMesh /// /// Gets a writable reference to the corner -> vertex id attribute. /// - /// @warning If the mesh contains edge/connectivity attributes, this function will throw an - /// exception. This is because it is impossible to update edge/connectivity + /// @warning If the mesh contains edge/connectivity attributes, by default this function will + /// throw an exception. This is because it is impossible to update edge/connectivity /// information if the facet buffer is directly modified by the user. Instead, the /// correct facet indices must be provided when the facet is constructed. /// + /// @param[in] policy Policy for retrieving writable references to attribute buffers. + /// /// @return Vertex indices attribute. /// - [[nodiscard]] Attribute& ref_corner_to_vertex(); + [[nodiscard]] Attribute& ref_corner_to_vertex( + AttributeRefPolicy policy = AttributeRefPolicy::Default); public: /// @} diff --git a/modules/core/include/lagrange/internal/SafeVector.h b/modules/core/include/lagrange/internal/SafeVector.h index 700873fa..9ced52bb 100644 --- a/modules/core/include/lagrange/internal/SafeVector.h +++ b/modules/core/include/lagrange/internal/SafeVector.h @@ -98,9 +98,10 @@ class SharedPtrVector : public std::vector> void push_back(const T& value) { Super::emplace_back(std::make_shared(value)); } void push_back(T&& value) { Super::push_back(std::make_shared(std::move(value))); } template - void emplace_back(Args&&... args) + T& emplace_back(Args&&... args) { Super::push_back(std::make_shared(std::forward(args)...)); + return back(); } const T& back() const { return *Super::back().get(); } T& back() { return *Super::back().get(); } diff --git a/modules/core/include/lagrange/internal/find_attribute_utils.h b/modules/core/include/lagrange/internal/find_attribute_utils.h index 6e071235..26087648 100644 --- a/modules/core/include/lagrange/internal/find_attribute_utils.h +++ b/modules/core/include/lagrange/internal/find_attribute_utils.h @@ -15,12 +15,50 @@ #include #include +#include #include namespace lagrange::internal { +/// Whether to reset the attribute values to default when an existing attribute is found enum class ResetToDefault { Yes, No }; +/// Whether the attribute should be writable. +enum class ShouldBeWritable { Yes, No }; + +/// Result of a check_attribute call. +struct CheckAttributeResult +{ + bool success = true; ///< Whether the check passed. + std::string msg; ///< Error message if the check failed. +}; + +/// +/// Check that an attribute matches the expected value type, element type, usage and number of +/// channels. Optionally verify that the attribute is writable. +/// +/// @param mesh Mesh where to look for the attribute. +/// @param[in] id Attribute id to check. +/// @param[in] expected_element Expected element type. +/// @param[in] expected_usage Expected attribute usage. +/// @param[in] expected_channels Expected number of channels. If 0, then the check is skipped. +/// @param[in] expected_writable Whether the attribute should be writable. +/// +/// @tparam ExpectedValueType Expected attribute value type. +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return A CheckAttributeResult indicating success or failure with an error message. +/// +template +CheckAttributeResult check_attribute( + const SurfaceMesh& mesh, + AttributeId id, + BitField expected_element, + AttributeUsage expected_usage, + size_t expected_channels, + ShouldBeWritable expected_writable = ShouldBeWritable::No); + /// /// Find an attribute with a given name, ensuring the usage and element type match an expected /// target. If the provided name is empty, the first attribute with matching properties is returned. diff --git a/modules/core/include/lagrange/internal/get_unique_attribute_name.h b/modules/core/include/lagrange/internal/get_unique_attribute_name.h new file mode 100644 index 00000000..352d8a78 --- /dev/null +++ b/modules/core/include/lagrange/internal/get_unique_attribute_name.h @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +#include + +namespace lagrange::internal { + +/// +/// Returns a unique attribute name by appending a suffix if necessary. If the input name does not +/// exist in the mesh, it is returned as is. If it already exists, a suffix of the form ".0", ".1", +/// etc. is appended until a unique name is found. If a unique name cannot be found after 1000 +/// attempts, an error is thrown. +/// +/// @param mesh The mesh to check for existing attribute names. +/// @param name The desired attribute name. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return A unique attribute name. +/// +template +std::string get_unique_attribute_name(const SurfaceMesh& mesh, std::string_view name) +{ + if (!mesh.has_attribute(name)) { + return std::string(name); + } else { + std::string new_name; + for (int cnt = 0; cnt < 1000; ++cnt) { + new_name = fmt::format("{}.{}", name, cnt); + if (!mesh.has_attribute(new_name)) { + logger().warn("Attribute '{}' already exists. Using '{}' instead.", name, new_name); + return new_name; + } + } + throw Error(fmt::format("Could not assign a unique attribute name for: {}", name)); + } +} + +} // namespace lagrange::internal diff --git a/modules/core/include/lagrange/mesh_convert.impl.h b/modules/core/include/lagrange/mesh_convert.impl.h index 8582d168..b046c654 100644 --- a/modules/core/include/lagrange/mesh_convert.impl.h +++ b/modules/core/include/lagrange/mesh_convert.impl.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -141,23 +142,8 @@ SurfaceMesh to_surface_mesh_internal(InputMeshType&& mesh) return AttributeUsage::Vector; }; - auto get_unique_name = [&](auto name) -> std::string { - if (!new_mesh.has_attribute(name)) { - return name; - } else { - std::string new_name; - for (int cnt = 0; cnt < 1000; ++cnt) { - new_name = fmt::format("{}.{}", name, cnt); - if (!new_mesh.has_attribute(new_name)) { - return new_name; - } - } - throw Error(fmt::format("Could not assign a unique attribute name for: {}", name)); - } - }; - auto transfer_attribute = [&](auto&& name, auto&& array, AttributeElement elem) { - std::string new_name = get_unique_name(name); + std::string new_name = internal::get_unique_attribute_name(new_mesh, name); decltype(auto) attr = array->template get(); if constexpr (policy == Policy::Copy) { new_mesh.template create_attribute( @@ -211,7 +197,7 @@ SurfaceMesh to_surface_mesh_internal(InputMeshType&& mesh) decltype(auto) attr = mesh.get_indexed_attribute_array(name); decltype(auto) values = std::get<0>(attr)->template get(); decltype(auto) indices = std::get<1>(attr)->template get(); - std::string new_name = get_unique_name(name); + std::string new_name = internal::get_unique_attribute_name(new_mesh, name); if constexpr (policy == Policy::Wrap) { static_assert(std::is_same_v, "Mesh attribute index type mismatch"); diff --git a/modules/core/python/src/core.cpp b/modules/core/python/src/core.cpp index f40ff400..f973acdb 100644 --- a/modules/core/python/src/core.cpp +++ b/modules/core/python/src/core.cpp @@ -30,7 +30,7 @@ void populate_core_module(nb::module_& m) using Scalar = double; using Index = uint32_t; - register_python_logger(); + register_python_logger(m); m.attr("invalid_scalar") = lagrange::invalid(); m.attr("invalid_index") = nb::int_(lagrange::invalid()); diff --git a/modules/core/python/src/logging.cpp b/modules/core/python/src/logging.cpp index ff6b6dd1..4de5cb50 100644 --- a/modules/core/python/src/logging.cpp +++ b/modules/core/python/src/logging.cpp @@ -23,29 +23,37 @@ #include +namespace nb = nanobind; + namespace lagrange::python { +namespace { + class PythonLoggingSink : public spdlog::sinks::base_sink { +public: + PythonLoggingSink(std::string logger_name) + { + nb::module_ logging = nb::module_::import_("logging"); + m_py_logger = logging.attr("getLogger")(logger_name); + } + protected: void sink_it_(const spdlog::details::log_msg& msg) override { // Logging in python requires the current thread to hold the GIL. if (!PyGILState_Check()) return; - namespace nb = nanobind; auto payload = msg.payload; auto res = nb::str(payload.data(), payload.size()); - nb::module_ logging = nb::module_::import_("logging"); - auto py_logger = logging.attr("getLogger")("lagrange"); switch (msg.level) { - case spdlog::level::trace: py_logger.attr("debug")(res); break; - case spdlog::level::debug: py_logger.attr("debug")(res); break; - case spdlog::level::info: py_logger.attr("info")(res); break; - case spdlog::level::warn: py_logger.attr("warning")(res); break; - case spdlog::level::err: py_logger.attr("error")(res); break; - case spdlog::level::critical: py_logger.attr("critical")(res); break; + case spdlog::level::trace: m_py_logger.attr("debug")(res); break; + case spdlog::level::debug: m_py_logger.attr("debug")(res); break; + case spdlog::level::info: m_py_logger.attr("info")(res); break; + case spdlog::level::warn: m_py_logger.attr("warning")(res); break; + case spdlog::level::err: m_py_logger.attr("error")(res); break; + case spdlog::level::critical: m_py_logger.attr("critical")(res); break; case spdlog::level::off: break; default: break; } @@ -56,21 +64,24 @@ class PythonLoggingSink : public spdlog::sinks::base_sink // Logging in python requires the current thread to hold the GIL. if (!PyGILState_Check()) return; - namespace nb = nanobind; - nb::module_ logging = nb::module_::import_("logging"); - auto py_logger = logging.attr("getLogger")("lagrange"); - auto handlers = py_logger.attr("handlers"); + auto handlers = m_py_logger.attr("handlers"); for (auto handler : handlers) { handler.attr("flush")(); } } + +private: + nanobind::object m_py_logger; }; -void register_python_logger() +} // namespace + +void register_python_logger(nanobind::module_& m) { auto& logger = lagrange::logger(); + std::string logger_name = nb::cast(m.attr("__name__")); logger.sinks().clear(); - logger.sinks().emplace_back(std::make_shared()); + logger.sinks().emplace_back(std::make_shared(std::move(logger_name))); logger.set_level(spdlog::level::trace); // Log level will be controlled by Python. } diff --git a/modules/core/python/src/logging.h b/modules/core/python/src/logging.h index d39bbf25..2fea3c65 100644 --- a/modules/core/python/src/logging.h +++ b/modules/core/python/src/logging.h @@ -11,11 +11,13 @@ */ #pragma once +#include + namespace lagrange::python { /// -/// Registers a Python logging sink for lagrange::Logger. +/// Registers a Python logging sink for the module m. /// -void register_python_logger(); +void register_python_logger(nanobind::module_& m); } // namespace lagrange::python diff --git a/modules/core/python/tests/assets.py b/modules/core/python/tests/assets.py index c19dbf71..a3678fbf 100644 --- a/modules/core/python/tests/assets.py +++ b/modules/core/python/tests/assets.py @@ -131,7 +131,7 @@ def cube_triangular(): @pytest.fixture def cube_with_uv(cube): mesh = cube - id = mesh.create_attribute( + mesh.create_attribute( "uv", lagrange.AttributeElement.Indexed, lagrange.AttributeUsage.UV, diff --git a/modules/core/python/tests/test_attribute.py b/modules/core/python/tests/test_attribute.py index 8e4ab233..fccff5a0 100644 --- a/modules/core/python/tests/test_attribute.py +++ b/modules/core/python/tests/test_attribute.py @@ -15,7 +15,7 @@ import pytest import sys -from .assets import single_triangle, single_triangle_with_index, cube +from .assets import single_triangle, single_triangle_with_index, cube # noqa: F401 from .utils import address, assert_sharing_raw_data @@ -121,8 +121,8 @@ def test_delete_attribute(self, single_triangle_with_index): mesh.delete_attribute("vertex_index") # `attr` is no longer valid. - with pytest.raises(RuntimeError) as e: - num_channels = attr.num_channels + with pytest.raises(RuntimeError): + attr.num_channels == 1 def test_delete_attribute_with_wrap(self, single_triangle_with_index): data = np.array([-1, 1, 0], dtype=np.intc) diff --git a/modules/core/python/tests/test_cast_attribute.py b/modules/core/python/tests/test_cast_attribute.py index ed631379..cd1054e8 100644 --- a/modules/core/python/tests/test_cast_attribute.py +++ b/modules/core/python/tests/test_cast_attribute.py @@ -12,7 +12,7 @@ import lagrange import numpy as np -from .assets import single_triangle +from .assets import single_triangle # noqa: F401 class TestCastAttribute: diff --git a/modules/core/python/tests/test_close_small_holes.py b/modules/core/python/tests/test_close_small_holes.py index a4a032f7..5c1ab613 100644 --- a/modules/core/python/tests/test_close_small_holes.py +++ b/modules/core/python/tests/test_close_small_holes.py @@ -11,7 +11,7 @@ # import lagrange -from .assets import cube, cube_with_uv +from .assets import cube, cube_with_uv # noqa: F401 class TestCloseSmallHoles: diff --git a/modules/core/python/tests/test_combine_meshes.py b/modules/core/python/tests/test_combine_meshes.py index 1248af94..f5892e69 100644 --- a/modules/core/python/tests/test_combine_meshes.py +++ b/modules/core/python/tests/test_combine_meshes.py @@ -12,9 +12,8 @@ import lagrange import numpy as np -import pytest -from .assets import cube +from .assets import cube # noqa: F401 class TestCombineMeshes: diff --git a/modules/core/python/tests/test_compute_centroid.py b/modules/core/python/tests/test_compute_centroid.py index 0221999b..e76a1266 100644 --- a/modules/core/python/tests/test_compute_centroid.py +++ b/modules/core/python/tests/test_compute_centroid.py @@ -12,11 +12,9 @@ import lagrange import numpy as np -from numpy.linalg import norm -import math import pytest -from .assets import single_triangle, cube +from .assets import single_triangle, cube # noqa: F401 class TestComputeCentroid: @@ -33,4 +31,4 @@ def test_triangle(self, single_triangle): def test_empty_mesh(self): mesh = lagrange.SurfaceMesh() with pytest.raises(Exception): - c = lagrange.compute_mesh_centroid(mesh) + lagrange.compute_mesh_centroid(mesh) diff --git a/modules/core/python/tests/test_compute_components.py b/modules/core/python/tests/test_compute_components.py index ab23c3a5..fe78c5ae 100644 --- a/modules/core/python/tests/test_compute_components.py +++ b/modules/core/python/tests/test_compute_components.py @@ -12,7 +12,7 @@ import lagrange import numpy as np -from .assets import cube +from .assets import cube # noqa: F401 class TestComputeComponents: diff --git a/modules/core/python/tests/test_compute_dihedral_angles.py b/modules/core/python/tests/test_compute_dihedral_angles.py index 76946883..2ae8d49a 100644 --- a/modules/core/python/tests/test_compute_dihedral_angles.py +++ b/modules/core/python/tests/test_compute_dihedral_angles.py @@ -14,7 +14,7 @@ import numpy as np import pytest -from .assets import single_triangle, cube +from .assets import single_triangle, cube # noqa: F401 class TestComputeDihedralAngles: diff --git a/modules/core/python/tests/test_compute_dijkstra_distance.py b/modules/core/python/tests/test_compute_dijkstra_distance.py index a58e6016..5b5c2e5e 100644 --- a/modules/core/python/tests/test_compute_dijkstra_distance.py +++ b/modules/core/python/tests/test_compute_dijkstra_distance.py @@ -11,10 +11,8 @@ # import lagrange -import numpy as np -import pytest -from .assets import single_triangle, cube +from .assets import single_triangle, cube # noqa: F401 class TestComputeDijkstraDistance: diff --git a/modules/core/python/tests/test_compute_edge_lengths.py b/modules/core/python/tests/test_compute_edge_lengths.py index c080df16..ab95b3be 100644 --- a/modules/core/python/tests/test_compute_edge_lengths.py +++ b/modules/core/python/tests/test_compute_edge_lengths.py @@ -14,7 +14,7 @@ import numpy as np import pytest -from .assets import cube +from .assets import cube # noqa: F401 class TestComputeEdgeLengths: diff --git a/modules/core/python/tests/test_compute_facet_area.py b/modules/core/python/tests/test_compute_facet_area.py index 9423539b..86d5a4b5 100644 --- a/modules/core/python/tests/test_compute_facet_area.py +++ b/modules/core/python/tests/test_compute_facet_area.py @@ -12,11 +12,10 @@ import lagrange import numpy as np -from numpy.linalg import norm import math import pytest -from .assets import single_triangle, cube, single_triangle_with_uv +from .assets import single_triangle, cube, single_triangle_with_uv # noqa: F401 class TestComputeFacetArea: diff --git a/modules/core/python/tests/test_compute_facet_circumcenter.py b/modules/core/python/tests/test_compute_facet_circumcenter.py index 76fc9f96..93897924 100644 --- a/modules/core/python/tests/test_compute_facet_circumcenter.py +++ b/modules/core/python/tests/test_compute_facet_circumcenter.py @@ -13,7 +13,7 @@ import numpy as np -from .assets import cube_triangular +from .assets import cube_triangular # noqa: F401 class TestComputeFacetCircumcenter: diff --git a/modules/core/python/tests/test_compute_facet_normal.py b/modules/core/python/tests/test_compute_facet_normal.py index 83e74634..6c725f78 100644 --- a/modules/core/python/tests/test_compute_facet_normal.py +++ b/modules/core/python/tests/test_compute_facet_normal.py @@ -12,11 +12,9 @@ import lagrange import numpy as np -from numpy.linalg import norm -import math import pytest -from .assets import single_triangle, cube +from .assets import single_triangle, cube # noqa: F401 class TestComputeFacetNormal: diff --git a/modules/core/python/tests/test_compute_mesh_covariance.py b/modules/core/python/tests/test_compute_mesh_covariance.py index fb97ae34..6b3662ec 100644 --- a/modules/core/python/tests/test_compute_mesh_covariance.py +++ b/modules/core/python/tests/test_compute_mesh_covariance.py @@ -11,9 +11,8 @@ # import lagrange import numpy as np -import pytest -from .assets import cube, single_triangle +from .assets import cube, single_triangle # noqa: F401 class TestComputeMeshCovariance: diff --git a/modules/core/python/tests/test_compute_normal.py b/modules/core/python/tests/test_compute_normal.py index c9686c15..fb131b4c 100644 --- a/modules/core/python/tests/test_compute_normal.py +++ b/modules/core/python/tests/test_compute_normal.py @@ -12,11 +12,10 @@ import lagrange import numpy as np -from numpy.linalg import norm import math import pytest -from .assets import single_triangle, cube +from .assets import single_triangle, cube # noqa: F401 class TestComputeNormal: diff --git a/modules/core/python/tests/test_compute_pointcloud_pca.py b/modules/core/python/tests/test_compute_pointcloud_pca.py index ea378d79..3919f587 100644 --- a/modules/core/python/tests/test_compute_pointcloud_pca.py +++ b/modules/core/python/tests/test_compute_pointcloud_pca.py @@ -11,7 +11,6 @@ # import lagrange import numpy as np -import pytest a = 0.1 b = 0.4 diff --git a/modules/core/python/tests/test_compute_seam_edges.py b/modules/core/python/tests/test_compute_seam_edges.py index e695a2fb..77ca82f3 100644 --- a/modules/core/python/tests/test_compute_seam_edges.py +++ b/modules/core/python/tests/test_compute_seam_edges.py @@ -12,9 +12,8 @@ import lagrange import numpy as np -import pytest -from .assets import cube_with_uv, cube +from .assets import cube_with_uv, cube # noqa: F401 class TestComputeCentroid: diff --git a/modules/core/python/tests/test_compute_tangent_bitangent.py b/modules/core/python/tests/test_compute_tangent_bitangent.py index 4a5ef65f..1d51db32 100644 --- a/modules/core/python/tests/test_compute_tangent_bitangent.py +++ b/modules/core/python/tests/test_compute_tangent_bitangent.py @@ -11,10 +11,9 @@ # import lagrange -import numpy as np import pytest -from .assets import single_triangle, single_triangle_with_uv, cube, cube_with_uv +from .assets import single_triangle, single_triangle_with_uv, cube, cube_with_uv # noqa: F401 class TestComputeTangentBitangent: diff --git a/modules/core/python/tests/test_compute_vertex_normal.py b/modules/core/python/tests/test_compute_vertex_normal.py index 876ac118..c43af739 100644 --- a/modules/core/python/tests/test_compute_vertex_normal.py +++ b/modules/core/python/tests/test_compute_vertex_normal.py @@ -15,7 +15,7 @@ import math import pytest -from .assets import cube +from .assets import cube # noqa: F401 class TestComputeVertexNormal: diff --git a/modules/core/python/tests/test_compute_vertex_valence.py b/modules/core/python/tests/test_compute_vertex_valence.py index eb6a1dac..1ea44fc2 100644 --- a/modules/core/python/tests/test_compute_vertex_valence.py +++ b/modules/core/python/tests/test_compute_vertex_valence.py @@ -13,7 +13,7 @@ import numpy as np -from .assets import single_triangle, cube +from .assets import single_triangle, cube # noqa: F401 class TestComputeVertexValence: diff --git a/modules/core/python/tests/test_detect_degenerate_facets.py b/modules/core/python/tests/test_detect_degenerate_facets.py index 96d40b6e..cbb22f34 100644 --- a/modules/core/python/tests/test_detect_degenerate_facets.py +++ b/modules/core/python/tests/test_detect_degenerate_facets.py @@ -10,7 +10,7 @@ # governing permissions and limitations under the License. # import lagrange -from .assets import cube +from .assets import cube # noqa: F401 import numpy as np diff --git a/modules/core/python/tests/test_filter_attributes.py b/modules/core/python/tests/test_filter_attributes.py index 10696934..5cd5881f 100644 --- a/modules/core/python/tests/test_filter_attributes.py +++ b/modules/core/python/tests/test_filter_attributes.py @@ -11,9 +11,8 @@ # import lagrange -import numpy as np -from .assets import cube, cube_with_uv +from .assets import cube, cube_with_uv # noqa: F401 class TestFilterAttributes: diff --git a/modules/core/python/tests/test_indexed_attribute.py b/modules/core/python/tests/test_indexed_attribute.py index d69fcaef..4855fddd 100644 --- a/modules/core/python/tests/test_indexed_attribute.py +++ b/modules/core/python/tests/test_indexed_attribute.py @@ -12,10 +12,8 @@ import lagrange import numpy as np -import pytest -import sys -from .assets import cube +from .assets import cube # noqa: F401 class TestIndexedAttribute: diff --git a/modules/core/python/tests/test_isoline.py b/modules/core/python/tests/test_isoline.py index cf072383..79bb239f 100644 --- a/modules/core/python/tests/test_isoline.py +++ b/modules/core/python/tests/test_isoline.py @@ -10,10 +10,9 @@ # governing permissions and limitations under the License. # import lagrange -from .assets import single_triangle +from .assets import single_triangle # noqa: F401 import numpy as np -import pytest class TestIsoline: diff --git a/modules/core/python/tests/test_normalize_meshes.py b/modules/core/python/tests/test_normalize_meshes.py index 0e9a7b91..788c1313 100644 --- a/modules/core/python/tests/test_normalize_meshes.py +++ b/modules/core/python/tests/test_normalize_meshes.py @@ -13,7 +13,6 @@ import numpy as np from numpy.linalg import norm -import math import pytest diff --git a/modules/core/python/tests/test_orient_outward.py b/modules/core/python/tests/test_orient_outward.py index bfd5aa2b..e1ad8bc1 100644 --- a/modules/core/python/tests/test_orient_outward.py +++ b/modules/core/python/tests/test_orient_outward.py @@ -10,10 +10,9 @@ # governing permissions and limitations under the License. # import lagrange -from .assets import cube +from .assets import cube # noqa: F401 import numpy as np -import pytest class TestOrientOutward: diff --git a/modules/core/python/tests/test_permute_facets.py b/modules/core/python/tests/test_permute_facets.py index 977b005f..a61cd4f7 100644 --- a/modules/core/python/tests/test_permute_facets.py +++ b/modules/core/python/tests/test_permute_facets.py @@ -10,7 +10,7 @@ # governing permissions and limitations under the License. # import lagrange -from .assets import cube +from .assets import cube # noqa: F401 import numpy as np import pytest diff --git a/modules/core/python/tests/test_permute_vertices.py b/modules/core/python/tests/test_permute_vertices.py index 33daec90..caf7d0fd 100644 --- a/modules/core/python/tests/test_permute_vertices.py +++ b/modules/core/python/tests/test_permute_vertices.py @@ -14,7 +14,7 @@ import numpy as np import pytest -from .assets import cube, cube_with_uv +from .assets import cube, cube_with_uv # noqa: F401 class TestPermuteVertices: diff --git a/modules/core/python/tests/test_remap_vertices.py b/modules/core/python/tests/test_remap_vertices.py index 7d2c7c45..eb936795 100644 --- a/modules/core/python/tests/test_remap_vertices.py +++ b/modules/core/python/tests/test_remap_vertices.py @@ -14,7 +14,7 @@ import numpy as np import pytest -from .assets import cube, cube_with_uv +from .assets import cube, cube_with_uv # noqa: F401 class TestRemapVertices: diff --git a/modules/core/python/tests/test_remove_degenerate_facets.py b/modules/core/python/tests/test_remove_degenerate_facets.py index 4696ae1f..c214c29d 100644 --- a/modules/core/python/tests/test_remove_degenerate_facets.py +++ b/modules/core/python/tests/test_remove_degenerate_facets.py @@ -10,7 +10,7 @@ # governing permissions and limitations under the License. # import lagrange -from .assets import cube, cube_triangular, cube_with_uv +from .assets import cube, cube_triangular, cube_with_uv # noqa: F401 import numpy as np diff --git a/modules/core/python/tests/test_remove_duplicate_vertices.py b/modules/core/python/tests/test_remove_duplicate_vertices.py index 2ac3842a..cafcafcd 100644 --- a/modules/core/python/tests/test_remove_duplicate_vertices.py +++ b/modules/core/python/tests/test_remove_duplicate_vertices.py @@ -10,7 +10,7 @@ # governing permissions and limitations under the License. # import lagrange -from .assets import cube +from .assets import cube # noqa: F401 import numpy as np diff --git a/modules/core/python/tests/test_remove_null_area_facets.py b/modules/core/python/tests/test_remove_null_area_facets.py index a4ffedbe..cce8ee3e 100644 --- a/modules/core/python/tests/test_remove_null_area_facets.py +++ b/modules/core/python/tests/test_remove_null_area_facets.py @@ -10,7 +10,7 @@ # governing permissions and limitations under the License. # import lagrange -from .assets import cube +from .assets import cube # noqa: F401 import numpy as np diff --git a/modules/core/python/tests/test_remove_short_edges.py b/modules/core/python/tests/test_remove_short_edges.py index afa589af..257b7cce 100644 --- a/modules/core/python/tests/test_remove_short_edges.py +++ b/modules/core/python/tests/test_remove_short_edges.py @@ -11,7 +11,7 @@ # import lagrange -from .assets import single_triangle +from .assets import single_triangle # noqa: F401 class TestRemoveShortEdges: diff --git a/modules/core/python/tests/test_resolve_nonmanifoldness.py b/modules/core/python/tests/test_resolve_nonmanifoldness.py index d3a8891a..25576ab8 100644 --- a/modules/core/python/tests/test_resolve_nonmanifoldness.py +++ b/modules/core/python/tests/test_resolve_nonmanifoldness.py @@ -10,9 +10,7 @@ # governing permissions and limitations under the License. # import lagrange -from .assets import cube - -import numpy as np +from .assets import cube # noqa: F401 class TestRemoveVertexNonmanifoldness: diff --git a/modules/core/python/tests/test_select_facets_by_normal_similarity.py b/modules/core/python/tests/test_select_facets_by_normal_similarity.py index 6596cd4f..6ed53d1f 100644 --- a/modules/core/python/tests/test_select_facets_by_normal_similarity.py +++ b/modules/core/python/tests/test_select_facets_by_normal_similarity.py @@ -10,10 +10,8 @@ # governing permissions and limitations under the License. # import lagrange -import numpy as np -import pytest -from .assets import * +from .assets import single_triangle # noqa: F401 class TestSelectFacetsByNormalSimilarity: diff --git a/modules/core/python/tests/test_select_facets_in_frustum.py b/modules/core/python/tests/test_select_facets_in_frustum.py index 2ae10432..03f09aa7 100644 --- a/modules/core/python/tests/test_select_facets_in_frustum.py +++ b/modules/core/python/tests/test_select_facets_in_frustum.py @@ -11,9 +11,8 @@ # import lagrange import numpy as np -import pytest -from .assets import * +from .assets import cube # noqa: F401 class TestSelectFacetsInFrustum: diff --git a/modules/core/python/tests/test_split_long_edges.py b/modules/core/python/tests/test_split_long_edges.py index e74702dc..9e8f66ff 100644 --- a/modules/core/python/tests/test_split_long_edges.py +++ b/modules/core/python/tests/test_split_long_edges.py @@ -12,7 +12,7 @@ import lagrange import numpy as np -from .assets import single_triangle +from .assets import single_triangle # noqa: F401 class TestSplitLongEdges: diff --git a/modules/core/python/tests/test_stubs.py b/modules/core/python/tests/test_stubs.py index c655d876..d0414903 100644 --- a/modules/core/python/tests/test_stubs.py +++ b/modules/core/python/tests/test_stubs.py @@ -11,7 +11,6 @@ # import lagrange -import pytest from pathlib import Path diff --git a/modules/core/python/tests/test_surface_mesh.py b/modules/core/python/tests/test_surface_mesh.py index da508fed..0f5d0923 100644 --- a/modules/core/python/tests/test_surface_mesh.py +++ b/modules/core/python/tests/test_surface_mesh.py @@ -13,9 +13,8 @@ import numpy as np import pytest -import sys -from .assets import single_triangle, single_triangle_with_index, cube +from .assets import single_triangle, single_triangle_with_index, cube # noqa: F401 from .utils import address, assert_sharing_raw_data @@ -98,8 +97,8 @@ def test_attribute(self, single_triangle): mesh.delete_attribute("index") del attr2 - with pytest.raises(RuntimeError) as e: - data = attr.data + with pytest.raises(RuntimeError): + attr.data def test_create_attribute_without_init_values(self, single_triangle): mesh = single_triangle diff --git a/modules/core/python/tests/test_thicken_and_close_mesh.py b/modules/core/python/tests/test_thicken_and_close_mesh.py index 0b1cb9ac..38c95a1b 100644 --- a/modules/core/python/tests/test_thicken_and_close_mesh.py +++ b/modules/core/python/tests/test_thicken_and_close_mesh.py @@ -11,7 +11,7 @@ # import lagrange -from .assets import single_triangle, cube +from .assets import single_triangle, cube # noqa: F401 class TestThickenAndCloseMesh: diff --git a/modules/core/python/tests/test_transform_mesh.py b/modules/core/python/tests/test_transform_mesh.py index e06427e2..817d5cf2 100644 --- a/modules/core/python/tests/test_transform_mesh.py +++ b/modules/core/python/tests/test_transform_mesh.py @@ -13,7 +13,7 @@ import numpy as np -from .assets import single_triangle +from .assets import single_triangle # noqa: F401 class TestTransformMesh: diff --git a/modules/core/python/tests/test_triangulate_polygonal_facets.py b/modules/core/python/tests/test_triangulate_polygonal_facets.py index 5dcc57c6..c8396678 100644 --- a/modules/core/python/tests/test_triangulate_polygonal_facets.py +++ b/modules/core/python/tests/test_triangulate_polygonal_facets.py @@ -12,7 +12,7 @@ import lagrange import pytest -from .assets import cube +from .assets import cube # noqa: F401 class TestTriangulatePolygonalFacets: diff --git a/modules/core/python/tests/test_unify_index_buffer.py b/modules/core/python/tests/test_unify_index_buffer.py index 066c0e21..2c1caf5c 100644 --- a/modules/core/python/tests/test_unify_index_buffer.py +++ b/modules/core/python/tests/test_unify_index_buffer.py @@ -11,7 +11,7 @@ # import lagrange -from .assets import cube +from .assets import cube # noqa: F401 class TestUnifyIndexBuffer: diff --git a/modules/core/python/tests/test_variant.py b/modules/core/python/tests/test_variant.py new file mode 100644 index 00000000..106c8b4f --- /dev/null +++ b/modules/core/python/tests/test_variant.py @@ -0,0 +1,31 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +import lagrange + + +class TestVariant: + def test_variant_exists(self): + """Test that the variant attribute exists.""" + assert hasattr(lagrange, "variant"), "lagrange.variant attribute does not exist" + + def test_variant_is_string(self): + """Test that the variant attribute is a string.""" + assert isinstance(lagrange.variant, str), ( + f"Expected variant to be str, got {type(lagrange.variant)}" + ) + + def test_variant_value(self): + """Test that the variant attribute has a valid value.""" + valid_variants = ["corp", "open"] + assert lagrange.variant in valid_variants, ( + f"Expected variant to be one of {valid_variants}, got {lagrange.variant!r}" + ) diff --git a/modules/core/python/tests/test_weld_indexed_attribute.py b/modules/core/python/tests/test_weld_indexed_attribute.py index d5d487d0..793fee39 100644 --- a/modules/core/python/tests/test_weld_indexed_attribute.py +++ b/modules/core/python/tests/test_weld_indexed_attribute.py @@ -13,7 +13,7 @@ import numpy as np -from .assets import cube +from .assets import cube # noqa: F401 class TestWeldIndexedAttribute: diff --git a/modules/core/src/Attribute.cpp b/modules/core/src/Attribute.cpp index 54c6f9fc..be74e750 100644 --- a/modules/core/src/Attribute.cpp +++ b/modules/core/src/Attribute.cpp @@ -24,6 +24,19 @@ #include #include +#if defined(__has_feature) + #if __has_feature(address_sanitizer) + #include + #define LAGRANGE_ASAN_ENABLED 1 + #endif +#elif defined(__SANITIZE_ADDRESS__) + #include + #define LAGRANGE_ASAN_ENABLED 1 +#endif +#ifndef LAGRANGE_ASAN_ENABLED + #define LAGRANGE_ASAN_ENABLED 0 +#endif + namespace lagrange { //////////////////////////////////////////////////////////////////////////////// @@ -514,6 +527,24 @@ lagrange::span Attribute::get_all() const return m_const_view.first(m_num_elements * get_num_channels()); } +template +lagrange::span Attribute::get_all_with_padding() const +{ + if (!is_external()) { +#if LAGRANGE_ASAN_ENABLED + // Unpoison the [size, capacity) region so that external libraries (e.g. Embree) can safely + // read padding entries without triggering ASan's container-overflow detection. + if (m_data.capacity() > m_data.size()) { + ASAN_UNPOISON_MEMORY_REGION( + m_data.data() + m_data.size(), + (m_data.capacity() - m_data.size()) * sizeof(ValueType)); + } +#endif + return {m_data.data(), m_data.capacity()}; + } + return get_all(); +} + template lagrange::span Attribute::ref_all() { diff --git a/modules/core/src/SurfaceMesh.cpp b/modules/core/src/SurfaceMesh.cpp index 72589361..ceb8e62d 100644 --- a/modules/core/src/SurfaceMesh.cpp +++ b/modules/core/src/SurfaceMesh.cpp @@ -1403,10 +1403,11 @@ auto SurfaceMesh::get_corner_to_vertex() const -> const Attribute } template -auto SurfaceMesh::ref_corner_to_vertex() -> Attribute& +auto SurfaceMesh::ref_corner_to_vertex(AttributeRefPolicy policy) + -> Attribute& { la_runtime_assert( - !has_edges(), + policy == AttributeRefPolicy::Force || !has_edges(), "Cannot retrieve a writeable reference to mesh facets when edge/connectivity is " "available."); return ref_attribute(m_reserved_ids.corner_to_vertex()); @@ -2074,9 +2075,11 @@ void flip_normals_tangents_bitangents( idx = value_to_flipped[idx]; continue; } - if (idx < old_num_values && has_nonflips_uses[idx] && - value_to_flipped[idx] == invalid()) { - // Need to create a new value and flip it + la_debug_assert(idx < old_num_values); + + if (has_nonflips_uses[idx]) { + // If the value is also used by non-flipped facets, we need to create a + // new value for the flipped facets and update the index accordingly. const Index new_idx = static_cast(values.get_num_elements()); values.insert_elements(1); std::copy_n( @@ -2085,6 +2088,12 @@ void flip_normals_tangents_bitangents( values.ref_row(new_idx).begin()); value_to_flipped[idx] = new_idx; idx = new_idx; + } else { + // No other corner in the mesh uses the same index, so we can flip the + // value in-place without creating a new one. We still need to update + // the "value_to_flipped" entry to avoid flipping the same value + // multiple times if it is shared between multiple flipped facets. + value_to_flipped[idx] = idx; } // Flip the value once. diff --git a/modules/core/src/combine_meshes.cpp b/modules/core/src/combine_meshes.cpp index 31d79d8d..e67e480b 100644 --- a/modules/core/src/combine_meshes.cpp +++ b/modules/core/src/combine_meshes.cpp @@ -208,10 +208,8 @@ void combine_attributes( }); } -} // namespace - template -SurfaceMesh combine_meshes( +SurfaceMesh combine_meshes_impl( size_t num_meshes, function_ref&(size_t)> get_mesh, bool preserve_attributes) @@ -291,6 +289,43 @@ SurfaceMesh combine_meshes( return combined_mesh; } +} // namespace + +template +SurfaceMesh combine_meshes( + size_t num_meshes, + function_ref&(size_t)> get_mesh, + bool preserve_attributes) +{ + if (preserve_attributes) { + // Check for empty meshes and skip them + bool has_empty_mesh = false; + for (size_t i = 0; i < num_meshes; ++i) { + if (get_mesh(i).get_num_vertices() == 0) { + has_empty_mesh = true; + break; + } + } + if (has_empty_mesh) { + // Only allocate remapping array if we have empty input meshes + std::vector non_empty_meshes; + for (size_t i = 0; i < num_meshes; ++i) { + if (get_mesh(i).get_num_vertices() > 0) { + non_empty_meshes.push_back(i); + } + } + return combine_meshes_impl( + non_empty_meshes.size(), + [&](size_t i) -> const SurfaceMesh& { + return get_mesh(non_empty_meshes[i]); + }, + true); + } + } + // Default path when we don't need to remap anything. + return combine_meshes_impl(num_meshes, get_mesh, preserve_attributes); +} + template SurfaceMesh combine_meshes( std::initializer_list*> meshes, diff --git a/modules/core/src/internal/find_attribute_utils.cpp b/modules/core/src/internal/find_attribute_utils.cpp index 9122ea48..401285d6 100644 --- a/modules/core/src/internal/find_attribute_utils.cpp +++ b/modules/core/src/internal/find_attribute_utils.cpp @@ -27,21 +27,15 @@ namespace lagrange::internal { namespace { -enum class ShouldBeWritable { Yes, No }; - -struct Result -{ - bool success = true; - std::string msg; -}; - -#define check_that(x, msh) \ - if (!(x)) { \ - return Result{false, msh}; \ +#define check_that(x, msh) \ + if (!(x)) { \ + return CheckAttributeResult{false, msh}; \ } +} // namespace + template -Result check_attribute( +CheckAttributeResult check_attribute( const SurfaceMesh& mesh, AttributeId id, BitField expected_element, @@ -91,8 +85,6 @@ Result check_attribute( return {true, ""}; } -} // namespace - template AttributeId find_matching_attribute( const SurfaceMesh& mesh, @@ -238,6 +230,13 @@ AttributeId find_or_create_attribute( // Iterate over attribute types x mesh (scalar, index) types #define LA_X_find_attribute(ExpectedValueType, Scalar, Index) \ + template LA_CORE_API CheckAttributeResult check_attribute( \ + const SurfaceMesh& mesh, \ + AttributeId id, \ + BitField expected_element, \ + AttributeUsage expected_usage, \ + size_t expected_channels, \ + ShouldBeWritable expected_writable); \ template LA_CORE_API AttributeId find_matching_attribute( \ const SurfaceMesh& mesh, \ std::string_view name, \ diff --git a/modules/core/src/map_attribute.cpp b/modules/core/src/map_attribute.cpp index 822d9c28..7f8600d1 100644 --- a/modules/core/src/map_attribute.cpp +++ b/modules/core/src/map_attribute.cpp @@ -118,12 +118,23 @@ AttributeId map_attribute_internal( case AttributeElement::Edge: num_elements = mesh.get_num_edges(); break; case AttributeElement::Corner: num_elements = mesh.get_num_corners(); break; case AttributeElement::Value: - num_elements = mesh.get_num_corners(); - la_runtime_assert(old_attr.get_num_elements() == num_elements); + // For constant values (single element), we'll broadcast to all elements + if (old_attr.get_num_elements() == 1) { + num_elements = 1; + } else { + num_elements = mesh.get_num_corners(); + la_runtime_assert(old_attr.get_num_elements() == num_elements); + } break; case AttributeElement::Indexed: la_debug_assert(false); } - src_element = [](size_t i) { return i; }; + // For constant values, broadcast the single element to all positions + // When mapping to Indexed, we optimize by keeping a single value with all indices == 0 + if (old_element == AttributeElement::Value && old_attr.get_num_elements() == 1) { + src_element = [](size_t) { return 0; }; + } else { + src_element = [](size_t i) { return i; }; + } } else { num_elements = mesh.get_num_corners(); switch (old_element) { @@ -153,8 +164,13 @@ AttributeId map_attribute_internal( case AttributeElement::Indexed: case AttributeElement::Value: la_debug_assert(false); break; } - la_runtime_assert(old_attr.get_num_elements() == num_elements); - src_element = [](size_t i) { return i; }; + // For constant values (single element), broadcast to all elements + if (old_attr.get_num_elements() == 1) { + src_element = [](size_t) { return 0; }; + } else { + la_runtime_assert(old_attr.get_num_elements() == num_elements); + src_element = [](size_t i) { return i; }; + } break; case AttributeElement::Indexed: la_debug_assert(false); } @@ -187,7 +203,12 @@ AttributeId map_attribute_internal( break; case AttributeElement::Corner: case AttributeElement::Value: - std::iota(src_index.begin(), src_index.end(), Index(0)); + if (num_elements == 1) { + // All indices point to the single constant value + std::fill(src_index.begin(), src_index.end(), Index(0)); + } else { + std::iota(src_index.begin(), src_index.end(), Index(0)); + } break; case AttributeElement::Indexed: la_debug_assert(false); } diff --git a/modules/core/src/weld_indexed_attribute.cpp b/modules/core/src/weld_indexed_attribute.cpp index 531ce013..d534eec0 100644 --- a/modules/core/src/weld_indexed_attribute.cpp +++ b/modules/core/src/weld_indexed_attribute.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -99,6 +100,11 @@ void weld_indexed_attribute( const bool had_edges = mesh.has_edges(); mesh.initialize_edges(); + const auto _ = make_scope_guard([&]() { + if (!had_edges) { + mesh.clear_edges(); + } + }); auto& attr_values = attr.values(); auto values = matrix_view(attr_values); auto corner_to_value = vector_ref(attr.indices()); @@ -134,7 +140,7 @@ void weld_indexed_attribute( tbb::blocked_range(0, num_vertices), [&](const tbb::blocked_range& range) { for (Index vi = range.begin(); vi < range.end(); vi++) { - if (exclude_vertices_mask[vi]) return; + if (exclude_vertices_mask[vi]) continue; SmallVector involved_indices_and_corners; mesh.foreach_corner_around_vertex(vi, [&](Index ci) { @@ -206,6 +212,15 @@ void weld_indexed_attribute( } } + // Propagate flags to roots after all merges are done + std::vector group_flagged(num_corners, false); + for (Index c = 0; c < num_corners; ++c) { + if (corner_map[c].flag()) { + Index rc = find_and_compress(c); + group_flagged[rc] = true; + } + } + Index num_reduced = 0; std::vector corner_to_reduced(num_corners, invalid()); std::vector index_to_reduced(num_values, invalid()); @@ -215,7 +230,7 @@ void weld_indexed_attribute( // If the root corner has already been processed, we can skip it. return; } - if (corner_map[c].flag()) { + if (group_flagged[c]) { // If the group is flagged, it means a merge happened, and we assign a new index to // the corner group. corner_to_reduced[c] = num_reduced++; @@ -280,12 +295,6 @@ void weld_indexed_attribute( tbb::parallel_for(Index(0), num_corners, [&](auto c) { corner_to_value[c] = corner_to_reduced[c]; }); - - if (!had_edges) { - // Initializing edges is an implementation detail and should not leak outside of the - // function. - mesh.clear_edges(); - } } template diff --git a/modules/core/tests/test_combine_meshes.cpp b/modules/core/tests/test_combine_meshes.cpp index 7217c487..522f0a3a 100644 --- a/modules/core/tests/test_combine_meshes.cpp +++ b/modules/core/tests/test_combine_meshes.cpp @@ -359,6 +359,47 @@ TEST_CASE("combine_meshes hybrid", "[surface][utilities]") REQUIRE(mesh.get_num_facets() == 392); } +TEST_CASE("combine_meshes empty", "[surface][utilities]") +{ + using Scalar = double; + using Index = uint32_t; + + SurfaceMesh mesh2; + mesh2.add_vertices(3); + mesh2.add_triangle(0, 1, 2); + + SurfaceMesh mesh3; + mesh3.add_vertices(4); + mesh3.add_quad(0, 1, 3, 2); + + SurfaceMesh empty; + + SECTION("without attributes") + { + auto mesh0 = combine_meshes({&mesh2, &mesh3, &empty}, true); + auto mesh1 = combine_meshes({&mesh2, &mesh3, &empty}, false); + REQUIRE(mesh0.get_num_vertices() == 7); + REQUIRE(mesh0.get_num_facets() == 2); + REQUIRE(mesh1.get_num_vertices() == 7); + REQUIRE(mesh1.get_num_facets() == 2); + } + + SECTION("with attributes") + { + mesh2.create_attribute("x", AttributeElement::Facet, 3); + mesh3.create_attribute("x", AttributeElement::Facet, 3); + + auto mesh0 = combine_meshes({&mesh2, &mesh3, &empty}, true); + auto mesh1 = combine_meshes({&mesh2, &mesh3, &empty}, false); + REQUIRE(mesh0.get_num_vertices() == 7); + REQUIRE(mesh0.get_num_facets() == 2); + REQUIRE(mesh1.get_num_vertices() == 7); + REQUIRE(mesh1.get_num_facets() == 2); + REQUIRE(mesh0.has_attribute("x")); + REQUIRE(!mesh1.has_attribute("x")); + } +} + TEST_CASE("combine_meshes benchmark", "[surface][utilities][!benchmark]") { using namespace lagrange; diff --git a/modules/core/tests/test_map_attribute.cpp b/modules/core/tests/test_map_attribute.cpp index 79b592aa..03cd5940 100644 --- a/modules/core/tests/test_map_attribute.cpp +++ b/modules/core/tests/test_map_attribute.cpp @@ -274,3 +274,102 @@ TEST_CASE("map_attribute: invalid", "[attribute][conversion]") { test_map_attribute_invalid(); } + +TEST_CASE("map_attribute: constant value", "[attribute][conversion]") +{ + using namespace lagrange; + + // Load a simple mesh + auto mesh = lagrange::testing::load_surface_mesh("open/core/poly/L-plane.obj"); + mesh.initialize_edges(); + + // Create a constant value attribute (single element that should be broadcast) + const size_t num_channels = 3; + auto id = mesh.create_attribute( + "constant_normal", + AttributeElement::Value, + AttributeUsage::Normal, + num_channels); + + // Set it to a single constant value (like a constant normal in USD) + auto& attr = mesh.ref_attribute(id); + attr.resize_elements(1); // Only 1 element (constant value) + auto values = attr.ref_all(); + values[0] = 0.0f; + values[1] = 0.0f; + values[2] = 1.0f; + + // Test mapping to Indexed (this was failing before the fix) + auto indexed_id = map_attribute(mesh, id, "constant_normal_indexed", AttributeElement::Indexed); + REQUIRE(mesh.is_attribute_indexed(indexed_id)); + + // Verify memory optimization: constant values should only store 1 element + const auto& indexed_attr = mesh.get_indexed_attribute(indexed_id); + REQUIRE(indexed_attr.values().get_num_elements() == 1); + for (uint32_t c = 0; c < mesh.get_num_corners(); ++c) { + const auto& row = indexed_attr.values().get_row(indexed_attr.indices().get(c)); + REQUIRE(Catch::Approx(row[0]) == 0.0f); + REQUIRE(Catch::Approx(row[1]) == 0.0f); + REQUIRE(Catch::Approx(row[2]) == 1.0f); + } + + // Test mapping to Corner + auto corner_id = map_attribute(mesh, id, "constant_normal_corner", AttributeElement::Corner); + const auto& corner_attr = mesh.get_attribute(corner_id); + REQUIRE(corner_attr.get_num_elements() == mesh.get_num_corners()); + for (size_t i = 0; i < corner_attr.get_num_elements(); ++i) { + const auto& row = corner_attr.get_row(i); + REQUIRE(Catch::Approx(row[0]) == 0.0f); + REQUIRE(Catch::Approx(row[1]) == 0.0f); + REQUIRE(Catch::Approx(row[2]) == 1.0f); + } + + // Test mapping to Vertex + auto vertex_id = map_attribute(mesh, id, "constant_normal_vertex", AttributeElement::Vertex); + const auto& vertex_attr = mesh.get_attribute(vertex_id); + REQUIRE(vertex_attr.get_num_elements() == mesh.get_num_vertices()); + for (size_t i = 0; i < vertex_attr.get_num_elements(); ++i) { + const auto& row = vertex_attr.get_row(i); + REQUIRE(Catch::Approx(row[0]) == 0.0f); + REQUIRE(Catch::Approx(row[1]) == 0.0f); + REQUIRE(Catch::Approx(row[2]) == 1.0f); + } + + // Test mapping to Facet + auto facet_id = map_attribute(mesh, id, "constant_normal_facet", AttributeElement::Facet); + const auto& facet_attr = mesh.get_attribute(facet_id); + REQUIRE(facet_attr.get_num_elements() == mesh.get_num_facets()); + for (size_t i = 0; i < facet_attr.get_num_elements(); ++i) { + const auto& row = facet_attr.get_row(i); + REQUIRE(Catch::Approx(row[0]) == 0.0f); + REQUIRE(Catch::Approx(row[1]) == 0.0f); + REQUIRE(Catch::Approx(row[2]) == 1.0f); + } + + // Test mapping to Edge (edges were initialized earlier) + auto edge_id = map_attribute(mesh, id, "constant_normal_edge", AttributeElement::Edge); + const auto& edge_attr = mesh.get_attribute(edge_id); + REQUIRE(edge_attr.get_num_elements() == mesh.get_num_edges()); + for (size_t i = 0; i < edge_attr.get_num_elements(); ++i) { + const auto& row = edge_attr.get_row(i); + REQUIRE(Catch::Approx(row[0]) == 0.0f); + REQUIRE(Catch::Approx(row[1]) == 0.0f); + REQUIRE(Catch::Approx(row[2]) == 1.0f); + } + + // Test map_attribute_in_place as well (this is what the user was calling) + mesh.create_attribute( + "primvars:normals", + AttributeElement::Value, + AttributeUsage::Normal, + num_channels); + auto& primvar_attr = mesh.ref_attribute("primvars:normals"); + primvar_attr.resize_elements(1); + auto primvar_values = primvar_attr.ref_all(); + primvar_values[0] = 0.0f; + primvar_values[1] = 0.0f; + primvar_values[2] = 1.0f; + + // This should not throw anymore + REQUIRE_NOTHROW(map_attribute_in_place(mesh, "primvars:normals", AttributeElement::Indexed)); +} diff --git a/modules/core/tests/test_orient_outward.cpp b/modules/core/tests/test_orient_outward.cpp index 4bcc1651..1178aa58 100644 --- a/modules/core/tests/test_orient_outward.cpp +++ b/modules/core/tests/test_orient_outward.cpp @@ -145,6 +145,31 @@ TEST_CASE("orient_outward: cube with attrs", "[mesh][orient]") } } +TEST_CASE("orient_outward: double flip", "[mesh][orient]" LA_CORP_FLAG) +{ + using Scalar = double; + using Index = uint32_t; + + auto mesh = + lagrange::testing::load_surface_mesh("corp/core/cone_low_flipped.obj"); + std::array indices = {8, 10, 11, 12, 13, 14, 15, 16}; + { + const auto& attr = mesh.get_indexed_attribute("normal"); + auto normals = matrix_view(attr.values()); + for (size_t i : indices) { + CHECK(normals.row(i).y() < 0); // pointing downward + } + } + lagrange::orient_outward(mesh); + { + const auto& attr = mesh.get_indexed_attribute("normal"); + auto normals = matrix_view(attr.values()); + for (size_t i : indices) { + CHECK(normals.row(i).y() > 0); // pointing upward + } + } +} + TEST_CASE("orient_outward: poly", "[mesh][orient]") { using Scalar = double; diff --git a/modules/filtering/python/tests/test_mesh_smoothing.py b/modules/filtering/python/tests/test_mesh_smoothing.py index 3a74206a..16372fd4 100644 --- a/modules/filtering/python/tests/test_mesh_smoothing.py +++ b/modules/filtering/python/tests/test_mesh_smoothing.py @@ -11,12 +11,8 @@ # import lagrange -import math -import pytest -import numpy as np -import logging -from .assets import cube +from .assets import cube # noqa: F401 class TestMeshSmoothing: diff --git a/modules/image/python/include/lagrange/python/image_utils.h b/modules/image/python/include/lagrange/python/image_utils.h new file mode 100644 index 00000000..60c99dde --- /dev/null +++ b/modules/image/python/include/lagrange/python/image_utils.h @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include + +namespace lagrange::python { + +namespace nb = nanobind; + +using ImageShape = nb::shape<-1, -1, -1>; + +template +using ImageTensor = nb::ndarray; + +// Numpy indexes tensors as (row, col, channel), but our mdspan uses (x, y, channel) +// coordinates, so we need to transpose the first two dimensions. + +template +auto tensor_to_image_view(const ImageTensor& tensor) -> image::experimental::View3D +{ + const image::experimental::dextents shape{ + tensor.shape(1), + tensor.shape(0), + tensor.shape(2), + }; + const std::array strides{ + static_cast(tensor.stride(1)), + static_cast(tensor.stride(0)), + static_cast(tensor.stride(2)), + }; + const image::experimental::layout_stride::mapping mapping{shape, strides}; + image::experimental::View3D view{ + static_cast(tensor.data()), + mapping, + }; + return view; +} + +template +void copy_tensor_to_image_view( + const ImageTensor& tensor, + image::experimental::View3D image) +{ + const auto width = static_cast(tensor.shape(1)); + const auto height = static_cast(tensor.shape(0)); + const auto num_channels = static_cast(tensor.shape(2)); + la_runtime_assert( + image.extent(0) == width && image.extent(1) == height && image.extent(2) == num_channels, + "Tensor and mdspan dimensions do not match"); + for (unsigned int j = 0; j < height; j++) { + for (unsigned int i = 0; i < width; i++) { + for (unsigned int c = 0; c < num_channels; c++) { + image(i, j, c) = tensor(j, i, c); + } + } + } +} + +template +nb::object image_array_to_tensor(const image::experimental::Array3D& image_) +{ + auto image = const_cast&>(image_); + auto tensor = Tensor( + static_cast(image.data()), + { + image.extent(1), + image.extent(0), + image.extent(2), + }, + nb::handle(), + { + static_cast(image.stride(1)), + static_cast(image.stride(0)), + static_cast(image.stride(2)), + }); + return tensor.cast(); +} + +} // namespace lagrange::python diff --git a/modules/io/python/src/io.cpp b/modules/io/python/src/io.cpp index 085fed30..710e305a 100644 --- a/modules/io/python/src/io.cpp +++ b/modules/io/python/src/io.cpp @@ -80,7 +80,11 @@ void populate_io_module(nb::module_& m) "search_path", &io::LoadOptions::search_path, "Search path for related files, such as .mtl, .bin, or image textures. By default, " - "searches the same folder as the provided filename"); + "searches the same folder as the provided filename") + .def_rw( + "stitch_vertices", + &io::LoadOptions::stitch_vertices, + "Stitch duplicate boundary vertices together when loading file"); nb::enum_(m, "FileEncoding", "File encoding type") .value("Binary", io::FileEncoding::Binary, "Binary encoding") diff --git a/modules/io/python/tests/test_io.py b/modules/io/python/tests/test_io.py index f9ed5cde..972749f5 100644 --- a/modules/io/python/tests/test_io.py +++ b/modules/io/python/tests/test_io.py @@ -138,6 +138,7 @@ def __save_and_load__( else: attr_name = mesh.get_attribute_name(attr_id) attr = mesh.attribute(attr_id) + assert type(attr) is lagrange.Attribute assert mesh2.has_attribute(attr_name) def save_and_load(self, mesh, attribute_ids=None): diff --git a/modules/io/src/load_fbx.cpp b/modules/io/src/load_fbx.cpp index 38c96bef..79f19a50 100644 --- a/modules/io/src/load_fbx.cpp +++ b/modules/io/src/load_fbx.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -113,7 +114,8 @@ MeshType convert_mesh_ufbx_to_lagrange(const ufbx_mesh* mesh, const LoadOptions& if (opt.load_uvs) { for (size_t i = 0; i < mesh->uv_sets.count; ++i) { const ufbx_uv_set& uv_set = mesh->uv_sets[i]; - std::string name{uv_set.name.data}; + std::string name = + lagrange::internal::get_unique_attribute_name(lmesh, uv_set.name.data); auto id = lmesh.template create_attribute( name, @@ -268,16 +270,31 @@ struct UfbxScene UfbxScene& operator=(UfbxScene&&) = delete; }; -UfbxScene load_ufbx(const fs::path& filename) +ufbx_load_opts create_load_opts() { - std::string filename_s = filename.string(); ufbx_load_opts opts{}; - ufbx_error error{}; - opts.target_axes.right = UFBX_COORDINATE_AXIS_POSITIVE_X; opts.target_axes.front = UFBX_COORDINATE_AXIS_NEGATIVE_Y; opts.target_axes.up = UFBX_COORDINATE_AXIS_POSITIVE_Z; opts.target_unit_meters = 1.0; + opts.geometry_transform_handling = UFBX_GEOMETRY_TRANSFORM_HANDLING_HELPER_NODES; + opts.inherit_mode_handling = UFBX_INHERIT_MODE_HANDLING_HELPER_NODES; + constexpr std::string_view scale_helper_name = "__scale__helper__"; + constexpr std::string_view geometry_helper_name = "__geometry_helper__"; + opts.scale_helper_name = {scale_helper_name.data(), scale_helper_name.size()}; + opts.geometry_transform_helper_name = { + geometry_helper_name.data(), + geometry_helper_name.size()}; + return opts; +} + +UfbxScene load_ufbx(const fs::path& filename) +{ + std::string filename_s = filename.string(); + + ufbx_load_opts opts = create_load_opts(); + ufbx_error error{}; + return ufbx_load_file(filename_s.c_str(), &opts, &error); } @@ -287,14 +304,9 @@ UfbxScene load_ufbx(std::istream& input_stream) std::istreambuf_iterator data_itr(input_stream), end_of_stream; std::string data(data_itr, end_of_stream); - ufbx_load_opts opts{}; + ufbx_load_opts opts = create_load_opts(); ufbx_error error{}; - opts.target_axes.right = UFBX_COORDINATE_AXIS_POSITIVE_X; - opts.target_axes.front = UFBX_COORDINATE_AXIS_NEGATIVE_Y; - opts.target_axes.up = UFBX_COORDINATE_AXIS_POSITIVE_Z; - opts.target_unit_meters = 1.0; - return ufbx_load_memory(data.data(), data.size(), &opts, &error); } diff --git a/modules/io/src/load_gltf.cpp b/modules/io/src/load_gltf.cpp index 90ce8bb8..bb4847de 100644 --- a/modules/io/src/load_gltf.cpp +++ b/modules/io/src/load_gltf.cpp @@ -435,7 +435,15 @@ MeshType convert_tinygltf_primitive_to_lagrange_mesh( // Different primitives can reference different materials and data buffers. MeshType lmesh; - la_runtime_assert(primitive.mode == TINYGLTF_MODE_TRIANGLES); + if (primitive.mode != TINYGLTF_MODE_TRIANGLES) { + if (!options.quiet) { + logger().warn( + "Skipping non-triangle primitive with mode {}. Empty mesh will be created for this " + "primitive.", + primitive.mode); + } + return lmesh; + } // read vertices auto it = primitive.attributes.find("POSITION"); @@ -730,11 +738,13 @@ SceneType load_scene_gltf(const tinygltf::Model& model, const LoadOptions& optio // We assume the image data was loaded by tinygltf. // If this assumptions does not always hold, then we will have // to instead read the gltf buffer or file from disk. + int bytes_per_component = tinygltf::GetComponentSizeInBytes(image.pixel_type); la_runtime_assert(image.width > 0); la_runtime_assert(image.height > 0); la_runtime_assert(image.component > 0); la_runtime_assert( - static_cast(image.image.size()) == image.width * image.height * image.component); + static_cast(image.image.size()) == + image.width * image.height * image.component * bytes_per_component); scene::ImageExperimental limage; limage.name = image.name; @@ -779,6 +789,8 @@ SceneType load_scene_gltf(const tinygltf::Model& model, const LoadOptions& optio logger().warn("Loading image with unsupported pixel precision!"); throw std::runtime_error("Unsupported pixel type"); } + la_runtime_assert( + bytes_per_component * 8 == static_cast(limage_buffer.get_bits_per_element())); limage_buffer.data = std::move(image.image); lscene.add(std::move(limage)); diff --git a/modules/io/src/save_gltf.cpp b/modules/io/src/save_gltf.cpp index 0d5893ef..41c361e2 100644 --- a/modules/io/src/save_gltf.cpp +++ b/modules/io/src/save_gltf.cpp @@ -912,8 +912,7 @@ tinygltf::Model lagrange_scene_to_gltf_model( // TODO animations - size_t num_nodes = lscene.nodes.size(); - std::vector node_indices(num_nodes, invalid()); + std::vector node_indices(lscene.nodes.size(), invalid()); std::function visit_node; visit_node = [&](const scene::Node& lnode) -> int { @@ -973,14 +972,38 @@ tinygltf::Model lagrange_scene_to_gltf_model( return node_idx; }; - for (size_t i = 0; i < num_nodes; i++) { - if (node_indices[i] != invalid()) continue; + auto visit_root = [&](scene::ElementId i, bool explicit_root) { + if (node_indices[i] != invalid()) { + if (!options.quiet && explicit_root) { + logger().warn( + "Node {} is a root node but has already been visited as a child node. " + "Skipping.", + i); + } + return; + }; const scene::Node& lnode = lscene.nodes[i]; int lnode_idx = visit_node(lnode); scene.nodes.push_back(lnode_idx); node_indices[i] = lnode_idx; + }; + if (lscene.root_nodes.empty()) { + if (!options.quiet) { + logger().warn( + "Scene has no explicit root nodes. Visiting all non-child nodes and treating them " + "as root nodes if they are not visited as children of other nodes."); + } + for (scene::ElementId i = 0; i < lscene.nodes.size(); ++i) { + if (lscene.nodes[i].parent == scene::invalid_element) { + visit_root(i, false); + } + } + } else { + for (auto i : lscene.root_nodes) { + visit_root(i, true); + } } return model; diff --git a/modules/io/src/save_mesh_msh.cpp b/modules/io/src/save_mesh_msh.cpp index 003cdfe3..db48a66e 100644 --- a/modules/io/src/save_mesh_msh.cpp +++ b/modules/io/src/save_mesh_msh.cpp @@ -331,6 +331,14 @@ void populate_non_indexed_attribute( #undef LA_X_try_attribute break; } + case AttributeElement::Value: { + if (!quiet) { + logger().warn( + "Ignoring value attribute {} when saving to MSH format.", + mesh.get_attribute_name(id)); + } + break; + } default: throw Error("Unsupported attribute element type!"); break; } } diff --git a/modules/io/tests/test_fbx.cpp b/modules/io/tests/test_fbx.cpp index 45720a96..8e55f0cc 100644 --- a/modules/io/tests/test_fbx.cpp +++ b/modules/io/tests/test_fbx.cpp @@ -13,9 +13,12 @@ #include #include #include +#include +#include #include #include +#include TEST_CASE("load_fbx", "[io][fbx]" LA_CORP_FLAG) { @@ -47,3 +50,35 @@ TEST_CASE("load_fbx_and_save", "[io][fbx]" LA_CORP_FLAG) REQUIRE_NOTHROW(lagrange::io::save_scene(output_glb, scene, save_options)); REQUIRE_NOTHROW(lagrange::io::save_scene(output_obj, scene, save_options)); } + +TEST_CASE("load_fbx with duplicate attr", "[io][fbx]") +{ + lagrange::io::LoadOptions options; + options.quiet = true; + REQUIRE_NOTHROW( + lagrange::io::load_mesh_fbx( + lagrange::testing::get_data_path("open/io/Walking.fbx"), + options)); +} + +TEST_CASE("load_fbx with geometric transform", "[io][fbx]" LA_CORP_FLAG) +{ + lagrange::io::LoadOptions options; + options.quiet = true; + auto mesh = lagrange::io::load_mesh_fbx( + lagrange::testing::get_data_path("corp/io/cgt_fmcg_stain_remove_whiten_049.fbx"), + options); + auto scene = lagrange::io::load_scene_fbx( + lagrange::testing::get_data_path("corp/io/cgt_fmcg_stain_remove_whiten_049.fbx"), + options); + auto flat = lagrange::scene::scene_to_mesh(scene); + auto expected = lagrange::io::load_mesh_ply( + lagrange::testing::get_data_path("corp/io/cgt_fmcg_stain_remove_whiten_049.ply"), + options); + lagrange::logger().debug("{}", lagrange::scene::internal::to_string(scene)); + mesh = lagrange::SurfaceMesh32d::stripped_move(std::move(mesh)); + flat = lagrange::SurfaceMesh32d::stripped_move(std::move(flat)); + expected = lagrange::SurfaceMesh32d::stripped_move(std::move(expected)); + lagrange::testing::ensure_approx_equivalent_mesh(mesh, expected); + lagrange::testing::ensure_approx_equivalent_mesh(flat, expected); +} diff --git a/modules/io/tests/test_load_gltf.cpp b/modules/io/tests/test_load_gltf.cpp index 78d6ae74..ebe623b5 100644 --- a/modules/io/tests/test_load_gltf.cpp +++ b/modules/io/tests/test_load_gltf.cpp @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +#include #include #include #include @@ -157,3 +158,17 @@ TEST_CASE("load_gltf_point_cloud", "[io][gltf]") lagrange::io::load_simple_scene_gltf>(ss); REQUIRE(scene2.get_num_meshes() == 1); } + +TEST_CASE("load_gltf_non_triangle_simple", "[io][gltf]" LA_CORP_FLAG) +{ + io::LoadOptions options; + options.quiet = true; + auto scene = io::load_simple_scene_gltf( + testing::get_data_path("corp/io/segments.glb"), + options); + REQUIRE(scene.get_num_meshes() == 2); + REQUIRE(scene.get_mesh(0).get_num_vertices() == 0); + REQUIRE(scene.get_mesh(0).get_num_facets() == 0); + REQUIRE(scene.get_mesh(1).get_num_vertices() == 198); + REQUIRE(scene.get_mesh(1).get_num_facets() == 130); +} diff --git a/modules/io/tests/test_load_scene.cpp b/modules/io/tests/test_load_scene.cpp index ed08b283..849af1f0 100644 --- a/modules/io/tests/test_load_scene.cpp +++ b/modules/io/tests/test_load_scene.cpp @@ -349,3 +349,17 @@ TEST_CASE("scene_extension_user", "[scene]" LA_CORP_FLAG) MyValue val = std::any_cast(scene.nodes[0].extensions.user_data["ADOBE_gsplat_asset"]); REQUIRE(val.splat_count == 104783); } + +TEST_CASE("load_gltf_non_triangle", "[io][gltf]" LA_CORP_FLAG) +{ + io::LoadOptions options; + options.quiet = true; + auto scene = io::load_scene_gltf( + testing::get_data_path("corp/io/segments.glb"), + options); + REQUIRE(scene.meshes.size() == 2); + REQUIRE(scene.meshes.at(0).get_num_vertices() == 0); + REQUIRE(scene.meshes.at(0).get_num_facets() == 0); + REQUIRE(scene.meshes.at(1).get_num_vertices() == 198); + REQUIRE(scene.meshes.at(1).get_num_facets() == 130); +} diff --git a/modules/io/tests/test_save_scene.cpp b/modules/io/tests/test_save_scene.cpp index 12027566..d37b2658 100644 --- a/modules/io/tests/test_save_scene.cpp +++ b/modules/io/tests/test_save_scene.cpp @@ -257,3 +257,39 @@ TEST_CASE("save_scene with float images", "[io]") LA_REQUIRE_THROWS(io::save_scene(output, scene, io::FileFormat::Gltf, options)); } } + + +TEST_CASE("save_scene with new root node", "[io]") +{ + using Scene = scene::Scene32f; + lagrange::io::LoadOptions load_options; + load_options.quiet = true; + + fs::path avocado_path = testing::get_data_path("open/io/avocado/Avocado.gltf"); + auto scene = lagrange::io::load_scene(avocado_path, load_options); + + const size_t old_num_nodes = scene.nodes.size(); + const size_t old_num_root_nodes = scene.root_nodes.size(); + + // Add a new root node to the scene + { + lagrange::scene::Node xform; + xform.name = "NewRoot"; + xform.transform.setIdentity(); + size_t new_root_node_index = scene.add(xform); + for (auto i : scene.root_nodes) { + scene.add_child(new_root_node_index, i); + } + scene.root_nodes = {new_root_node_index}; + } + + // Save the modified scene + fs::path reparented_scene = testing::get_test_output_path("test_save_scene/reparented.glb"); + lagrange::io::save_scene(reparented_scene, scene); + auto re_scene = lagrange::io::load_scene(reparented_scene, load_options); + + REQUIRE(re_scene.nodes.size() == old_num_nodes + 1); + REQUIRE(re_scene.root_nodes.size() == 1); + REQUIRE(re_scene.nodes[re_scene.root_nodes[0]].name == "NewRoot"); + REQUIRE(re_scene.nodes[re_scene.root_nodes[0]].children.size() == old_num_root_nodes); +} diff --git a/modules/poisson/python/scripts/poisson_recon.py b/modules/poisson/python/scripts/poisson_recon.py index 496c31e7..116d703c 100755 --- a/modules/poisson/python/scripts/poisson_recon.py +++ b/modules/poisson/python/scripts/poisson_recon.py @@ -15,7 +15,6 @@ import argparse import lagrange -import numpy as np def parse_args(): diff --git a/modules/poisson/python/tests/test_poisson_reconstruction.py b/modules/poisson/python/tests/test_poisson_reconstruction.py index e7efc864..c3330703 100644 --- a/modules/poisson/python/tests/test_poisson_reconstruction.py +++ b/modules/poisson/python/tests/test_poisson_reconstruction.py @@ -12,9 +12,7 @@ import lagrange import math -import pytest import numpy as np -import logging def fibonacci_sphere(num_points=1000): diff --git a/modules/polyddg/include/lagrange/polyddg/DifferentialOperators.h b/modules/polyddg/include/lagrange/polyddg/DifferentialOperators.h index e786d580..2490b46a 100644 --- a/modules/polyddg/include/lagrange/polyddg/DifferentialOperators.h +++ b/modules/polyddg/include/lagrange/polyddg/DifferentialOperators.h @@ -79,32 +79,23 @@ class DifferentialOperators Eigen::SparseMatrix d1() const; /// - /// Compute the discrete Hodge star operator for 0-form. + /// Compute the discrete Hodge star operator for 0-forms (diagonal mass matrix, size #V x #V). /// - /// The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimension of the - /// manifold. The discrete Hodge star operator for 0-form is a matrix of size #V by #V. - /// - /// @return A sparse matrix representing the discrete Hodge star operator for 0-forms. + /// @return A diagonal sparse matrix of size #V x #V. /// Eigen::SparseMatrix star0() const; /// - /// Compute the discrete Hodge star operator for 1-form. - /// - /// The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimension of the - /// manifold. The discrete Hodge star operator for 1-form is a matrix of size #E by #E. + /// Compute the discrete Hodge star operator for 1-forms (diagonal mass matrix, size #E x #E). /// - /// @return A sparse matrix representing the discrete Hodge star operator for 1-forms. + /// @return A diagonal sparse matrix of size #E x #E. /// Eigen::SparseMatrix star1() const; /// - /// Compute the discrete Hodge star operator for 2-form. - /// - /// The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimension of the - /// manifold. The discrete Hodge star operator for 2-form is a matrix of size #F by #F. + /// Compute the discrete Hodge star operator for 2-forms (diagonal mass matrix, size #F x #F). /// - /// @return A sparse matrix representing the discrete Hodge star operator for 2-forms. + /// @return A diagonal sparse matrix of size #F x #F. /// Eigen::SparseMatrix star2() const; @@ -238,51 +229,69 @@ class DifferentialOperators Eigen::SparseMatrix levi_civita() const; /// - /// Compute the discrete Levi-Civita connection for n-rosy fields. - /// - /// The discrete Levi-Civita connection parallel transports tangent vectors defined on vertices - /// to tangent vectors defined on corners. It is represented as a matrix of size #C * 2 by #V * - /// 2. All tangent vectors are expressed in its local tangent basis. - /// - /// @param[in] n Number of times to apply the connection. + /// n-rosy variant of levi_civita(). /// - /// @note The parameter n is designed to work with n-rosy field, where a representative - /// tangent vector is the n-time rotation of any of the n vectors in a n-rosy field. + /// @param[in] n Symmetry order of the rosy field (applies the connection n times). /// - /// @return A sparse matrix representing the discrete Levi-Civita connection. + /// @return A sparse matrix of size (#C * 2) x (#V * 2). /// Eigen::SparseMatrix levi_civita_nrosy(Index n) const; /// /// Compute the discrete covariant derivative operator. /// - /// The covariance derivative operator measures the change of a tangent vector field with + /// The covariant derivative operator measures the change of a tangent vector field with /// respect to another tangent vector field using the Levi-Civita connection. In the discrete - /// setting, both vector fields are defined on the vertices. The discrete covariance derivative - /// operator is represented as a matrix of size #F * 4 by #V * 2. The output covariant - /// derivative is a flattened 2 by 2 matrix defined on each facet. + /// setting, both vector fields are defined on the vertices. The output covariant derivative is + /// a flattened 2x2 matrix defined on each facet. /// /// @return A sparse matrix representing the discrete covariant derivative operator. /// Eigen::SparseMatrix covariant_derivative() const; /// - /// Compute the discrete covariant derivative operator for n-rosy fields. + /// n-rosy variant of covariant_derivative(). /// - /// The covariance derivative operator measures the change of a tangent vector field with - /// respect to another tangent vector field using the Levi-Civita connection. In the discrete - /// setting, both vector fields are defined on the vertices. The discrete covariance derivative - /// operator is represented as a matrix of size #F * 4 by #V * 2. The output covariant - /// derivative is a flattened 2 by 2 matrix defined on each facet. + /// @param[in] n Symmetry order of the rosy field (applies the connection n times). /// - /// @param[in] n Number of times to apply the connection. + /// @return A sparse matrix of size (#F * 4) x (#V * 2). /// - /// @note The parameter n is designed to work with n-rosy field, where a representative - /// tangent vector is the n-time rotation of any of the n vectors in a n-rosy field. + Eigen::SparseMatrix covariant_derivative_nrosy(Index n) const; + /// - /// @return A sparse matrix representing the discrete covariant derivative operator. + /// Compute the global discrete shape operator (Eq. (23), de Goes et al. 2020). /// - Eigen::SparseMatrix covariant_derivative_nrosy(Index n) const; + /// Applies the per-facet shape operator to the precomputed per-vertex normals and assembles + /// the result into a sparse linear map. It maps a per-vertex 3-D normal field (#V * 3 values, + /// three interleaved components per vertex) to per-facet 2x2 symmetrized shape operators + /// (#F * 4 values, row-major per facet: [S(0,0), S(0,1), S(1,0), S(1,1)]). + /// + /// @return A sparse matrix of size (#F * 4) x (#V * 3). + /// + Eigen::SparseMatrix shape_operator() const; + + /// + /// Compute the global discrete adjoint gradient operator (Eq. (24), de Goes et al. 2020). + /// + /// The adjoint gradient is the vertex-centered dual of the per-facet gradient. It maps a + /// per-facet scalar field (#F values) to a per-vertex 3-D tangent vector field. The returned + /// sparse matrix has shape (#V * 3) x #F, with three interleaved components per vertex row. + /// + /// @return A sparse matrix of size (#V * 3) x #F. + /// + Eigen::SparseMatrix adjoint_gradient() const; + + /// + /// Compute the global discrete adjoint shape operator (Eq. (26), de Goes et al. 2020). + /// + /// The adjoint shape operator is the vertex-centered dual of the per-facet shape operator. It + /// maps a per-facet 3-D normal field (#F * 3 values, three interleaved components per facet) + /// to per-vertex 2x2 symmetrized shape operators (#V * 4 values, row-major per vertex: + /// [S(0,0), S(0,1), S(1,0), S(1,1)]). + /// + /// @return A sparse matrix of size (#V * 4) x (#F * 3). + /// + Eigen::SparseMatrix adjoint_shape_operator() const; /// /// Compute the connection Laplacian operator. @@ -298,32 +307,25 @@ class DifferentialOperators Eigen::SparseMatrix connection_laplacian(Scalar lambda = 1) const; /// - /// Compute the connection Laplacian operator for n-rosy fields. - /// - /// The connection Laplacian operator computes the Laplacian of a tangent vector field defined - /// on the vertices using Levi-Civita connection for parallel transport. It is represented as a - /// matrix of size #V * 2 by #V * 2. + /// n-rosy variant of connection_laplacian(). /// - /// @param[in] n Number of times to apply the connection. + /// @param[in] n Symmetry order of the rosy field (applies the connection n times). /// @param[in] lambda Weight of projection term for the 1-form inner product (default: 1). /// - /// @note The parameter n is designed to work with n-rosy field, where a representative - /// tangent vector is the n-time rotation of any of the n vectors in a n-rosy field. - /// - /// @return A sparse matrix representing the discrete connection Laplacian operator. + /// @return A sparse matrix of size (#V * 2) x (#V * 2). /// Eigen::SparseMatrix connection_laplacian_nrosy(Index n, Scalar lambda = 1) const; public: /// - /// Compute the gradient for a single facet. + /// Compute the per-corner gradient vectors for a single facet (Eq. (8), de Goes et al. 2020). /// - /// The gradient for a single facet is a 3 by nf vector, where nf is the number vertices in the - /// facet. It represents the gradient of a linear function defined on the facet. + /// Returns a 3 x nf matrix whose column l is (a_f x e_f^l) / (2 * |a_f|^2), where a_f is + /// the facet vector area and e_f^l = x_{l-1} - x_{l+1} spans the opposite edge. /// /// @param[in] fid Facet index. /// - /// @return A matrix representing the gradient for the given facet. + /// @return A 3 x nf matrix of per-corner gradient vectors. /// Eigen::Matrix gradient(Index fid) const; @@ -356,39 +358,36 @@ class DifferentialOperators /// /// Compute the flat operator for a single facet. /// - /// The discrete flat operator for a single fact is a nf by 3 matrix, where nf is the number of - /// vertices of the facet. It maps a vector field defined on the facet to a 1-form defined on - /// the edges of the facet. + /// Maps a vector field defined on the facet to a 1-form on its edges. The matrix has size + /// nf x 3, where nf is the number of vertices of the facet. /// /// @param[in] fid Facet index. /// - /// @return A matrix representing the flat operator for the given facet. + /// @return An nf x 3 matrix representing the flat operator for the given facet. /// Eigen::Matrix flat(Index fid) const; /// /// Compute the sharp operator for a single facet. /// - /// The discrete sharp operator for a single fact is a 3 by nf matrix, where nf is the number of - /// vertices of the facet. It maps a 1-form defined on the edges of the facet to a vector - /// field defined on the facet. + /// Maps a 1-form on the edges of the facet to a vector field on the facet. The matrix has + /// size 3 x nf, where nf is the number of vertices of the facet. /// /// @param[in] fid Facet index. /// - /// @return A matrix representing the sharp operator for the given facet. + /// @return A 3 x nf matrix representing the sharp operator for the given facet. /// Eigen::Matrix sharp(Index fid) const; /// /// Compute the projection operator for a single facet. /// - /// The discrete projection operator for a single fact is a nf by nf matrix, where nf is the - /// number of vertices of the facet. It measures the information loss when extracting the part - /// of the 1-form associated with a vector field. + /// Measures the information loss when extracting the part of a 1-form associated with a + /// vector field. The matrix has size nf x nf, where nf is the number of vertices. /// /// @param[in] fid Facet index. /// - /// @return A matrix representing the projection operator for the given facet. + /// @return An nf x nf matrix representing the projection operator for the given facet. /// Eigen::Matrix projection(Index fid) const; @@ -460,17 +459,13 @@ class DifferentialOperators Eigen::Matrix levi_civita(Index fid, Index lv) const; /// - /// Compute the discrete Levi-Civita connection that parallel transport a tangent vector from a - /// vertex to an incident facet for n-rosy fields. + /// n-rosy variant of levi_civita(fid, lv). /// /// @param[in] fid Facet index. /// @param[in] lv Local vertex index in the facet. - /// @param[in] n Number of times to apply the connection. + /// @param[in] n Symmetry order of the rosy field (applies the connection n times). /// - /// @note The parameter n is designed to work with n-rosy field, where a representative - /// tangent vector is the n-time rotation of any of the n vectors in a n-rosy field. - /// - /// @return A 2 by 2 matrix representing the Levi-Civita connection. + /// @return A 2x2 matrix representing the Levi-Civita connection. /// Eigen::Matrix levi_civita_nrosy(Index fid, Index lv, Index n) const; @@ -488,19 +483,12 @@ class DifferentialOperators Eigen::Matrix levi_civita(Index fid) const; /// - /// Compute the discrete Levi-Civita connection for a single facet for n-rosy fields. - /// - /// The per-facet Levi-Civita connection is a 2*nf by 2*nf block diagonal matrix that parallel - /// transports tangent vectors from the vertex tangent space to the tangent space of the facet. - /// Here nf is the number of vertices in the facet. + /// n-rosy variant of levi_civita(fid). /// /// @param[in] fid Facet index. - /// @param[in] n Number of times to apply the connection. + /// @param[in] n Symmetry order of the rosy field (applies the connection n times). /// - /// @note The parameter n is designed to work with n-rosy field, where a representative - /// tangent vector is the n-time rotation of any of the n vectors in a n-rosy field. - /// - /// @return A matrix representing the Levi-Civita connection. + /// @return A (2*nf) x (2*nf) block-diagonal matrix representing the Levi-Civita connection. /// Eigen::Matrix levi_civita_nrosy(Index fid, Index n) const; @@ -519,49 +507,80 @@ class DifferentialOperators Eigen::Matrix covariant_derivative(Index fid) const; /// - /// Compute the discrete covariant derivative operator for a single facet for n-rosy fields. + /// n-rosy variant of covariant_derivative(fid). /// - /// The discrete covariant derivative operator is a 4 by 2 * nf matrix that maps a tangent vector - /// defined on a vertex to a flattened 2 by 2 covariant derivative matrix defined on the facet. - /// Here nf is the number of vertices in the facet. + /// @param[in] fid Facet index. + /// @param[in] n Symmetry order of the rosy field (applies the connection n times). + /// + /// @return A 4 x (2*nf) matrix representing the covariant derivative operator. + /// + Eigen::Matrix covariant_derivative_nrosy(Index fid, Index n) const; + + /// + /// Compute the discrete shape operator for a single facet (Eq. (23), de Goes et al. 2020). + /// + /// Applies the per-facet gradient to the precomputed per-vertex normals and symmetrizes the + /// result in the facet tangent plane. The returned 2x2 matrix is symmetric; its trace divided + /// by two gives the mean curvature at the facet, and its determinant gives the Gaussian + /// curvature. /// /// @param[in] fid Facet index. - /// @param[in] n Number of times to apply the connection. /// - /// @note The parameter n is designed to work with n-rosy field, where a representative - /// tangent vector is the n-time rotation of any of the n vectors in a n-rosy field. + /// @return A 2x2 symmetric matrix representing the shape operator for the given facet. /// - /// @return A matrix representing the discrete covariant derivative operator. + Eigen::Matrix shape_operator(Index fid) const; + /// - Eigen::Matrix covariant_derivative_nrosy(Index fid, Index n) const; + /// Compute the adjoint gradient operator for a single vertex (Eq. (24), de Goes et al. 2020). + /// + /// Returns a 3 x k dense matrix whose columns are the area-weighted, parallel-transported + /// per-corner gradient vectors for vertex @p vid, where k is the number of incident faces. + /// Columns are in the order of the incident-face traversal via get_first_corner_around_vertex(), + /// consistent with adjoint_shape_operator(). + /// + /// @note There is a sign correction for Eq. (24) in the implementation. + /// + /// @param[in] vid Vertex index. + /// + /// @return A 3 x k dense matrix (k = number of incident faces). + /// + Eigen::Matrix adjoint_gradient(Index vid) const; + + /// + /// Compute the adjoint shape operator for a single vertex (Eq. (26), de Goes et al. 2020). + /// + /// The adjoint shape operator is the vertex-centered dual of the per-facet shape operator. It + /// applies the adjoint gradient to the unit normals of the incident faces and symmetrizes the + /// result in the vertex tangent plane. The returned 2x2 matrix is symmetric; its trace divided + /// by two gives the mean curvature at the vertex, and its determinant gives the Gaussian + /// curvature. + /// + /// @param[in] vid Vertex index. + /// + /// @return A 2x2 symmetric matrix representing the adjoint shape operator at the vertex. + /// + Eigen::Matrix adjoint_shape_operator(Index vid) const; /// /// Compute the discrete covariant projection operator for a single facet. /// - /// The discrete covariant projection operator for a single fact is a 2*nf by 2*nf matrix, where nf - /// is the number vertices in facet. It measures the information loss when extracting the part - /// of a tangent vector field associated with a covariant derivative. + /// Measures the information loss when extracting the part of a tangent vector field associated + /// with a covariant derivative. The matrix has size (2*nf) x (2*nf), where nf is the number of + /// vertices in the facet. /// /// @param[in] fid Facet index. /// - /// @return A matrix representing the discrete covariant projection operator. + /// @return A (2*nf) x (2*nf) matrix representing the covariant projection operator. /// Eigen::Matrix covariant_projection(Index fid) const; /// - /// Compute the discrete covariant projection operator for a single facet for n-rosy fields. - /// - /// The discrete covariant projection operator for a single fact is a 2*nf by 2*nf matrix, where nf - /// is the number vertices in facet. It measures the information loss when extracting the part - /// of a tangent vector field associated with a covariant derivative. + /// n-rosy variant of covariant_projection(fid). /// /// @param[in] fid Facet index. - /// @param[in] n Number of times to apply the connection. + /// @param[in] n Symmetry order of the rosy field (applies the connection n times). /// - /// @note The parameter n is designed to work with n-rosy field, where a representative - /// tangent vector is the n-time rotation of any of the n vectors in a n-rosy field. - /// - /// @return A matrix representing the discrete covariant projection operator. + /// @return A (2*nf) x (2*nf) matrix representing the covariant projection operator. /// Eigen::Matrix covariant_projection_nrosy( Index fid, @@ -585,31 +604,25 @@ class DifferentialOperators Scalar lambda = 1) const; /// - /// Compute the discrete connection Laplacian operator for a single facet for n-rosy fields. - /// - /// The discrete connection Laplacian operator for a single facet is a 2*nf by 2*nf matrix, - /// where nf is the number vertices in facet. It computes the Laplacian of a tangent vector - /// field defined on the vertices of the facet using Levi-Civita connection for parallel - /// transport. + /// n-rosy variant of connection_laplacian(fid). /// /// @param[in] fid Facet index. - /// @param[in] n Number of times to apply the connection. + /// @param[in] n Symmetry order of the rosy field (applies the connection n times). /// @param[in] lambda Weight of projection term for the 1-form inner product (default: 1). /// - /// @note The parameter n is designed to work with n-rosy field, where a representative - /// tangent vector is the n-time rotation of any of the n vectors in a n-rosy field. - /// - /// @return A matrix representing the discrete connection Laplacian operator. + /// @return A (2*nf) x (2*nf) matrix representing the connection Laplacian operator. /// Eigen::Matrix connection_laplacian_nrosy(Index fid, Index n, Scalar lambda = 1) const; private: /// - /// Compute the per-facet vector area attribute and store it in the mesh. + /// Compute per-vertex normals as the sum of incident facet vector areas and store them in the + /// mesh. /// void compute_vertex_normal_from_vector_area(); + public: /// /// Compute the local tangent basis for a single facet. diff --git a/modules/polyddg/include/lagrange/polyddg/compute_principal_curvatures.h b/modules/polyddg/include/lagrange/polyddg/compute_principal_curvatures.h new file mode 100644 index 00000000..3373a594 --- /dev/null +++ b/modules/polyddg/include/lagrange/polyddg/compute_principal_curvatures.h @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +#include + +namespace lagrange::polyddg { + +/// @addtogroup module-polyddg +/// @{ + +/// +/// Options for compute_principal_curvatures(). +/// +struct PrincipalCurvaturesOptions +{ + /// Output attribute name for the minimum principal curvature (scalar, per vertex). + std::string_view kappa_min_attribute = "@kappa_min"; + + /// Output attribute name for the maximum principal curvature (scalar, per vertex). + std::string_view kappa_max_attribute = "@kappa_max"; + + /// Output attribute name for the principal direction of kappa_min (3-D vector, per vertex). + std::string_view direction_min_attribute = "@principal_direction_min"; + + /// Output attribute name for the principal direction of kappa_max (3-D vector, per vertex). + std::string_view direction_max_attribute = "@principal_direction_max"; +}; + +/// +/// Result of compute_principal_curvatures(). +/// +struct PrincipalCurvaturesResult +{ + /// Attribute ID of the minimum principal curvature attribute. + AttributeId kappa_min_id = invalid_attribute_id(); + + /// Attribute ID of the maximum principal curvature attribute. + AttributeId kappa_max_id = invalid_attribute_id(); + + /// Attribute ID of the principal direction for kappa_min. + AttributeId direction_min_id = invalid_attribute_id(); + + /// Attribute ID of the principal direction for kappa_max. + AttributeId direction_max_id = invalid_attribute_id(); +}; + +/// +/// Compute per-vertex principal curvatures and principal curvature directions. +/// +/// Performs an eigendecomposition of the adjoint shape operator at each vertex. The two +/// eigenvalues are the principal curvatures (kappa_min <= kappa_max) and the corresponding +/// eigenvectors, mapped back to 3-D through the vertex tangent basis, are the principal +/// directions. Results are stored as vertex attributes in the mesh. +/// +/// @param[in,out] mesh Input surface mesh. Output attributes are added or overwritten. +/// @param[in] ops Precomputed differential operators for the mesh. +/// @param[in] options Attribute name options. Defaults produce attributes named +/// @kappa_min, @kappa_max, @principal_direction_min, +/// @principal_direction_max. +/// +/// @return Attribute IDs of the four output attributes. +/// +template +LA_POLYDDG_API PrincipalCurvaturesResult compute_principal_curvatures( + SurfaceMesh& mesh, + const DifferentialOperators& ops, + PrincipalCurvaturesOptions options = {}); + +/// +/// Compute per-vertex principal curvatures and principal curvature directions. +/// +/// Convenience overload that constructs a DifferentialOperators object internally. +/// +/// @param[in,out] mesh Input surface mesh. Output attributes are added or overwritten. +/// @param[in] options Attribute name options. +/// +/// @return Attribute IDs of the four output attributes. +/// +template +LA_POLYDDG_API PrincipalCurvaturesResult compute_principal_curvatures( + SurfaceMesh& mesh, + PrincipalCurvaturesOptions options = {}); + +/// @} + +} // namespace lagrange::polyddg diff --git a/modules/polyddg/python/src/polyddg.cpp b/modules/polyddg/python/src/polyddg.cpp index ca168c6e..92dd3d30 100644 --- a/modules/polyddg/python/src/polyddg.cpp +++ b/modules/polyddg/python/src/polyddg.cpp @@ -11,6 +11,7 @@ */ #include +#include #include #include @@ -57,27 +58,21 @@ void populate_polyddg_module(nb::module_& m) .def( "star0", [](const polyddg::DifferentialOperators& self) { return self.star0(); }, - R"(Compute the discrete Hodge star operator for 0-forms. + R"(Compute the discrete Hodge star operator for 0-forms (diagonal mass matrix, size #V x #V). -The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimension of the manifold. - -:return: A sparse matrix representing the discrete Hodge star operator for 0-forms.)") +:return: A diagonal sparse matrix of size (#V, #V).)") .def( "star1", [](const polyddg::DifferentialOperators& self) { return self.star1(); }, - R"(Compute the discrete Hodge star operator for 1-forms. - -The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimension of the manifold. + R"(Compute the discrete Hodge star operator for 1-forms (diagonal mass matrix, size #E x #E). -:return: A sparse matrix representing the discrete Hodge star operator for 1-forms.)") +:return: A diagonal sparse matrix of size (#E, #E).)") .def( "star2", [](const polyddg::DifferentialOperators& self) { return self.star2(); }, - R"(Compute the discrete Hodge star operator for 2-forms. - -The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimension of the manifold. + R"(Compute the discrete Hodge star operator for 2-forms (diagonal mass matrix, size #F x #F). -:return: A sparse matrix representing the discrete Hodge star operator for 2-forms.)") +:return: A diagonal sparse matrix of size (#F, #F).)") .def( "flat", [](const polyddg::DifferentialOperators& self) { return self.flat(); }, @@ -101,6 +96,8 @@ The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimen "beta"_a = 1, R"(Compute the discrete polygonal inner product operator for 1-forms. +:param beta: Weight of projection term (default: 1). + :return: A sparse matrix representing the inner product operator for 1-forms.)") .def( "inner_product_2_form", @@ -143,23 +140,25 @@ The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimen "beta"_a = 1, R"(Compute the discrete polygonal Laplacian operator. +:param beta: Weight of projection term for the 1-form inner product (default: 1). + :return: A sparse matrix representing the Laplacian operator.)") .def( "vertex_tangent_coordinates", [](const polyddg::DifferentialOperators& self) { return self.vertex_tangent_coordinates(); }, - R"(Compute the coordinate transformation that maps a per-vertex tangent vector field expressed in the global 3D coordinate to the local tangent basis at each vertex. + R"(Compute the per-vertex coordinate transformation from the global 3D frame to the local tangent basis at each vertex. -:return: A sparse matrix representing the coordinate transformation.)") +:return: A sparse matrix of size (#V * 2, #V * 3).)") .def( "facet_tangent_coordinates", [](const polyddg::DifferentialOperators& self) { return self.facet_tangent_coordinates(); }, - R"(Compute the coordinate transformation that maps a per-facet tangent vector field expressed in the global 3D coordinate to the local tangent basis at each facet. + R"(Compute the per-facet coordinate transformation from the global 3D frame to the local tangent basis at each facet. -:return: A sparse matrix representing the coordinate transformation.)") +:return: A sparse matrix of size (#F * 2, #F * 3).)") .def( "covariant_derivative", [](const polyddg::DifferentialOperators& self) { @@ -175,11 +174,47 @@ The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimen }, nb::kw_only(), "n"_a, - R"(Compute the discrete covariant derivative operator for n-rosy fields. + R"(n-rosy variant of covariant_derivative(). -:param n: Number of times to apply the connection. +:param n: Symmetry order of the rosy field (applies the connection n times). -:return: A sparse matrix representing the covariant derivative operator.)") +:return: A sparse matrix of size (#F * 4, #V * 2).)") + .def( + "shape_operator", + [](const polyddg::DifferentialOperators& self) { + return self.shape_operator(); + }, + R"(Compute the global discrete shape operator (Eq. (23), de Goes et al. 2020). + +Maps a per-vertex 3-D normal field to per-facet 2x2 symmetrized shape operators. The returned +sparse matrix has shape ``(#F * 4, #V * 3)`` with input layout ``[n_v^x, n_v^y, n_v^z, ...]`` +per vertex and output layout ``[S(0,0), S(0,1), S(1,0), S(1,1), ...]`` per facet. + +:return: A sparse matrix of shape ``(#F * 4, #V * 3)``)") + .def( + "adjoint_gradient", + [](const polyddg::DifferentialOperators& self) { + return self.adjoint_gradient(); + }, + R"(Compute the global discrete adjoint gradient operator (Eq. (24), de Goes et al. 2020). + +The vertex-centred dual of the per-facet gradient. Maps a per-facet scalar field to per-vertex +3-D tangent vectors. The returned sparse matrix has shape ``(#V * 3, #F)``. + +:return: A sparse matrix of shape ``(#V * 3, #F)``)") + .def( + "adjoint_shape_operator", + [](const polyddg::DifferentialOperators& self) { + return self.adjoint_shape_operator(); + }, + R"(Compute the global discrete adjoint shape operator (Eq. (26), de Goes et al. 2020). + +The vertex-centred dual of the per-facet shape operator. Maps a per-facet 3-D normal field to +per-vertex 2x2 symmetrized shape operators. The returned sparse matrix has shape +``(#V * 4, #F * 3)`` with input layout ``[n_f^x, n_f^y, n_f^z, ...]`` per facet and output +layout ``[S(0,0), S(0,1), S(1,0), S(1,1), ...]`` per vertex. + +:return: A sparse matrix of shape ``(#V * 4, #F * 3)``)") .def( "levi_civita", [](const polyddg::DifferentialOperators& self) { @@ -195,11 +230,11 @@ The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimen }, nb::kw_only(), "n"_a, - R"(Compute the discrete Levi-Civita connection for n-rosy fields. + R"(n-rosy variant of levi_civita(). -:param n: Number of times to apply the connection. +:param n: Symmetry order of the rosy field (applies the connection n times). -:return: A sparse matrix representing the Levi-Civita connection.)") +:return: A sparse matrix of size (#C * 2, #V * 2).)") .def( "connection_laplacian", [](const polyddg::DifferentialOperators& self, Scalar beta) { @@ -220,27 +255,26 @@ The Hodge star operator maps a k-form to a dual (n-k)-form, where n is the dimen nb::kw_only(), "n"_a, "beta"_a = 1, - R"(Compute the discrete connection Laplacian operator for n-rosy fields. + R"(n-rosy variant of connection_laplacian(). -:param n: Number of times to apply the connection. +:param n: Symmetry order of the rosy field (applies the connection n times). :param beta: Weight of projection term for the 1-form inner product (default: 1). -:return: A sparse matrix representing the connection Laplacian operator.)") +:return: A sparse matrix of size (#V * 2, #V * 2).)") .def( "gradient", [](const polyddg::DifferentialOperators& self, Index fid) { return self.gradient(fid); }, "fid"_a, - R"(Compute the discrete gradient operator for a single facet. + R"(Compute the per-corner gradient vectors for a single facet (Eq. (8), de Goes et al. 2020). -The discrete gradient operator for a single facet is a 3 by n vector, where n is the number vertices -in the facet. It maps a scalar functions defined on the vertices to a gradient vector defined on the -facet. +Returns a ``(3, nf)`` matrix whose column ``l`` is ``(a_f x e_f^l) / (2 * |a_f|^2)``, where +``a_f`` is the facet vector area and ``e_f^l = x_{l-1} - x_{l+1}`` spans the opposite edge. :param fid: Facet index. -:return: A dense matrix representing the per-facet gradient operator.)") +:return: A dense matrix of shape ``(3, nf)``.)") .def( "d0", [](const polyddg::DifferentialOperators& self, Index fid) { @@ -378,11 +412,11 @@ the edges of the facet. "lv"_a, nb::kw_only(), "n"_a, - R"(Compute the discrete Levi-Civita connection from a vertex to a facet for n-rosy fields. + R"(n-rosy variant of levi_civita(fid, lv). :param fid: Facet index. :param lv: Local vertex index within the facet. -:param n: Number of times to apply the connection. +:param n: Symmetry order of the rosy field (applies the connection n times). :return: A 2x2 dense matrix representing the vertex-to-facet Levi-Civita connection.)") .def( @@ -404,12 +438,12 @@ the edges of the facet. "fid"_a, nb::kw_only(), "n"_a, - R"(Compute the discrete Levi-Civita connection for a single facet for n-rosy fields. + R"(n-rosy variant of levi_civita(fid). :param fid: Facet index. -:param n: Number of times to apply the connection. +:param n: Symmetry order of the rosy field (applies the connection n times). -:return: A dense matrix representing the per-facet Levi-Civita connection.)") +:return: A dense matrix of size ``(2*nf, 2*nf)`` representing the per-facet Levi-Civita connection.)") .def( "covariant_derivative", [](const polyddg::DifferentialOperators& self, Index fid) { @@ -428,12 +462,58 @@ the edges of the facet. }, "fid"_a, "n"_a, - R"(Compute the discrete covariant derivative operator for a single facet for n-rosy fields. + R"(n-rosy variant of covariant_derivative(fid). :param fid: Facet index. -:param n: Number of times to apply the connection. +:param n: Symmetry order of the rosy field (applies the connection n times). -:return: A dense matrix representing the per-facet covariant derivative operator.)") +:return: A dense matrix of size ``(4, 2*nf)`` representing the per-facet covariant derivative.)") + .def( + "shape_operator", + [](const polyddg::DifferentialOperators& self, Index fid) { + return self.shape_operator(fid); + }, + "fid"_a, + R"(Compute the discrete shape operator for a single facet (Eq. (23), de Goes et al. 2020). + +Applies the per-facet gradient to the precomputed per-vertex normals and symmetrizes the result +in the facet tangent plane. The returned 2x2 matrix is symmetric; its trace divided by two gives +the mean curvature at the facet, and its determinant gives the Gaussian curvature. + +:param fid: Facet index. + +:return: A 2x2 dense symmetric matrix.)") + .def( + "adjoint_gradient", + [](const polyddg::DifferentialOperators& self, Index vid) { + return self.adjoint_gradient(vid); + }, + "vid"_a, + R"(Compute the adjoint gradient operator for a single vertex (Eq. (24), de Goes et al. 2020). + +Returns a ``(3, k)`` dense matrix of area-weighted, parallel-transported per-corner gradient +vectors, where k is the number of incident faces. Columns are in the same incident-face traversal +order used by ``adjoint_shape_operator(vid)``. + +:param vid: Vertex index. + +:return: A dense matrix of shape ``(3, k)`` where k is the number of incident faces.)") + .def( + "adjoint_shape_operator", + [](const polyddg::DifferentialOperators& self, Index vid) { + return self.adjoint_shape_operator(vid); + }, + "vid"_a, + R"(Compute the adjoint shape operator for a single vertex (Eq. (26), de Goes et al. 2020). + +The vertex-centred dual of the per-facet shape operator. Applies the adjoint gradient to the unit +normals of the incident faces and symmetrizes the result in the vertex tangent plane. The returned +2x2 matrix is symmetric; its trace divided by two gives the mean curvature at the vertex, and its +determinant gives the Gaussian curvature. + +:param vid: Vertex index. + +:return: A 2x2 dense symmetric matrix.)") .def( "covariant_projection", [](const polyddg::DifferentialOperators& self, Index fid) { @@ -452,12 +532,12 @@ the edges of the facet. }, "fid"_a, "n"_a, - R"(Compute the discrete covariant projection operator for a single facet for n-rosy fields. + R"(n-rosy variant of covariant_projection(fid). :param fid: Facet index. -:param n: Number of times to apply the connection. +:param n: Symmetry order of the rosy field (applies the connection n times). -:return: A dense matrix representing the per-facet covariant projection operator.)") +:return: A dense matrix of size ``(2*nf, 2*nf)`` representing the per-facet covariant projection.)") .def( "connection_laplacian", [](const polyddg::DifferentialOperators& self, Index fid, Scalar beta) { @@ -482,28 +562,120 @@ the edges of the facet. nb::kw_only(), "n"_a, "beta"_a = 1, - R"(Compute the discrete connection Laplacian operator for a single facet for n-rosy fields. + R"(n-rosy variant of connection_laplacian(fid). :param fid: Facet index. -:param n: Number of times to apply the connection. +:param n: Symmetry order of the rosy field (applies the connection n times). :param beta: Weight of projection term (default: 1). -:return: A dense matrix representing the per-facet connection Laplacian operator.)") +:return: A dense matrix of size ``(2*nf, 2*nf)`` representing the per-facet connection Laplacian.)") .def_prop_ro( "vector_area_attribute_id", &polyddg::DifferentialOperators::get_vector_area_attribute_id, - "Get the attribute ID of the per-facet vector area attribute used in the differential " - "operators.") + "Attribute ID of the per-facet vector area attribute.") .def_prop_ro( "centroid_attribute_id", &polyddg::DifferentialOperators::get_centroid_attribute_id, - "Get the attribute ID of the per-facet centroid attribute used in the differential " - "operators.") + "Attribute ID of the per-facet centroid attribute.") .def_prop_ro( "vertex_normal_attribute_id", &polyddg::DifferentialOperators::get_vertex_normal_attribute_id, - "Get the attribute ID of the per-vertex normal attribute used in the differential " - "operators."); + "Attribute ID of the per-vertex normal attribute."); + + // Default attribute names are taken directly from PrincipalCurvaturesOptions to avoid + // duplication if the defaults change. + static const polyddg::PrincipalCurvaturesOptions default_pc_opts{}; + + m.def( + "compute_principal_curvatures", + [](SurfaceMesh& mesh, + const polyddg::DifferentialOperators& ops, + std::string_view kappa_min_attribute, + std::string_view kappa_max_attribute, + std::string_view direction_min_attribute, + std::string_view direction_max_attribute) { + polyddg::PrincipalCurvaturesOptions opts; + opts.kappa_min_attribute = kappa_min_attribute; + opts.kappa_max_attribute = kappa_max_attribute; + opts.direction_min_attribute = direction_min_attribute; + opts.direction_max_attribute = direction_max_attribute; + auto r = polyddg::compute_principal_curvatures(mesh, ops, opts); + return std::make_tuple( + r.kappa_min_id, + r.kappa_max_id, + r.direction_min_id, + r.direction_max_id); + }, + "mesh"_a, + "ops"_a, + nb::kw_only(), + "kappa_min_attribute"_a = default_pc_opts.kappa_min_attribute, + "kappa_max_attribute"_a = default_pc_opts.kappa_max_attribute, + "direction_min_attribute"_a = default_pc_opts.direction_min_attribute, + "direction_max_attribute"_a = default_pc_opts.direction_max_attribute, + R"(Compute per-vertex principal curvatures and principal curvature directions. + +Eigendecomposes the adjoint shape operator at each vertex using a precomputed +:class:`DifferentialOperators` instance. The eigenvalues (``kappa_min <= kappa_max``) are the +principal curvatures and the eigenvectors, mapped back to 3-D through the vertex tangent basis, +are the principal directions. All four quantities are stored as vertex attributes in the mesh. + +:param mesh: Input surface mesh (modified in place with new attributes). +:param ops: Precomputed :class:`DifferentialOperators` for the mesh. +:param kappa_min_attribute: Output attribute name for the minimum principal curvature + (default: ``"@kappa_min"``). +:param kappa_max_attribute: Output attribute name for the maximum principal curvature + (default: ``"@kappa_max"``). +:param direction_min_attribute: Output attribute name for the kappa_min direction + (default: ``"@principal_direction_min"``). +:param direction_max_attribute: Output attribute name for the kappa_max direction + (default: ``"@principal_direction_max"``). + +:return: A tuple ``(kappa_min_id, kappa_max_id, direction_min_id, direction_max_id)`` of attribute IDs.)"); + + m.def( + "compute_principal_curvatures", + [](SurfaceMesh& mesh, + std::string_view kappa_min_attribute, + std::string_view kappa_max_attribute, + std::string_view direction_min_attribute, + std::string_view direction_max_attribute) { + polyddg::PrincipalCurvaturesOptions opts; + opts.kappa_min_attribute = kappa_min_attribute; + opts.kappa_max_attribute = kappa_max_attribute; + opts.direction_min_attribute = direction_min_attribute; + opts.direction_max_attribute = direction_max_attribute; + auto r = polyddg::compute_principal_curvatures(mesh, opts); + return std::make_tuple( + r.kappa_min_id, + r.kappa_max_id, + r.direction_min_id, + r.direction_max_id); + }, + "mesh"_a, + nb::kw_only(), + "kappa_min_attribute"_a = default_pc_opts.kappa_min_attribute, + "kappa_max_attribute"_a = default_pc_opts.kappa_max_attribute, + "direction_min_attribute"_a = default_pc_opts.direction_min_attribute, + "direction_max_attribute"_a = default_pc_opts.direction_max_attribute, + R"(Compute per-vertex principal curvatures and principal curvature directions. + +Eigendecomposes the adjoint shape operator at each vertex. A :class:`DifferentialOperators` +instance is constructed internally. The eigenvalues (``kappa_min <= kappa_max``) are the +principal curvatures and the eigenvectors, mapped back to 3-D through the vertex tangent basis, +are the principal directions. All four quantities are stored as vertex attributes in the mesh. + +:param mesh: Input surface mesh (modified in place with new attributes). +:param kappa_min_attribute: Output attribute name for the minimum principal curvature + (default: ``"@kappa_min"``). +:param kappa_max_attribute: Output attribute name for the maximum principal curvature + (default: ``"@kappa_max"``). +:param direction_min_attribute: Output attribute name for the kappa_min direction + (default: ``"@principal_direction_min"``). +:param direction_max_attribute: Output attribute name for the kappa_max direction + (default: ``"@principal_direction_max"``). + +:return: A tuple ``(kappa_min_id, kappa_max_id, direction_min_id, direction_max_id)`` of attribute IDs.)"); } } // namespace lagrange::python diff --git a/modules/polyddg/src/DifferentialOperators.cpp b/modules/polyddg/src/DifferentialOperators.cpp index 9e40eecf..b49a2578 100644 --- a/modules/polyddg/src/DifferentialOperators.cpp +++ b/modules/polyddg/src/DifferentialOperators.cpp @@ -797,7 +797,7 @@ Eigen::SparseMatrix DifferentialOperators::connection_lap return Lc; } -// Per-facet gradient operator +// Per-facet gradient operator — stacks per-corner gradient vectors column-wise. template Eigen::Matrix DifferentialOperators::gradient( Index fid) const @@ -810,16 +810,12 @@ Eigen::Matrix DifferentialOperators::g const Scalar area_sq = a.squaredNorm(); Eigen::Matrix G(3, facet_size); - for (Index lv = 0; lv < facet_size; lv++) { - const Index vid_next = m_mesh.get_facet_vertex(fid, (lv + 1) % facet_size); const Index vid_prev = m_mesh.get_facet_vertex(fid, (lv + facet_size - 1) % facet_size); - Vector e = (vertices.row(vid_prev) - vertices.row(vid_next)).template head<3>(); - Vector g = a.cross(e) / (2 * area_sq); - - G.col(lv) = g.transpose(); + const Index vid_next = m_mesh.get_facet_vertex(fid, (lv + 1) % facet_size); + const Vector e = (vertices.row(vid_prev) - vertices.row(vid_next)).template head<3>(); + G.col(lv) = (a.cross(e) / (2 * area_sq)).transpose(); } - return G; } @@ -1138,6 +1134,341 @@ DifferentialOperators::connection_laplacian_nrosy(Index fid, Inde return area * G_cov.transpose() * G_cov + lambda * P_cov.transpose() * P_cov; } +// Per-facet shape operator (Eq. 23, de Goes et al. 2020) +template +Eigen::Matrix DifferentialOperators::shape_operator(Index fid) const +{ + // G_f : 3 x nf discrete gradient + // T_f : 3 x 2 local tangent frame + // N_f : nf x 3 per-vertex normals stacked row-wise + // + // S_f = (1/2) T_f^t (G_f N_f + (G_f N_f)^t) T_f [Eq. 23] + // = sym( T_f^t G_f N_f T_f ) + + auto G_f = gradient(fid); // 3 x nf + auto T_f = facet_basis(fid); // 3 x 2 + + auto vertex_normals = attribute_matrix_view(m_mesh, m_vertex_normal_id); + const Index facet_size = m_mesh.get_facet_size(fid); + + Eigen::Matrix N_f(facet_size, 3); + for (Index lv = 0; lv < facet_size; lv++) { + N_f.row(lv) = vertex_normals.row(m_mesh.get_facet_vertex(fid, lv)); + } + + // Q = T_f^t G_f N_f T_f (2x2) + Eigen::Matrix Q = T_f.transpose() * G_f * N_f * T_f; + return Scalar(0.5) * (Q + Q.transpose()); +} + +// Global shape operator (#F*4 x #V*3, maps 3D vertex normals to per-face 2x2 shape operators) +template +Eigen::SparseMatrix DifferentialOperators::shape_operator() const +{ + // Output layout per facet f (row offset fid*4): + // row 0: S_f(0,0), row 1: S_f(0,1), row 2: S_f(1,0), row 3: S_f(1,1) + // + // Input layout per vertex v (column offset vid*3): + // col 0: n_v^x, col 1: n_v^y, col 2: n_v^z + // + // From the per-facet derivation, with C_f = T_f^t G_f (2 x nf): + // + // S_f(a,b) = (1/2) sum_{lv,m} [ C_f(a,lv)*T_f(m,b) + C_f(b,lv)*T_f(m,a) ] * n_{v_lv, m} + // + // so the global-operator coefficient for output (fid*4 + a*2 + b) and input (vid*3 + m) is + // (1/2) * [ C_f(a,lv)*T_f(m,b) + C_f(b,lv)*T_f(m,a) ] + // where lv is the local index of vid within face fid. + + const Index num_facets = m_mesh.get_num_facets(); + const Index num_vertices = m_mesh.get_num_vertices(); + const Index num_corners = m_mesh.get_num_corners(); + + // Each corner contributes 4 outputs x 3 normal components = 12 triplets. + std::vector> entries; + entries.reserve(num_corners * 12); + + for (Index fid = 0; fid < num_facets; fid++) { + const Index facet_size = m_mesh.get_facet_size(fid); + auto G_f = gradient(fid); // 3 x nf + auto T_f = facet_basis(fid); // 3 x 2 + + // C_f = T_f^t * G_f (2 x nf) + Eigen::Matrix C_f = T_f.transpose() * G_f; + + for (Index lv = 0; lv < facet_size; lv++) { + const Index vid = m_mesh.get_facet_vertex(fid, lv); + + for (Eigen::Index a = 0; a < 2; a++) { + for (Eigen::Index b = 0; b < 2; b++) { + const Eigen::Index out_row = static_cast(fid * 4 + a * 2 + b); + for (Eigen::Index m = 0; m < 3; m++) { + const Eigen::Index in_col = static_cast(vid * 3 + m); + const Scalar val = + Scalar(0.5) * (C_f(a, lv) * T_f(m, b) + C_f(b, lv) * T_f(m, a)); + entries.emplace_back(out_row, in_col, val); + } + } + } + } + } + + Eigen::SparseMatrix S(num_facets * 4, num_vertices * 3); + S.setFromTriplets(entries.begin(), entries.end()); + return S; +} + +// Per-vertex adjoint gradient (Eq. 24, de Goes et al. 2020) +template +Eigen::Matrix DifferentialOperators::adjoint_gradient( + Index vid) const +{ + auto vertices = vertex_view(m_mesh); + auto vec_area = attribute_matrix_view(m_mesh, m_vector_area_id); + auto vertex_normals = attribute_matrix_view(m_mesh, m_vertex_normal_id); + + const Vector nv = vertex_normals.row(vid); + + // a_v = sum_{f incident to v} a_f / n_f + Scalar a_v = Scalar(0); + Index n_incident = 0; + { + Index c = m_mesh.get_first_corner_around_vertex(vid); + while (c != invalid()) { + const Index fid = m_mesh.get_corner_facet(c); + a_v += vec_area.row(fid).norm() / m_mesh.get_facet_size(fid); + n_incident++; + c = m_mesh.get_next_corner_around_vertex(c); + } + } + + // G̃_v : 3 × n_incident + // Column j = -(a_{f_j} / a_v) * (Q_{f_j}^v)^t * g_{f_j}^v (sign correction) + Eigen::Matrix G_adj(3, n_incident); + + Index j = 0; + Index c = m_mesh.get_first_corner_around_vertex(vid); + while (c != invalid()) { + const Index fid = m_mesh.get_corner_facet(c); + const Index lv = c - m_mesh.get_facet_corner_begin(fid); + + const Vector nf = vec_area.row(fid); + const Scalar a_f = nf.norm(); + const Scalar area_sq = a_f * a_f; + const Eigen::Matrix Q = + Eigen::Quaternion::FromTwoVectors(nv, nf).matrix(); + + const Index facet_size = m_mesh.get_facet_size(fid); + const Index vid_prev = m_mesh.get_facet_vertex(fid, (lv + facet_size - 1) % facet_size); + const Index vid_next = m_mesh.get_facet_vertex(fid, (lv + 1) % facet_size); + const Vector e = (vertices.row(vid_prev) - vertices.row(vid_next)).template head<3>(); + const Eigen::Matrix g = (nf.cross(e) / (2 * area_sq)).transpose(); + + G_adj.col(j) = -(a_f / a_v) * Q.transpose() * g; + + j++; + c = m_mesh.get_next_corner_around_vertex(c); + } + + return G_adj; +} + +// Per-vertex adjoint shape operator (Eq. 26, de Goes et al. 2020) +template +Eigen::Matrix DifferentialOperators::adjoint_shape_operator( + Index vid) const +{ + auto vec_area = attribute_matrix_view(m_mesh, m_vector_area_id); + + auto G_adj = adjoint_gradient(vid); // 3 × k + auto T_v = vertex_basis(vid); // 3 × 2 + + // N_v : k × 3, rows are unit face normals of incident faces (same traversal order) + const Index k = G_adj.cols(); + Eigen::Matrix N_v(k, 3); + + Index j = 0; + Index c = m_mesh.get_first_corner_around_vertex(vid); + while (c != invalid()) { + const Index fid = m_mesh.get_corner_facet(c); + N_v.row(j) = vec_area.row(fid).template head<3>().stableNormalized(); + j++; + c = m_mesh.get_next_corner_around_vertex(c); + } + + // Eq. (26): S̃_v = (1/2) T_v^t (G̃_v N_v + (G̃_v N_v)^t) T_v + // = sym( T_v^t G̃_v N_v T_v ) + Eigen::Matrix Q = T_v.transpose() * G_adj * N_v * T_v; + return Scalar(0.5) * (Q + Q.transpose()); +} + +// Global adjoint gradient (3*#V × #F) +template +Eigen::SparseMatrix DifferentialOperators::adjoint_gradient() const +{ + // Entry [vid*3+i, fid] = -(a_f / a_v) * ((Q_f^v)^t g_f^v)[i] + // for every (vertex, incident-face) pair. + + const Index num_vertices = m_mesh.get_num_vertices(); + const Index num_facets = m_mesh.get_num_facets(); + const Index num_corners = m_mesh.get_num_corners(); + + auto vertices = vertex_view(m_mesh); + auto vec_area = attribute_matrix_view(m_mesh, m_vector_area_id); + auto vertex_normals = attribute_matrix_view(m_mesh, m_vertex_normal_id); + + std::vector> entries; + entries.reserve(num_corners * 3); // each corner → 3 triplets (one per xyz) + + for (Index vid = 0; vid < num_vertices; vid++) { + const Vector nv = vertex_normals.row(vid); + + // Compute vertex area: a_v = sum_{f incident to v} a_f / n_f + Scalar a_v = Scalar(0); + { + Index c = m_mesh.get_first_corner_around_vertex(vid); + while (c != invalid()) { + const Index fid = m_mesh.get_corner_facet(c); + a_v += vec_area.row(fid).norm() / m_mesh.get_facet_size(fid); + c = m_mesh.get_next_corner_around_vertex(c); + } + } + + Index c = m_mesh.get_first_corner_around_vertex(vid); + while (c != invalid()) { + const Index fid = m_mesh.get_corner_facet(c); + const Index lv = c - m_mesh.get_facet_corner_begin(fid); + + const Vector nf = vec_area.row(fid); + const Scalar a_f = nf.norm(); + const Scalar area_sq = a_f * a_f; + const Eigen::Matrix Q = + Eigen::Quaternion::FromTwoVectors(nv, nf).matrix(); + + const Index facet_size = m_mesh.get_facet_size(fid); + const Index vid_prev = m_mesh.get_facet_vertex(fid, (lv + facet_size - 1) % facet_size); + const Index vid_next = m_mesh.get_facet_vertex(fid, (lv + 1) % facet_size); + const Vector e = (vertices.row(vid_prev) - vertices.row(vid_next)).template head<3>(); + const Eigen::Matrix g = (nf.cross(e) / (2 * area_sq)).transpose(); + + const Eigen::Matrix col = -(a_f / a_v) * Q.transpose() * g; + + for (Eigen::Index i = 0; i < 3; i++) { + entries.emplace_back( + static_cast(vid * 3 + i), + static_cast(fid), + col[i]); + } + + c = m_mesh.get_next_corner_around_vertex(c); + } + } + + Eigen::SparseMatrix G_adj(num_vertices * 3, num_facets); + G_adj.setFromTriplets(entries.begin(), entries.end()); + return G_adj; +} + +// Global adjoint shape operator (4*#V × 3*#F) +template +Eigen::SparseMatrix DifferentialOperators::adjoint_shape_operator() const +{ + // Output layout per vertex v (row offset vid*4): + // row 0: S̃_v(0,0), row 1: S̃_v(0,1), row 2: S̃_v(1,0), row 3: S̃_v(1,1) + // + // Input layout per face f (column offset fid*3): + // col 0: n_f^x, col 1: n_f^y, col 2: n_f^z + // + // With C̃_v = T_v^t G̃_v (2 × k) and k incident faces: + // + // S̃_v(a,b) = (1/2) sum_{j,m} [ C̃_v(a,j)*T_v(m,b) + C̃_v(b,j)*T_v(m,a) ] * n_{f_j,m} + // + // so the triplet weight for output (vid*4 + a*2+b) and input (fid_j*3+m) is + // (1/2) * [ C̃_v(a,j)*T_v(m,b) + C̃_v(b,j)*T_v(m,a) ] + + const Index num_vertices = m_mesh.get_num_vertices(); + const Index num_facets = m_mesh.get_num_facets(); + const Index num_corners = m_mesh.get_num_corners(); + + auto vertices = vertex_view(m_mesh); + auto vec_area = attribute_matrix_view(m_mesh, m_vector_area_id); + auto vertex_normals = attribute_matrix_view(m_mesh, m_vertex_normal_id); + + std::vector> entries; + entries.reserve(num_corners * 12); // each corner → 4 outputs × 3 normal components + + for (Index vid = 0; vid < num_vertices; vid++) { + const Vector nv = vertex_normals.row(vid); + const Eigen::Matrix T_v = vertex_basis(vid); + + // Compute vertex area and count incident faces + Scalar a_v = Scalar(0); + Index k = 0; + { + Index c = m_mesh.get_first_corner_around_vertex(vid); + while (c != invalid()) { + const Index fid = m_mesh.get_corner_facet(c); + a_v += vec_area.row(fid).norm() / m_mesh.get_facet_size(fid); + k++; + c = m_mesh.get_next_corner_around_vertex(c); + } + } + + // Build G̃_v (3 × k) and record incident face IDs + Eigen::Matrix G_adj_v(3, k); + std::vector incident_faces(k); + { + Index j = 0; + Index c = m_mesh.get_first_corner_around_vertex(vid); + while (c != invalid()) { + const Index fid = m_mesh.get_corner_facet(c); + const Index lv = c - m_mesh.get_facet_corner_begin(fid); + + incident_faces[j] = fid; + const Vector nf = vec_area.row(fid); + const Scalar a_f = nf.norm(); + const Scalar area_sq = a_f * a_f; + const Eigen::Matrix Q = + Eigen::Quaternion::FromTwoVectors(nv, nf).matrix(); + + const Index facet_size = m_mesh.get_facet_size(fid); + const Index vid_prev = + m_mesh.get_facet_vertex(fid, (lv + facet_size - 1) % facet_size); + const Index vid_next = m_mesh.get_facet_vertex(fid, (lv + 1) % facet_size); + const Vector e = + (vertices.row(vid_prev) - vertices.row(vid_next)).template head<3>(); + const Eigen::Matrix g = (nf.cross(e) / (2 * area_sq)).transpose(); + + G_adj_v.col(j) = -(a_f / a_v) * Q.transpose() * g; + + j++; + c = m_mesh.get_next_corner_around_vertex(c); + } + } + + // C̃_v = T_v^t * G̃_v (2 × k) + const Eigen::Matrix C_adj_v = T_v.transpose() * G_adj_v; + + for (Index j = 0; j < k; j++) { + const Index fid_j = incident_faces[j]; + for (Eigen::Index a = 0; a < 2; a++) { + for (Eigen::Index b = 0; b < 2; b++) { + const Eigen::Index out_row = static_cast(vid * 4 + a * 2 + b); + for (Eigen::Index m = 0; m < 3; m++) { + const Eigen::Index in_col = static_cast(fid_j * 3 + m); + const Scalar val = + Scalar(0.5) * (C_adj_v(a, j) * T_v(m, b) + C_adj_v(b, j) * T_v(m, a)); + entries.emplace_back(out_row, in_col, val); + } + } + } + } + } + + Eigen::SparseMatrix S_adj(num_vertices * 4, num_facets * 3); + S_adj.setFromTriplets(entries.begin(), entries.end()); + return S_adj; +} + // Compute vertex normals from vector area template void DifferentialOperators::compute_vertex_normal_from_vector_area() diff --git a/modules/polyddg/src/compute_principal_curvatures.cpp b/modules/polyddg/src/compute_principal_curvatures.cpp new file mode 100644 index 00000000..e89acb43 --- /dev/null +++ b/modules/polyddg/src/compute_principal_curvatures.cpp @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +// clang-format on + +// Include early to silence -Wmaybe-uninitialized with GCC 13 (see DifferentialOperators.cpp). +LA_IGNORE_MAYBE_UNINITIALIZED_START +#include +LA_IGNORE_MAYBE_UNINITIALIZED_END + +#include + +namespace lagrange::polyddg { + +template +PrincipalCurvaturesResult compute_principal_curvatures( + SurfaceMesh& mesh, + const DifferentialOperators& ops, + PrincipalCurvaturesOptions options) +{ + const Index num_vertices = mesh.get_num_vertices(); + + // Create or reuse output attributes. + const auto kappa_min_id = internal::find_or_create_attribute( + mesh, + options.kappa_min_attribute, + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1, + internal::ResetToDefault::No); + const auto kappa_max_id = internal::find_or_create_attribute( + mesh, + options.kappa_max_attribute, + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1, + internal::ResetToDefault::No); + const auto direction_min_id = internal::find_or_create_attribute( + mesh, + options.direction_min_attribute, + AttributeElement::Vertex, + AttributeUsage::Vector, + 3, + internal::ResetToDefault::No); + const auto direction_max_id = internal::find_or_create_attribute( + mesh, + options.direction_max_attribute, + AttributeElement::Vertex, + AttributeUsage::Vector, + 3, + internal::ResetToDefault::No); + + auto kappa_min_data = attribute_matrix_ref(mesh, kappa_min_id); + auto kappa_max_data = attribute_matrix_ref(mesh, kappa_max_id); + auto direction_min_data = attribute_matrix_ref(mesh, direction_min_id); + auto direction_max_data = attribute_matrix_ref(mesh, direction_max_id); + + // Each vertex is independent: reads from mesh attributes (const) and writes to distinct rows. + tbb::parallel_for(Index(0), num_vertices, [&](Index vid) { + // Eigendecompose the symmetric 2×2 adjoint shape operator. + // SelfAdjointEigenSolver returns eigenvalues sorted in ascending order. + Eigen::Matrix S = ops.adjoint_shape_operator(vid); + Eigen::SelfAdjointEigenSolver> solver(S); + + kappa_min_data(vid, 0) = solver.eigenvalues()(0); + kappa_max_data(vid, 0) = solver.eigenvalues()(1); + + // Map 2-D eigenvectors back to 3-D through the vertex tangent basis. + Eigen::Matrix B = ops.vertex_basis(vid); + LA_IGNORE_ARRAY_BOUNDS_BEGIN + direction_min_data.row(vid) = (B * solver.eigenvectors().col(0)).normalized().transpose(); + direction_max_data.row(vid) = (B * solver.eigenvectors().col(1)).normalized().transpose(); + LA_IGNORE_ARRAY_BOUNDS_END + }); + + return {kappa_min_id, kappa_max_id, direction_min_id, direction_max_id}; +} + +template +PrincipalCurvaturesResult compute_principal_curvatures( + SurfaceMesh& mesh, + PrincipalCurvaturesOptions options) +{ + DifferentialOperators ops(mesh); + return compute_principal_curvatures(mesh, ops, std::move(options)); +} + +#define LA_X_compute_principal_curvatures(_, Scalar, Index) \ + template LA_POLYDDG_API PrincipalCurvaturesResult compute_principal_curvatures( \ + SurfaceMesh&, \ + const DifferentialOperators&, \ + PrincipalCurvaturesOptions); \ + template LA_POLYDDG_API PrincipalCurvaturesResult compute_principal_curvatures( \ + SurfaceMesh&, \ + PrincipalCurvaturesOptions); +LA_SURFACE_MESH_X(compute_principal_curvatures, 0) + +} // namespace lagrange::polyddg diff --git a/modules/polyddg/tests/test_differential_operators.cpp b/modules/polyddg/tests/test_differential_operators.cpp index 49e5a4e8..dc8740fe 100644 --- a/modules/polyddg/tests/test_differential_operators.cpp +++ b/modules/polyddg/tests/test_differential_operators.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -428,6 +429,207 @@ TEST_CASE("DifferentialOperators", "[polyddg]") } } + SECTION("shape operator") + { + // ---- symmetry on simple meshes ---- + + SECTION("symmetry - triangle") + { + polyddg::DifferentialOperators ops(triangle_mesh); + Eigen::Matrix S = ops.shape_operator(0); + REQUIRE_THAT((S - S.transpose()).norm(), Catch::Matchers::WithinAbs(0.0, 1e-12)); + } + + SECTION("symmetry - pyramid") + { + polyddg::DifferentialOperators ops(pyramid_mesh); + for (Index fid = 0; fid < pyramid_mesh.get_num_facets(); fid++) { + Eigen::Matrix S = ops.shape_operator(fid); + REQUIRE_THAT((S - S.transpose()).norm(), Catch::Matchers::WithinAbs(0.0, 1e-12)); + } + } + + // ---- global operator dimensions on simple meshes ---- + + SECTION("global operator dimensions - triangle") + { + polyddg::DifferentialOperators ops(triangle_mesh); + auto S = ops.shape_operator(); + REQUIRE(S.rows() == 4); // 1 facet * 4 + REQUIRE(S.cols() == 9); // 3 vertices * 3 + } + + SECTION("global operator dimensions - pyramid") + { + polyddg::DifferentialOperators ops(pyramid_mesh); + auto S = ops.shape_operator(); + REQUIRE(S.rows() == 20); // 5 facets * 4 + REQUIRE(S.cols() == 15); // 5 vertices * 3 + } + + // ---- unit sphere ---- + + SECTION("unit sphere") + { + auto sphere = lagrange::testing::create_test_sphere( + lagrange::testing::CreateOptions{false, false}); + polyddg::DifferentialOperators ops(sphere); + + const Index num_vertices = sphere.get_num_vertices(); + const Index num_facets = sphere.get_num_facets(); + + SECTION("symmetry at every facet") + { + for (Index fid = 0; fid < num_facets; fid++) { + Eigen::Matrix S = ops.shape_operator(fid); + REQUIRE_THAT( + (S - S.transpose()).norm(), + Catch::Matchers::WithinAbs(0.0, 1e-12)); + } + } + + SECTION("mean curvature is positive at every facet") + { + // For a unit sphere with outward normals, H = trace(S) / 2 = 1 > 0. + for (Index fid = 0; fid < num_facets; fid++) { + const Scalar H = ops.shape_operator(fid).trace() / Scalar(2); + REQUIRE(H > 0.0); + } + } + + SECTION("mean curvature is close to 1 at every facet") + { + // The icosphere is coarse, so a generous tolerance is needed. + for (Index fid = 0; fid < num_facets; fid++) { + const Scalar H = ops.shape_operator(fid).trace() / Scalar(2); + REQUIRE_THAT(H, Catch::Matchers::WithinAbs(1.0, 0.5)); + } + } + + SECTION("global operator is consistent with per-facet shape_operator(fid)") + { + // Build the flat vertex-normal input vector (size #V * 3). + // shape_operator(fid) reads the raw stored vertex normals directly, + // so we use the same attribute here for consistency. + auto vertex_normals = + attribute_matrix_view(sphere, ops.get_vertex_normal_attribute_id()); + Eigen::Matrix normals_flat(num_vertices * 3); + for (Index vid = 0; vid < num_vertices; vid++) { + normals_flat.template segment<3>(vid * 3) = + vertex_normals.row(vid).template head<3>().transpose(); + } + + Eigen::SparseMatrix S_global = ops.shape_operator(); + REQUIRE(S_global.rows() == static_cast(num_facets * 4)); + REQUIRE(S_global.cols() == static_cast(num_vertices * 3)); + + Eigen::Matrix result = S_global * normals_flat; + + // Each 4-element block in result must match shape_operator(fid). + // Layout per facet: [S(0,0), S(0,1), S(1,0), S(1,1)] + for (Index fid = 0; fid < num_facets; fid++) { + Eigen::Matrix S_per_f = ops.shape_operator(fid); + Eigen::Matrix S_from_global; + S_from_global(0, 0) = result[fid * 4 + 0]; + S_from_global(0, 1) = result[fid * 4 + 1]; + S_from_global(1, 0) = result[fid * 4 + 2]; + S_from_global(1, 1) = result[fid * 4 + 3]; + REQUIRE_THAT( + (S_per_f - S_from_global).norm(), + Catch::Matchers::WithinAbs(0.0, 1e-10)); + } + } + } + } + + SECTION("adjoint shape operator - unit sphere") + { + // Build the sphere and its differential operators. + auto sphere = lagrange::testing::create_test_sphere( + lagrange::testing::CreateOptions{false, false}); + polyddg::DifferentialOperators ops(sphere); + + const Index num_vertices = sphere.get_num_vertices(); + const Index num_facets = sphere.get_num_facets(); + + // ---- per-vertex method ---- + + SECTION("mean curvature is positive at every vertex") + { + for (Index vid = 0; vid < num_vertices; vid++) { + Eigen::Matrix S = ops.adjoint_shape_operator(vid); + + // S must be symmetric. + REQUIRE_THAT((S - S.transpose()).norm(), Catch::Matchers::WithinAbs(0.0, 1e-12)); + + // Mean curvature H = trace(S) / 2. + // For a unit sphere with outward normals H = 1 > 0. + const Scalar H = S.trace() / Scalar(2); + REQUIRE(H > 0.0); + } + } + + SECTION("mean curvature is close to 1 everywhere") + { + // The icosphere approximation of a unit sphere is coarse (42 vertices), + // so we allow a generous tolerance. + for (Index vid = 0; vid < num_vertices; vid++) { + const Scalar H = ops.adjoint_shape_operator(vid).trace() / Scalar(2); + REQUIRE_THAT(H, Catch::Matchers::WithinAbs(1.0, 0.5)); + } + } + + // ---- global operator ---- + + SECTION("global operator is consistent with per-vertex method") + { + // Build the face-normal input vector (size 3*#F): [n_f0^x, n_f0^y, n_f0^z, n_f1^x, ...] + auto vec_area = + attribute_matrix_view(sphere, ops.get_vector_area_attribute_id()); + Eigen::Matrix face_normals(num_facets * 3); + for (Index fid = 0; fid < num_facets; fid++) { + face_normals.template segment<3>(fid * 3) = + vec_area.row(fid).template head<3>().stableNormalized().transpose(); + } + + // Apply the global operator. + Eigen::SparseMatrix S_global = ops.adjoint_shape_operator(); + REQUIRE(S_global.rows() == static_cast(num_vertices * 4)); + REQUIRE(S_global.cols() == static_cast(num_facets * 3)); + + Eigen::Matrix result = S_global * face_normals; + + // Compare each per-vertex result to the per-vertex method. + for (Index vid = 0; vid < num_vertices; vid++) { + Eigen::Matrix S_per_v = ops.adjoint_shape_operator(vid); + Eigen::Matrix S_from_global; + // Row-major layout: [S(0,0), S(0,1), S(1,0), S(1,1)] + S_from_global(0, 0) = result[vid * 4 + 0]; + S_from_global(0, 1) = result[vid * 4 + 1]; + S_from_global(1, 0) = result[vid * 4 + 2]; + S_from_global(1, 1) = result[vid * 4 + 3]; + REQUIRE_THAT( + (S_per_v - S_from_global).norm(), + Catch::Matchers::WithinAbs(0.0, 1e-10)); + } + } + + // ---- adjoint gradient size and symmetry sanity ---- + + SECTION("adjoint gradient has correct dimensions") + { + for (Index vid = 0; vid < num_vertices; vid++) { + auto G_adj = ops.adjoint_gradient(vid); + REQUIRE(G_adj.rows() == 3); + REQUIRE(G_adj.cols() > 0); // every vertex has at least one incident face + } + + Eigen::SparseMatrix G_adj_global = ops.adjoint_gradient(); + REQUIRE(G_adj_global.rows() == static_cast(num_vertices * 3)); + REQUIRE(G_adj_global.cols() == static_cast(num_facets)); + } + } + SECTION("Connection Laplacian") { SECTION("triangle") diff --git a/modules/polyscope/examples/mesh_viewer.cpp b/modules/polyscope/examples/mesh_viewer.cpp index 12825aa5..d71bf2ec 100644 --- a/modules/polyscope/examples/mesh_viewer.cpp +++ b/modules/polyscope/examples/mesh_viewer.cpp @@ -10,13 +10,15 @@ * governing permissions and limitations under the License. */ #include -#include #include #include #include -#include +#include +#include #include +#include + // clang-format off #include #include @@ -27,6 +29,37 @@ using SurfaceMesh = lagrange::SurfaceMesh32d; +void prepare_mesh(SurfaceMesh& mesh) +{ + lagrange::AttributeMatcher matcher; + matcher.element_types = lagrange::AttributeElement::Indexed; + + // 1st, unify index buffers for all non-UV indexed attributes + matcher.usages = ~lagrange::BitField(lagrange::AttributeUsage::UV); + auto ids = lagrange::find_matching_attributes(mesh, matcher); + if (!ids.empty()) { + std::vector attr_names; + for (auto id : ids) { + attr_names.emplace_back(mesh.get_attribute_name(id)); + } + lagrange::logger().info( + "Unifying index buffers for {} non-UV indexed attributes: {}", + ids.size(), + fmt::join(attr_names, ", ")); + mesh = lagrange::unify_index_buffer(mesh, ids); + } + + // 2nd, convert indexed UV attributes into corner attributes + matcher.usages = lagrange::AttributeUsage::UV; + ids = lagrange::find_matching_attributes(mesh, matcher); + for (auto id : ids) { + lagrange::logger().info( + "Converting indexed UV attribute to corner attribute: {}", + mesh.get_attribute_name(id)); + map_attribute_in_place(mesh, id, lagrange::AttributeElement::Corner); + } +} + int main(int argc, char** argv) { struct @@ -51,7 +84,8 @@ int main(int argc, char** argv) for (auto input : args.inputs) { lagrange::logger().info("Loading input mesh: {}", input.string()); auto mesh = lagrange::io::load_mesh(input); - lagrange::polyscope::register_mesh(input.stem().string(), std::move(mesh)); + prepare_mesh(mesh); + lagrange::polyscope::register_structure(input.stem().string(), std::move(mesh)); } polyscope::show(); diff --git a/modules/polyscope/src/register_attributes.h b/modules/polyscope/src/register_attributes.h index f6167748..e93004ed 100644 --- a/modules/polyscope/src/register_attributes.h +++ b/modules/polyscope/src/register_attributes.h @@ -216,7 +216,9 @@ void register_attributes(PolyscopeStructure* ps_struct, const SurfaceMesh; - if constexpr (!AttributeType::IsIndexed) { + if constexpr (AttributeType::IsIndexed) { + lagrange::logger().warn("Skipping indexed attribute: {}", name); + } else { if (!register_attribute(ps_struct, name, attr)) { lagrange::logger().warn("Skipping unsupported attribute: {}", name); } diff --git a/modules/polyscope/src/register_point_cloud.cpp b/modules/polyscope/src/register_point_cloud.cpp index b0398f4d..6d5af763 100644 --- a/modules/polyscope/src/register_point_cloud.cpp +++ b/modules/polyscope/src/register_point_cloud.cpp @@ -34,6 +34,12 @@ ::polyscope::PointCloud* register_point_cloud( return ::polyscope::registerPointCloud(std::string(name), vertex_view(mesh)); } }(); + if (mesh.get_num_vertices() > 500000) { + logger().warn( + "Mesh '{}' is a large point cloud. Rendering points as quads to improve performance.", + name); + ps_cloud->setPointRenderMode(::polyscope::PointRenderMode::Quad); + } register_attributes(ps_cloud, mesh); return ps_cloud; } diff --git a/modules/primitive/python/tests/test_rounded_cone.py b/modules/primitive/python/tests/test_rounded_cone.py index f4c616f7..3ed912c9 100644 --- a/modules/primitive/python/tests/test_rounded_cone.py +++ b/modules/primitive/python/tests/test_rounded_cone.py @@ -10,8 +10,6 @@ # governing permissions and limitations under the License. # import lagrange -import numpy as np -import pytest import math diff --git a/modules/primitive/python/tests/test_torus.py b/modules/primitive/python/tests/test_torus.py index 658a9df9..77c66401 100644 --- a/modules/primitive/python/tests/test_torus.py +++ b/modules/primitive/python/tests/test_torus.py @@ -10,8 +10,6 @@ # governing permissions and limitations under the License. # import lagrange -import numpy as np -import pytest import math diff --git a/modules/python/CMakeLists.txt b/modules/python/CMakeLists.txt index 693dcba4..cc1dc675 100644 --- a/modules/python/CMakeLists.txt +++ b/modules/python/CMakeLists.txt @@ -135,17 +135,30 @@ function(lagrange_generate_init_file) list(APPEND init_lines "from .lagrange import ${module_name}") endforeach() - # Generate __init__.py to import all modules. + # Determine Python package variant + if(LAGRANGE_NO_INTERNAL) + set(python_variant "open") + else() + set(python_variant "corp") + endif() + + # Build the import lines + set(import_lines "") + foreach(line IN ITEMS ${init_lines}) + string(APPEND import_lines "${line}\n") + endforeach() + + # Generate __init__.py to import all modules using file(GENERATE) set(init_file ${SKBUILD_PLATLIB_DIR}/lagrange/__init__.py) - file(WRITE ${init_file} [[ + file(GENERATE OUTPUT ${init_file} CONTENT "\ # # Copyright 2022 Adobe. All rights reserved. -# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# This file is licensed to you under the Apache License, Version 2.0 (the \"License\"); # you may not use this file except in compliance with the License. You may obtain a copy # of the License at http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS # OF ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. # @@ -155,12 +168,12 @@ from ._version import * del _logging, lagrange # type: ignore -# Import all modules. -]]) +# Package variant identifier +# One of: \"corp\", \"open\" +variant: str = \"${python_variant}\" - foreach(line IN ITEMS ${init_lines}) - file(APPEND ${init_file} ${line}\n) - endforeach() +# Import all modules. +${import_lines}") endfunction() function(lagrange_generate_python_binding_module) @@ -182,6 +195,7 @@ function(lagrange_generate_python_binding_module) set(init_pyi_file ${SKBUILD_PLATLIB_DIR}/lagrange/__init__.pyi) file(APPEND ${init_pyi_file} "from ._logging import logger\n") file(APPEND ${init_pyi_file} "from .core import *\n") + file(APPEND ${init_pyi_file} "\n# Package variant identifier\nvariant: str\n") # Generate stubs for python binding within the install location. get_target_property(active_modules lagrange_python LAGRANGE_ACTIVE_MODULES) diff --git a/modules/python/lagrange/_logging.py b/modules/python/lagrange/_logging.py index 161d5cce..d7b96a4a 100644 --- a/modules/python/lagrange/_logging.py +++ b/modules/python/lagrange/_logging.py @@ -16,7 +16,7 @@ if platform.system() == "Windows": colorama.just_fix_windows_console() -logger = logging.getLogger("lagrange") +logger = logging.getLogger(__name__) handler = logging.StreamHandler() formatter = logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s") diff --git a/modules/raycasting/CMakeLists.txt b/modules/raycasting/CMakeLists.txt index 0c719a4f..7659f647 100644 --- a/modules/raycasting/CMakeLists.txt +++ b/modules/raycasting/CMakeLists.txt @@ -13,14 +13,21 @@ lagrange_add_module() # 2. dependencies -lagrange_find_package(embree 3 CONFIG REQUIRED) -lagrange_include_modules(bvh) +lagrange_include_modules(bvh scene) target_link_libraries(lagrange_raycasting PUBLIC lagrange::core lagrange::bvh - embree::embree + lagrange::scene ) +if(LAGRANGE_WITH_EMBREE_3) + lagrange_find_package(embree 3 CONFIG REQUIRED) + target_compile_definitions(lagrange_raycasting PUBLIC LAGRANGE_WITH_EMBREE_3) +else() + lagrange_find_package(embree 4 CONFIG REQUIRED) +endif() +target_link_libraries(lagrange_raycasting PUBLIC embree::embree) + option(LAGRANGE_EMBREE_DEBUG "Perform error-checking when performing each single Embree call" OFF) if(LAGRANGE_EMBREE_DEBUG) target_compile_definitions(lagrange_raycasting PUBLIC LAGRANGE_EMBREE_DEBUG) @@ -38,3 +45,8 @@ endif() if(LAGRANGE_EXAMPLES) add_subdirectory(examples) endif() + +# 4. python binding +if(LAGRANGE_MODULE_PYTHON) + add_subdirectory(python) +endif() diff --git a/modules/raycasting/examples/CMakeLists.txt b/modules/raycasting/examples/CMakeLists.txt index 3c4fd6fe..b006f530 100644 --- a/modules/raycasting/examples/CMakeLists.txt +++ b/modules/raycasting/examples/CMakeLists.txt @@ -9,10 +9,13 @@ # OF ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. # -lagrange_include_modules(raycasting io) +lagrange_include_modules(raycasting io polyscope) lagrange_add_example(project_attributes project.cpp) target_link_libraries(project_attributes lagrange::raycasting lagrange::io CLI11::CLI11) lagrange_add_example(uv_image uv_image.cpp) target_link_libraries(uv_image lagrange::raycasting lagrange::io lagrange::image_io CLI11::CLI11) + +lagrange_add_example(picking_demo picking_demo.cpp) +target_link_libraries(picking_demo lagrange::raycasting lagrange::io lagrange::polyscope CLI11::CLI11) diff --git a/modules/raycasting/examples/picking_demo.cpp b/modules/raycasting/examples/picking_demo.cpp new file mode 100644 index 00000000..5f49192d --- /dev/null +++ b/modules/raycasting/examples/picking_demo.cpp @@ -0,0 +1,315 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +// clang-format off +#include +#include +#include +#include +#include +// clang-format on + +#include + +using SurfaceMesh = lagrange::SurfaceMesh32f; +using Scalar = SurfaceMesh::Scalar; +using Index = SurfaceMesh::Index; + +/// Struct to store information about a picked point on the mesh surface +struct PickedPoint +{ + uint32_t mesh_index = 0; + uint32_t instance_index = 0; + uint32_t facet_index = 0; + Eigen::Vector3f position = Eigen::Vector3f::Zero(); + Eigen::Vector3f normal = Eigen::Vector3f::Zero(); + Eigen::Vector2f barycentric_coord = Eigen::Vector2f::Zero(); + float ray_depth = 0.0f; +}; + +/// Global state for the demo +struct DemoState +{ + // Mesh and raycaster + SurfaceMesh mesh; + lagrange::raycasting::RayCaster ray_caster; + uint32_t mesh_index = 0; + std::optional selected_facet; + + // Polyscope objects + polyscope::SurfaceMesh* ps_mesh = nullptr; + polyscope::SurfaceMeshQuantity* ps_selected_facet = nullptr; + + // Picking state + std::optional picked_point; + + // UI state + bool show_normals = true; + float normal_length = 0.1f; +}; + +/// Convert screen coordinates to a ray in world space +void screen_to_ray( + float screen_x, + float screen_y, + Eigen::Vector3f& origin, + Eigen::Vector3f& direction) +{ + glm::vec2 screen_coords{screen_x, screen_y}; + glm::vec3 world_ray = polyscope::view::screenCoordsToWorldRay(screen_coords); + glm::vec3 world_pos = polyscope::view::getCameraWorldPosition(); + + origin = Eigen::Vector3f(world_pos.x, world_pos.y, world_pos.z); + direction = Eigen::Vector3f(world_ray.x, world_ray.y, world_ray.z); +} + +/// Perform ray casting and update picked point +void perform_picking(DemoState& state, float screen_x, float screen_y) +{ + // Get ray from screen coordinates + Eigen::Vector3f ray_origin, ray_direction; + screen_to_ray(screen_x, screen_y, ray_origin, ray_direction); + + // Cast the ray + auto hit = state.ray_caster.cast(ray_origin, ray_direction); + + if (hit) { + // Store the picked point + PickedPoint point; + point.mesh_index = hit->mesh_index; + point.instance_index = hit->instance_index; + point.facet_index = hit->facet_index; + point.position = hit->position; + point.normal = hit->normal.normalized(); + point.barycentric_coord = hit->barycentric_coord; + point.ray_depth = hit->ray_depth; + + if (!state.selected_facet.has_value()) { + state.selected_facet = state.mesh.create_attribute( + "picked facet", + lagrange::AttributeElement::Facet, + lagrange::AttributeUsage::Scalar, + 1); + } else { + la_runtime_assert(state.picked_point.has_value()); + } + auto selected_facets = + state.mesh.ref_attribute(state.selected_facet.value()).ref_all(); + if (state.picked_point.has_value()) { + selected_facets[state.picked_point->facet_index] = 0.0f; + } + selected_facets[point.facet_index] = 1.0f; + + state.picked_point = point; + + lagrange::logger().info( + "Picked facet {} at position ({:.3f}, {:.3f}, {:.3f}), distance: {:.3f}", + point.facet_index, + point.position.x(), + point.position.y(), + point.position.z(), + point.ray_depth); + } else { + lagrange::logger().info("No intersection found"); + } +} + +/// Update visualization of picked points +void update_visualization(DemoState& state) +{ + // Visualize current picked point + if (state.picked_point && state.ps_mesh) { + // Create a point cloud for the picked location + std::vector> points; + points.push_back( + {state.picked_point->position.x(), + state.picked_point->position.y(), + state.picked_point->position.z()}); + + auto* ps_pick = polyscope::registerPointCloud("Picked Point", points); + ps_pick->setPointColor(glm::vec3{1.0f, 0.0f, 0.0f}); + + // Show normal vector + if (state.show_normals) { + std::vector> normals; + normals.push_back( + {state.picked_point->normal.x() * state.normal_length, + state.picked_point->normal.y() * state.normal_length, + state.picked_point->normal.z() * state.normal_length}); + auto* ps_normal = + ps_pick->addVectorQuantity("Normal", normals, polyscope::VectorType::AMBIENT); + ps_normal->setEnabled(true); + ps_normal->setVectorColor(glm::vec3{0.0f, 0.0f, 1.0f}); + } + + // Highlight the picked facet + auto* ps_facet = lagrange::polyscope::register_attribute( + *state.ps_mesh, + "picked facet", + state.mesh.get_attribute(state.selected_facet.value())); + ps_facet->setEnabled(true); + if (auto ps_scalar = dynamic_cast(ps_facet)) { + ps_scalar->setColorMap("blues"); + } + } +} + +/// ImGui callback for custom UI +void draw_ui_panel(DemoState& state) +{ + ImGui::PushItemWidth(150); + + if (ImGui::CollapsingHeader("Picking Info", ImGuiTreeNodeFlags_DefaultOpen)) { + if (state.picked_point) { + ImGui::Text("Facet: %u", state.picked_point->facet_index); + ImGui::Text( + "Position: (%.3f, %.3f, %.3f)", + state.picked_point->position.x(), + state.picked_point->position.y(), + state.picked_point->position.z()); + ImGui::Text( + "Normal: (%.3f, %.3f, %.3f)", + state.picked_point->normal.x(), + state.picked_point->normal.y(), + state.picked_point->normal.z()); + ImGui::Text( + "Barycentric: (%.3f, %.3f)", + state.picked_point->barycentric_coord.x(), + state.picked_point->barycentric_coord.y()); + ImGui::Text("Ray Distance: %.3f", state.picked_point->ray_depth); + } else { + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "No point picked"); + } + } + + if (ImGui::CollapsingHeader("Visualization", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::Checkbox("Show Normals", &state.show_normals)) { + update_visualization(state); + } + if (state.show_normals) { + if (ImGui::SliderFloat("Normal Length", &state.normal_length, 0.01f, 1.0f, "%.2f")) { + update_visualization(state); + } + } + } + + if (ImGui::CollapsingHeader("Instructions", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::TextWrapped("- Click on the mesh to pick a point"); + ImGui::TextWrapped("- The red sphere shows the current pick"); + ImGui::TextWrapped("- The blue vector shows the surface normal"); + ImGui::TextWrapped("- The highlighted facet shows the picked triangle"); + } + + ImGui::PopItemWidth(); +} + +/// Polyscope user callback +void user_callback(DemoState& state) +{ + ImGuiIO& io = ImGui::GetIO(); + + // Handle mouse clicks + if (io.MouseClicked[0] && !io.WantCaptureMouse && !io.WantCaptureKeyboard) { + perform_picking(state, io.MousePos.x, io.MousePos.y); + update_visualization(state); + } + + // Draw custom UI panel + draw_ui_panel(state); +} + +int main(int argc, char** argv) +{ + struct Args + { + lagrange::fs::path input; + int log_level = 2; // info + } args; + + CLI::App app{argv[0]}; + app.add_option("input", args.input, "Input mesh file (OBJ, STL, PLY, etc.)") + ->required() + ->check(CLI::ExistingFile); + app.add_option("-l,--log-level", args.log_level, "Log level (0 = trace, 6 = off)"); + CLI11_PARSE(app, argc, argv) + + // Configure logging + spdlog::set_level(static_cast(args.log_level)); + + // Initialize Polyscope + polyscope::init(); + polyscope::options::configureImGuiStyleCallback = []() { ImGui::StyleColorsLight(); }; + + // Load mesh + lagrange::logger().info("Loading mesh: {}", args.input.string()); + lagrange::io::LoadOptions load_options; + load_options.triangulate = true; + auto mesh = lagrange::io::load_mesh(args.input, load_options); + + lagrange::logger().info( + "Loaded mesh with {} vertices and {} facets", + mesh.get_num_vertices(), + mesh.get_num_facets()); + + // Initialize demo state + DemoState state; + state.mesh = std::move(mesh); + + // Register mesh with Polyscope + state.ps_mesh = lagrange::polyscope::register_mesh("Input Mesh", state.mesh); + state.ps_mesh->setEdgeWidth(1.0); + + // Initialize raycaster + lagrange::logger().info("Building acceleration structure..."); + state.ray_caster = lagrange::raycasting::RayCaster( + lagrange::raycasting::SceneFlags::Robust, + lagrange::raycasting::BuildQuality::High); + + // Add mesh to raycaster (creates a single instance with identity transform) + state.mesh_index = state.ray_caster.add_mesh( + SurfaceMesh(state.mesh), + std::make_optional(Eigen::Affine3f::Identity())); + + // Commit updates to build the BVH + state.ray_caster.commit_updates(); + lagrange::logger().info("Acceleration structure built"); + + // Compute scene extent for normal length + Eigen::AlignedBox3f bbox; + for (const auto& p : lagrange::vertex_view(state.mesh).rowwise()) { + bbox.extend(p.transpose()); + } + state.normal_length = 0.1f * bbox.diagonal().norm(); + + // Set up user callback + polyscope::state::userCallback = [&]() { user_callback(state); }; + + lagrange::logger().info("Starting interactive session"); + lagrange::logger().info("Click on the mesh to pick points!"); + + // Start Polyscope UI + polyscope::show(); + + return 0; +} diff --git a/modules/raycasting/examples/project.cpp b/modules/raycasting/examples/project.cpp index acfc5326..0ff62cd8 100644 --- a/modules/raycasting/examples/project.cpp +++ b/modules/raycasting/examples/project.cpp @@ -35,8 +35,8 @@ struct Args // Projection options lagrange::raycasting::ProjectMode project_mode; - lagrange::raycasting::WrapMode wrap_mode = lagrange::raycasting::WrapMode::CONSTANT; - lagrange::raycasting::CastMode cast_mode = lagrange::raycasting::CastMode::BOTH_WAYS; + lagrange::raycasting::FallbackMode fallback_mode = lagrange::raycasting::FallbackMode::Constant; + lagrange::raycasting::CastMode cast_mode = lagrange::raycasting::CastMode::BothWays; std::array direction = {0, 0, 1}; double fill_value = 0.0; }; @@ -63,10 +63,11 @@ int parse_args(int argc, char const* argv[], Args& args) ->transform( CLI::CheckedTransformer(lagrange::raycasting::project_modes(), CLI::ignore_case)); app.add_option( - "--wrap-mode", - args.wrap_mode, - "Wrapping mode for non-hit vertices when using ray-casting projection mode.") - ->transform(CLI::CheckedTransformer(lagrange::raycasting::wrap_modes(), CLI::ignore_case)); + "--fallback-mode", + args.fallback_mode, + "Fallback mode for non-hit vertices when using ray-casting projection mode.") + ->transform( + CLI::CheckedTransformer(lagrange::raycasting::fallback_modes(), CLI::ignore_case)); app.add_option( "--cast-mode", args.cast_mode, @@ -147,7 +148,7 @@ int main(int argc, char const* argv[]) args.project_mode, dir, args.cast_mode, - args.wrap_mode, + args.fallback_mode, args.fill_value); auto finish_time = lagrange::get_timestamp(); auto timing = lagrange::timestamp_diff_in_seconds(start_time, finish_time); diff --git a/modules/raycasting/include/lagrange/raycasting/ClosestPointResult.h b/modules/raycasting/include/lagrange/raycasting/ClosestPointResult.h index a9f6d288..64597987 100644 --- a/modules/raycasting/include/lagrange/raycasting/ClosestPointResult.h +++ b/modules/raycasting/include/lagrange/raycasting/ClosestPointResult.h @@ -11,31 +11,6 @@ */ #pragma once -#include - -#include - -#include - -namespace lagrange { -namespace raycasting { - -template -struct ClosestPointResult -{ - // Point type - using Point = Eigen::Matrix; - - // Callback to populate triangle corner position given a (mesh_id, facet_id) - std::function populate_triangle; - - // Current best result - unsigned mesh_index = invalid(); - unsigned facet_index = invalid(); - Point closest_point; - Point barycentric_coord; -}; - -} // namespace raycasting - -} // namespace lagrange +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/include/lagrange/raycasting/EmbreeHelper.h b/modules/raycasting/include/lagrange/raycasting/EmbreeHelper.h index c3793877..fe405adf 100644 --- a/modules/raycasting/include/lagrange/raycasting/EmbreeHelper.h +++ b/modules/raycasting/include/lagrange/raycasting/EmbreeHelper.h @@ -11,16 +11,6 @@ */ #pragma once -#include -#include - -namespace lagrange { -namespace raycasting { -namespace EmbreeHelper { - -LA_RAYCASTING_API -void ensure_no_errors(const RTCDevice& device); - -} // namespace EmbreeHelper -} // namespace raycasting -} // namespace lagrange +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/include/lagrange/raycasting/EmbreeRayCaster.h b/modules/raycasting/include/lagrange/raycasting/EmbreeRayCaster.h index 31cb0bbf..350a4e20 100644 --- a/modules/raycasting/include/lagrange/raycasting/EmbreeRayCaster.h +++ b/modules/raycasting/include/lagrange/raycasting/EmbreeRayCaster.h @@ -11,1136 +11,6 @@ */ #pragma once -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace lagrange { -namespace raycasting { - -/** - * A wrapper for Embree's raycasting API to compute ray intersections with (instances of) meshes. - * Supports intersection and occlusion queries on single rays and ray packets (currently only - * packets of size at most 4 are supported). Filters may be specified (per mesh, not per instance) - * to process each individual hit event during any of these queries. - */ -template -class EmbreeRayCaster -{ -public: - using Scalar = ScalarType; - using Transform = Eigen::Matrix; - using Point = Eigen::Matrix; - using Direction = Eigen::Matrix; - using Index = size_t; - using ClosestPoint = ClosestPointResult; - using TransformVector = std::vector; - - using Point4 = Eigen::Matrix; - using Direction4 = Eigen::Matrix; - using Index4 = Eigen::Matrix; - using Scalar4 = Eigen::Matrix; - using Mask4 = Eigen::Matrix; - - using FloatData = std::vector; - using IntData = std::vector; - - /** - * Interface for a hit filter function. Most information in `RTCFilterFunctionNArguments` maps - * directly to elements of the EmbreeRayCaster class, but the mesh and instance IDs need special - * conversion. `mesh_index` is an array of `args->N` EmbreeRayCaster mesh indices, and - * `instance_index` is an array of `args->N` EmbreeRayCaster instance indices, one for each - * ray/hit. For the other elements of `args`, the mappings are: - * - * @code - * facet_index <-- primID - * ray_depth <-- tfar - * barycentric_coord <-- [u, v, 1 - u - v] - * normal <-- Ng - * @endcode - */ - using FilterFunction = std::function; - -private: - /** Select between two sets of filters stored in the object. */ - enum { FILTER_INTERSECT = 0, FILTER_OCCLUDED = 1 }; - -public: - /** Constructor. */ - EmbreeRayCaster( - RTCSceneFlags scene_flags = RTC_SCENE_FLAG_DYNAMIC, - RTCBuildQuality build_quality = RTC_BUILD_QUALITY_LOW) - { -// Embree strongly recommend to have the Flush to Zero and -// Denormals are Zero mode of the MXCSR control and status -// register enabled for each thread before calling the -// rtcIntersect and rtcOccluded functions. -#ifdef _MM_SET_FLUSH_ZERO_MODE - _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); -#endif -#ifdef _MM_SET_DENORMALS_ZERO_MODE - _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON); +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include #endif - - m_scene_flags = scene_flags; - m_build_quality = build_quality; - m_device = rtcNewDevice(NULL); - ensure_no_errors_internal(); - m_embree_world_scene = rtcNewScene(m_device); - ensure_no_errors_internal(); - m_instance_index_ranges.push_back(safe_cast(0)); - - m_need_rebuild = true; - m_need_commit = false; - } - - /** Destructor. */ - virtual ~EmbreeRayCaster() - { - release_scenes(); - rtcReleaseDevice(m_device); - } - - EmbreeRayCaster(const EmbreeRayCaster&) = delete; - void operator=(const EmbreeRayCaster&) = delete; - -public: - /** Get the total number of meshes (not instances). */ - Index get_num_meshes() const { return safe_cast(m_meshes.size()); } - - /** Get the total number of mesh instances. */ - Index get_num_instances() const { return m_instance_index_ranges.back(); } - - /** Get the number of instances of a particular mesh. */ - Index get_num_instances(Index mesh_index) const - { - la_debug_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); - return safe_cast( - m_instance_index_ranges[mesh_index + 1] - m_instance_index_ranges[mesh_index]); - } - - /** - * Get the mesh with a given index. Requires the caller to know the original type of the mesh - * (@a MeshType) in advance. - */ - template - std::shared_ptr get_mesh(Index index) const - { - la_runtime_assert(index < safe_cast(m_meshes.size())); - la_runtime_assert(m_meshes[index] != nullptr); - return dynamic_cast&>(*m_meshes[index]).get_mesh_ptr(); - } - - /** - * Get the index of the mesh corresponding to a given instance, where the instances are indexed - * sequentially starting from the instances of the first mesh, then the instances of the second - * mesh, and so on. Use get_mesh() to map the returned index to an actual mesh. - * - * @param cumulative_instance_index An integer in the range `0` to `get_num_instances() - 1` - * (both inclusive). - */ - Index get_mesh_for_instance(Index cumulative_instance_index) const - { - la_runtime_assert(cumulative_instance_index < get_num_instances()); - return m_instance_to_user_mesh[cumulative_instance_index]; - } - - /** - * Add an instance of a mesh to the scene, with a given transformation. If another instance of - * the same mesh has been previously added to the scene, the two instances will NOT be - * considered to share the same mesh, but will be treated as separate instances of separate - * meshes. To add multiple instances of the same mesh, use add_meshes(). - */ - template - Index add_mesh( - std::shared_ptr mesh, - const Transform& trans = Transform::Identity(), - RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) - { - return add_raycasting_mesh( - std::make_unique>(mesh), - trans, - build_quality); - } - - /** - * Add multiple instances of a single mesh to the scene, with given transformations. If another - * instance of the same mesh has been previously added to the scene, the new instances will NOT - * be considered to share the same mesh as the old instance, but will be treated as instances of - * a new mesh. Add all instances in a single add_meshes() call if you want to avoid this. - */ - template - Index add_meshes( - std::shared_ptr mesh, - const TransformVector& trans_vector, - RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) - { - m_meshes.push_back(std::move(std::make_unique>(mesh))); - m_transforms.insert(m_transforms.end(), trans_vector.begin(), trans_vector.end()); - m_mesh_build_qualities.push_back(build_quality); - m_visibility.resize(m_visibility.size() + trans_vector.size(), true); - for (auto& f : m_filters) { // per-mesh, not per-instance - f.push_back(nullptr); - } - Index mesh_index = safe_cast(m_meshes.size() - 1); - la_runtime_assert(m_instance_index_ranges.size() > 0); - Index instance_index = m_instance_index_ranges.back(); - la_runtime_assert(instance_index == safe_cast(m_instance_to_user_mesh.size())); - Index new_instance_size = instance_index + trans_vector.size(); - m_instance_index_ranges.push_back(new_instance_size); - m_instance_to_user_mesh.resize(new_instance_size, mesh_index); - m_need_rebuild = true; - return mesh_index; - } - - /** - * Update a particular mesh with a new mesh object. All its instances will be affected. - * - * @note If you have changed the vertices of a mesh already in the scene, and just want the - * object to reflect that, then call update_mesh_vertices() instead. - */ - template - void update_mesh( - Index index, - std::shared_ptr mesh, - RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) - { - update_raycasting_mesh( - index, - std::make_unique>(mesh), - build_quality); - } - - /** - * Update the object to reflect external changes to the vertices of a particular mesh which is - * already in the scene. All its instances will be affected. The number of vertices in the mesh, - * and their order in the vertex array, must not change. - */ - void update_mesh_vertices(Index index) - { - la_runtime_assert(index < safe_cast(m_meshes.size())); - if (m_need_rebuild) return; - - la_runtime_assert(index < safe_cast(m_embree_mesh_scenes.size())); - auto geom = rtcGetGeometry(m_embree_mesh_scenes[index], 0); - - // Update the vertex buffer in Embree - auto const& mesh = m_meshes[index]; - la_runtime_assert( - safe_cast(mesh->get_num_vertices()) == m_mesh_vertex_counts[index]); - - auto vbuf = - reinterpret_cast(rtcGetGeometryBufferData(geom, RTC_BUFFER_TYPE_VERTEX, 0)); - la_runtime_assert(vbuf); - mesh->vertices_to_float(vbuf); - rtcUpdateGeometryBuffer(geom, RTC_BUFFER_TYPE_VERTEX, 0); - - // Re-commit the mesh geometry and scene - rtcCommitGeometry(geom); - rtcCommitScene(m_embree_mesh_scenes[index]); - - // Re-commit every instance of the mesh - for (Index instance_index = m_instance_index_ranges[index]; - instance_index < m_instance_index_ranges[index + 1]; - ++instance_index) { - Index rtc_inst_id = m_instance_index_ranges[index] + instance_index; - auto geom_inst = - rtcGetGeometry(m_embree_world_scene, static_cast(rtc_inst_id)); - rtcCommitGeometry(geom_inst); - } - - // Mark the world scene as needing a re-commit (will be called lazily) - m_need_commit = true; - } - - /** Get the transform applied to a given mesh instance. */ - Transform get_transform(Index mesh_index, Index instance_index) const - { - la_runtime_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); - Index index = m_instance_index_ranges[mesh_index] + instance_index; - la_runtime_assert(index < m_instance_index_ranges[mesh_index + 1]); - la_runtime_assert(index < safe_cast(m_transforms.size())); - return m_transforms[index]; - } - - /** Update the transform applied to a given mesh instance. */ - void update_transformation(Index mesh_index, Index instance_index, const Transform& trans) - { - la_runtime_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); - Index index = m_instance_index_ranges[mesh_index] + instance_index; - la_runtime_assert(index < m_instance_index_ranges[mesh_index + 1]); - la_runtime_assert(index < safe_cast(m_transforms.size())); - m_transforms[index] = trans; - if (!m_need_rebuild) { - auto geom = rtcGetGeometry(m_embree_world_scene, static_cast(index)); - Eigen::Matrix T = trans.template cast(); - rtcSetGeometryTransform(geom, 0, RTC_FORMAT_FLOAT4X4_COLUMN_MAJOR, T.eval().data()); - rtcCommitGeometry(geom); - m_need_commit = true; - } - } - - /** Get the visibility flag of a given mesh instance. */ - bool get_visibility(Index mesh_index, Index instance_index) const - { - la_runtime_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); - Index index = m_instance_index_ranges[mesh_index] + instance_index; - la_runtime_assert(index < m_instance_index_ranges[mesh_index + 1]); - la_runtime_assert(index < safe_cast(m_visibility.size())); - return m_visibility[index]; - } - - /** Update the visibility of a given mesh index (true for visible, false for invisible). */ - void update_visibility(Index mesh_index, Index instance_index, bool visible) - { - la_runtime_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); - Index index = m_instance_index_ranges[mesh_index] + instance_index; - la_runtime_assert(index < m_instance_index_ranges[mesh_index + 1]); - la_runtime_assert(index < safe_cast(m_visibility.size())); - m_visibility[index] = visible; - if (!m_need_rebuild && - rtcGetDeviceProperty(m_device, RTC_DEVICE_PROPERTY_RAY_MASK_SUPPORTED)) { - // ^^^ else, visibility will be checked by the already bound filter - - auto geom = rtcGetGeometry(m_embree_world_scene, static_cast(index)); - rtcSetGeometryMask(geom, visible ? 0xFFFFFFFF : 0x00000000); - rtcCommitGeometry(geom); - m_need_commit = true; - } - } - - /** - * Set an intersection filter that is called for every hit on (every instance of) a mesh during - * an intersection query. The @a filter function must be callable as: - * - * @code - * void filter(const EmbreeRayCaster* obj, const Index* mesh_index, const Index* instance_index, - * const RTCFilterFunctionNArguments* args); - * @endcode - * - * It functions exactly like Embree's `rtcSetGeometryIntersectFilterFunction`, except it also - * receives a handle to this object, and mesh and instance indices specific to this object. A - * null @a filter disables intersection filtering for this mesh. - * - * @note Embree dictates that filters can be associated only with meshes (raw geometries), not - * instances. - */ - void set_intersection_filter(Index mesh_index, FilterFunction filter) - { - la_runtime_assert(mesh_index < m_filters[FILTER_INTERSECT].size()); - m_filters[FILTER_INTERSECT][mesh_index] = filter; - m_need_rebuild = true; - } - - /** - * Get the intersection filter function currently bound to a given mesh. - * - * @note Embree dictates that filters can be associated only with meshes (raw geometries), not - * instances. - */ - FilterFunction get_intersection_filter(Index mesh_index) const - { - la_runtime_assert(mesh_index < m_filters[FILTER_INTERSECT].size()); - return m_filters[FILTER_INTERSECT][mesh_index]; - } - - /** - * Set an occlusion filter that is called for every hit on (every instance of) a mesh during an - * occlusion query. The @a filter function must be callable as: - * - * @code - * void filter(const EmbreeRayCaster* obj, const Index* mesh_index, const Index* instance_index, - * const RTCFilterFunctionNArguments* args); - * @endcode - * - * It functions exactly like Embree's `rtcSetGeometryOccludedFilterFunction`, except it also - * receives a handle to this object, and mesh and instance indices specific to this object. A - * null @a filter disables occlusion filtering for this mesh. - * - * @note Embree dictates that filters can be associated only with meshes (raw geometries), not - * instances. - */ - void set_occlusion_filter(Index mesh_index, FilterFunction filter) - { - la_runtime_assert(mesh_index < m_filters[FILTER_OCCLUDED].size()); - m_filters[FILTER_OCCLUDED][mesh_index] = filter; - m_need_rebuild = true; - } - - /** - * Get the occlusion filter function currently bound to a given mesh. - * - * @note Embree dictates that filters can be associated only with meshes (raw geometries), not - * instances. - */ - FilterFunction get_occlusion_filter(Index mesh_index) const - { - la_runtime_assert(mesh_index < m_filters[FILTER_OCCLUDED].size()); - return m_filters[FILTER_OCCLUDED][mesh_index]; - } - - /** - * Call `rtcCommitScene()` on the overall scene, if it has been marked as modified. - * - * @todo Now that this is automatically called by update_internal() based on a dirty flag, can - * we make this a protected/private function? That would break the API so maybe reserve it - * for a major version. - */ - void commit_scene_changes() - { - if (!m_need_commit) return; - - rtcCommitScene(m_embree_world_scene); - m_need_commit = false; - } - - /** Throw an exception if an Embree error has occurred.*/ - void ensure_no_errors() const { EmbreeHelper::ensure_no_errors(m_device); } - - /** - * Cast a packet of up to 4 rays through the scene, returning full data of the closest - * intersections including normals and instance indices. - */ - uint32_t cast4( - uint32_t batch_size, - const Point4& origin, - const Direction4& direction, - const Mask4& mask, - Index4& mesh_index, - Index4& instance_index, - Index4& facet_index, - Scalar4& ray_depth, - Point4& barycentric_coord, - Point4& normal, - const Scalar4& tmin = Scalar4::Zero(), - const Scalar4& tmax = Scalar4::Constant(std::numeric_limits::infinity())) - { - la_debug_assert(batch_size <= 4); - - update_internal(); - - RTCRayHit4 embree_raypacket; - for (int i = 0; i < static_cast(batch_size); ++i) { - // Set ray origins - embree_raypacket.ray.org_x[i] = static_cast(origin(i, 0)); - embree_raypacket.ray.org_y[i] = static_cast(origin(i, 1)); - embree_raypacket.ray.org_z[i] = static_cast(origin(i, 2)); - - // Set ray directions - embree_raypacket.ray.dir_x[i] = static_cast(direction(i, 0)); - embree_raypacket.ray.dir_y[i] = static_cast(direction(i, 1)); - embree_raypacket.ray.dir_z[i] = static_cast(direction(i, 2)); - - // Misc - embree_raypacket.ray.tnear[i] = static_cast(tmin[i]); - embree_raypacket.ray.tfar[i] = std::isinf(tmax[i]) ? std::numeric_limits::max() - : static_cast(tmax[i]); - embree_raypacket.ray.mask[i] = 0xFFFFFFFF; - embree_raypacket.ray.id[i] = static_cast(i); - embree_raypacket.ray.flags[i] = 0; - - // Required initialization of the hit substructure - embree_raypacket.hit.geomID[i] = RTC_INVALID_GEOMETRY_ID; - embree_raypacket.hit.primID[i] = RTC_INVALID_GEOMETRY_ID; - embree_raypacket.hit.instID[0][i] = RTC_INVALID_GEOMETRY_ID; - } - - // Modify the mask to make 100% sure extra rays in the packet will be ignored - auto packet_mask = mask; - for (int i = static_cast(batch_size); i < 4; ++i) packet_mask[i] = 0; - - ensure_no_errors_internal(); - { - RTCIntersectContext context; - rtcInitIntersectContext(&context); - rtcIntersect4(packet_mask.data(), m_embree_world_scene, &context, &embree_raypacket); - } - ensure_no_errors_internal(); - - uint32_t is_hits = 0; - for (int i = 0; i < static_cast(batch_size); ++i) { - if (embree_raypacket.hit.geomID[i] != RTC_INVALID_GEOMETRY_ID) { - Index rtc_inst_id = embree_raypacket.hit.instID[0][i]; - Index rtc_mesh_id = (rtc_inst_id == RTC_INVALID_GEOMETRY_ID) - ? embree_raypacket.hit.geomID[i] - : rtc_inst_id; - assert(rtc_mesh_id < m_instance_to_user_mesh.size()); - assert(m_visibility[rtc_mesh_id]); - mesh_index[i] = m_instance_to_user_mesh[rtc_mesh_id]; - assert(mesh_index[i] + 1 < m_instance_index_ranges.size()); - assert(mesh_index[i] < safe_cast(m_meshes.size())); - instance_index[i] = rtc_mesh_id - m_instance_index_ranges[mesh_index[i]]; - facet_index[i] = embree_raypacket.hit.primID[i]; - ray_depth[i] = embree_raypacket.ray.tfar[i]; - barycentric_coord(i, 0) = - 1.0f - embree_raypacket.hit.u[i] - embree_raypacket.hit.v[i]; - barycentric_coord(i, 1) = embree_raypacket.hit.u[i]; - barycentric_coord(i, 2) = embree_raypacket.hit.v[i]; - normal(i, 0) = embree_raypacket.hit.Ng_x[i]; - normal(i, 1) = embree_raypacket.hit.Ng_y[i]; - normal(i, 2) = embree_raypacket.hit.Ng_z[i]; - is_hits = is_hits | (1 << i); - } - } - - return is_hits; - } - - /** - * Cast a packet of up to 4 rays through the scene, returning data of the closest intersections - * excluding normals and instance indices. - */ - uint32_t cast4( - uint32_t batch_size, - const Point4& origin, - const Direction4& direction, - const Mask4& mask, - Index4& mesh_index, - Index4& facet_index, - Scalar4& ray_depth, - Point4& barycentric_coord, - const Scalar4& tmin = Scalar4::Zero(), - const Scalar4& tmax = Scalar4::Constant(std::numeric_limits::infinity())) - { - Index4 instance_index; - Point4 normal; - return cast4( - batch_size, - origin, - direction, - mask, - mesh_index, - instance_index, - facet_index, - ray_depth, - barycentric_coord, - normal, - tmin, - tmax); - } - - /** - * Cast a packet of up to 4 rays through the scene and check whether they hit anything or not. - */ - uint32_t cast4( - uint32_t batch_size, - const Point4& origin, - const Direction4& direction, - const Mask4& mask, - const Scalar4& tmin = Scalar4::Zero(), - const Scalar4& tmax = Scalar4::Constant(std::numeric_limits::infinity())) - { - la_debug_assert(batch_size <= 4); - - update_internal(); - - RTCRay4 embree_raypacket; - for (int i = 0; i < static_cast(batch_size); ++i) { - // Set ray origins - embree_raypacket.org_x[i] = static_cast(origin(i, 0)); - embree_raypacket.org_y[i] = static_cast(origin(i, 1)); - embree_raypacket.org_z[i] = static_cast(origin(i, 2)); - - // Set ray directions - embree_raypacket.dir_x[i] = static_cast(direction(i, 0)); - embree_raypacket.dir_y[i] = static_cast(direction(i, 1)); - embree_raypacket.dir_z[i] = static_cast(direction(i, 2)); - - // Misc - embree_raypacket.tnear[i] = static_cast(tmin[i]); - embree_raypacket.tfar[i] = std::isinf(tmax[i]) ? std::numeric_limits::max() - : static_cast(tmax[i]); - embree_raypacket.mask[i] = 0xFFFFFFFF; - embree_raypacket.id[i] = static_cast(i); - embree_raypacket.flags[i] = 0; - } - - // Modify the mask to make 100% sure extra rays in the packet will be ignored - auto packet_mask = mask; - for (int i = static_cast(batch_size); i < 4; ++i) packet_mask[i] = 0; - - ensure_no_errors_internal(); - { - RTCIntersectContext context; - rtcInitIntersectContext(&context); - rtcOccluded4(packet_mask.data(), m_embree_world_scene, &context, &embree_raypacket); - } - ensure_no_errors_internal(); - - // If hit, the tfar field will be set to -inf. - uint32_t is_hits = 0; - for (uint32_t i = 0; i < batch_size; ++i) - if (!std::isfinite(embree_raypacket.tfar[i])) is_hits = is_hits | (1 << i); - - return is_hits; - } - - /** - * Cast a single ray through the scene, returning full data of the closest intersection - * including the normal and the instance index. - */ - bool cast( - const Point& origin, - const Direction& direction, - Index& mesh_index, - Index& instance_index, - Index& facet_index, - Scalar& ray_depth, - Point& barycentric_coord, - Point& normal, - Scalar tmin = 0, - Scalar tmax = std::numeric_limits::infinity()) - { - // Overloaded when specializing tnear and tfar - - update_internal(); - - RTCRayHit embree_rayhit; - embree_rayhit.ray.org_x = static_cast(origin.x()); - embree_rayhit.ray.org_y = static_cast(origin.y()); - embree_rayhit.ray.org_z = static_cast(origin.z()); - embree_rayhit.ray.dir_x = static_cast(direction.x()); - embree_rayhit.ray.dir_y = static_cast(direction.y()); - embree_rayhit.ray.dir_z = static_cast(direction.z()); - embree_rayhit.ray.tnear = static_cast(tmin); - embree_rayhit.ray.tfar = - std::isinf(tmax) ? std::numeric_limits::max() : static_cast(tmax); - embree_rayhit.hit.geomID = RTC_INVALID_GEOMETRY_ID; - embree_rayhit.hit.primID = RTC_INVALID_GEOMETRY_ID; - embree_rayhit.hit.instID[0] = RTC_INVALID_GEOMETRY_ID; - embree_rayhit.ray.mask = 0xFFFFFFFF; - embree_rayhit.ray.id = 0; - embree_rayhit.ray.flags = 0; - ensure_no_errors_internal(); - { - RTCIntersectContext context; - rtcInitIntersectContext(&context); - rtcIntersect1(m_embree_world_scene, &context, &embree_rayhit); - } - ensure_no_errors_internal(); - - if (embree_rayhit.hit.geomID != RTC_INVALID_GEOMETRY_ID) { - Index rtc_inst_id = embree_rayhit.hit.instID[0]; - Index rtc_mesh_id = - (rtc_inst_id == RTC_INVALID_GEOMETRY_ID) ? embree_rayhit.hit.geomID : rtc_inst_id; - assert(rtc_mesh_id < m_instance_to_user_mesh.size()); - assert(m_visibility[rtc_mesh_id]); - mesh_index = m_instance_to_user_mesh[rtc_mesh_id]; - assert(mesh_index + 1 < m_instance_index_ranges.size()); - assert(mesh_index < safe_cast(m_meshes.size())); - instance_index = rtc_mesh_id - m_instance_index_ranges[mesh_index]; - facet_index = embree_rayhit.hit.primID; - ray_depth = embree_rayhit.ray.tfar; - barycentric_coord[0] = 1.0f - embree_rayhit.hit.u - embree_rayhit.hit.v; - barycentric_coord[1] = embree_rayhit.hit.u; - barycentric_coord[2] = embree_rayhit.hit.v; - normal[0] = embree_rayhit.hit.Ng_x; - normal[1] = embree_rayhit.hit.Ng_y; - normal[2] = embree_rayhit.hit.Ng_z; - return true; - } else { - // Ray missed. - mesh_index = invalid(); - instance_index = invalid(); - facet_index = invalid(); - return false; - } - } - - /** - * Cast a single ray through the scene, returning data of the closest intersection excluding the - * normal and the instance index. - */ - bool cast( - const Point& origin, - const Direction& direction, - Index& mesh_index, - Index& facet_index, - Scalar& ray_depth, - Point& barycentric_coord, - Scalar tmin = 0, - Scalar tmax = std::numeric_limits::infinity()) - { - Index instance_index; - Point normal; - return cast( - origin, - direction, - mesh_index, - instance_index, - facet_index, - ray_depth, - barycentric_coord, - normal, - tmin, - tmax); - } - - /** Cast a single ray through the scene and check whether it hits anything or not. */ - bool cast( - const Point& origin, - const Direction& direction, - Scalar tmin = 0, - Scalar tmax = std::numeric_limits::infinity()) - { - update_internal(); - - RTCRay embree_ray; - embree_ray.org_x = static_cast(origin.x()); - embree_ray.org_y = static_cast(origin.y()); - embree_ray.org_z = static_cast(origin.z()); - embree_ray.dir_x = static_cast(direction.x()); - embree_ray.dir_y = static_cast(direction.y()); - embree_ray.dir_z = static_cast(direction.z()); - embree_ray.tnear = static_cast(tmin); - embree_ray.tfar = - std::isinf(tmax) ? std::numeric_limits::max() : static_cast(tmax); - embree_ray.mask = 0xFFFFFFFF; - embree_ray.id = 0; - embree_ray.flags = 0; - - ensure_no_errors_internal(); - { - RTCIntersectContext context; - rtcInitIntersectContext(&context); - rtcOccluded1(m_embree_world_scene, &context, &embree_ray); - } - ensure_no_errors_internal(); - - // If hit, the tfar field will be set to -inf. - return !std::isfinite(embree_ray.tfar); - } - - /** Use the underlying BVH to find the point closest to a query point. */ - ClosestPoint query_closest_point(const Point& p) const; - - /** Add raycasting utilities **/ - Index add_raycasting_mesh( - std::unique_ptr mesh, - const Transform& trans = Transform::Identity(), - RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) - { - m_meshes.push_back(std::move(mesh)); - m_transforms.push_back(trans); - m_mesh_build_qualities.push_back(build_quality); - m_visibility.push_back(true); - for (auto& f : m_filters) { // per-mesh, not per-instance - f.push_back(nullptr); - } - Index mesh_index = safe_cast(m_meshes.size() - 1); - la_runtime_assert(m_instance_index_ranges.size() > 0); - Index instance_index = m_instance_index_ranges.back(); - la_runtime_assert(instance_index == safe_cast(m_instance_to_user_mesh.size())); - m_instance_index_ranges.push_back(instance_index + 1); - m_instance_to_user_mesh.resize(instance_index + 1, mesh_index); - m_need_rebuild = true; - return mesh_index; - } - - void update_raycasting_mesh( - Index index, - std::unique_ptr mesh, - RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) - { - la_runtime_assert(mesh->get_dim() == 3); - la_runtime_assert(mesh->get_vertex_per_facet() == 3); - la_runtime_assert(index < safe_cast(m_meshes.size())); - m_meshes[index] = std::move(mesh); - m_mesh_build_qualities[index] = build_quality; - m_need_rebuild = true; // TODO: Make this more fine-grained so only the affected part of - // the Embree scene is updated - } - -protected: - /** Release internal Embree scenes */ - void release_scenes() - { - for (auto& s : m_embree_mesh_scenes) { - rtcReleaseScene(s); - } - rtcReleaseScene(m_embree_world_scene); - } - - /** Get the Embree scene flags. */ - virtual RTCSceneFlags get_scene_flags() const { return m_scene_flags; } - - /** Get the Embree geometry build quality. */ - virtual RTCBuildQuality get_scene_build_quality() const { return m_build_quality; } - - /** Update all internal structures based on the current dirty flags. */ - void update_internal() - { - if (m_need_rebuild) - generate_scene(); // full rebuild - else if (m_need_commit) - commit_scene_changes(); // just call rtcCommitScene() - } - - /** - * Build the whole Embree scene from the specified meshes, instances, etc. - * - * @todo Make the dirty flags more fine-grained so that only the changed meshes are re-sent to - * Embree. - */ - void generate_scene() - { - if (!m_need_rebuild) return; - - // Scene needs to be updated - release_scenes(); - m_embree_world_scene = rtcNewScene(m_device); - auto scene_flags = get_scene_flags(); // FIXME: or just m_scene_flags? - auto scene_build_quality = get_scene_build_quality(); // FIXME: or just m_build_quality? - rtcSetSceneFlags( - m_embree_world_scene, - scene_flags); // TODO: maybe also set RTC_SCENE_FLAG_CONTEXT_FILTER_FUNCTION - rtcSetSceneBuildQuality(m_embree_world_scene, scene_build_quality); - m_float_data.clear(); - m_int_data.clear(); - const auto num_meshes = m_meshes.size(); - la_runtime_assert(num_meshes + 1 == m_instance_index_ranges.size()); - m_embree_mesh_scenes.resize(num_meshes); - m_mesh_vertex_counts.resize(num_meshes); - ensure_no_errors_internal(); - - bool is_mask_supported = - rtcGetDeviceProperty(m_device, RTC_DEVICE_PROPERTY_RAY_MASK_SUPPORTED); - for (size_t i = 0; i < num_meshes; i++) { - // Initialize RTC meshes - const auto& mesh = m_meshes[i]; - // const auto& vertices = mesh->get_vertices(); - // const auto& facets = mesh->get_facets(); - const Index num_vertices = m_mesh_vertex_counts[i] = - safe_cast(mesh->get_num_vertices()); - const Index num_facets = safe_cast(mesh->get_num_facets()); - - auto& embree_mesh_scene = m_embree_mesh_scenes[i]; - embree_mesh_scene = rtcNewScene(m_device); - - rtcSetSceneFlags(embree_mesh_scene, scene_flags); - rtcSetSceneBuildQuality(embree_mesh_scene, scene_build_quality); - ensure_no_errors_internal(); - - RTCGeometry geom = rtcNewGeometry( - m_device, - RTC_GEOMETRY_TYPE_TRIANGLE); // EMBREE_FIXME: check if geometry gets properly - // committed - rtcSetGeometryBuildQuality(geom, m_mesh_build_qualities[i]); - // rtcSetGeometryTimeStepCount(geom, 1); - - const float* vertex_data = extract_float_data(*mesh); - const unsigned* facet_data = extract_int_data(*mesh); - - rtcSetSharedGeometryBuffer( - geom, - RTC_BUFFER_TYPE_VERTEX, - 0, - RTC_FORMAT_FLOAT3, - vertex_data, - 0, - sizeof(float) * 3, - num_vertices); - rtcSetSharedGeometryBuffer( - geom, - RTC_BUFFER_TYPE_INDEX, - 0, - RTC_FORMAT_UINT3, - facet_data, - 0, - sizeof(int) * 3, - num_facets); - - set_intersection_filter(geom, m_filters[FILTER_INTERSECT][i], is_mask_supported); - set_occlusion_filter(geom, m_filters[FILTER_OCCLUDED][i], is_mask_supported); - - rtcCommitGeometry(geom); - rtcAttachGeometry(embree_mesh_scene, geom); - rtcReleaseGeometry(geom); - ensure_no_errors_internal(); - - // Initialize RTC instances - for (Index instance_index = m_instance_index_ranges[i]; - instance_index < m_instance_index_ranges[i + 1]; - ++instance_index) { - const auto& trans = m_transforms[instance_index]; - - RTCGeometry geom_inst = rtcNewGeometry( - m_device, - RTC_GEOMETRY_TYPE_INSTANCE); // EMBREE_FIXME: check if geometry gets properly - // committed - rtcSetGeometryInstancedScene(geom_inst, embree_mesh_scene); - rtcSetGeometryTimeStepCount(geom_inst, 1); - - Eigen::Matrix T = trans.template cast(); - rtcSetGeometryTransform(geom_inst, 0, RTC_FORMAT_FLOAT4X4_COLUMN_MAJOR, T.data()); - ensure_no_errors_internal(); - - if (is_mask_supported) { - rtcSetGeometryMask( - geom_inst, - m_visibility[instance_index] ? 0xFFFFFFFF : 0x00000000); - } - ensure_no_errors_internal(); - - rtcCommitGeometry(geom_inst); - unsigned rtc_instance_id = rtcAttachGeometry(m_embree_world_scene, geom_inst); - rtcReleaseGeometry(geom_inst); - la_runtime_assert(safe_cast(rtc_instance_id) == instance_index); - ensure_no_errors_internal(); - } - - rtcCommitScene(embree_mesh_scene); - ensure_no_errors_internal(); - } - rtcCommitScene(m_embree_world_scene); - ensure_no_errors_internal(); - - m_need_rebuild = m_need_commit = false; - } - - /** Get the vertex data of a mesh as an array of floats. */ - const float* extract_float_data(const RaycasterMesh& mesh) - { - auto float_data = mesh.vertices_to_float(); - // Due to Embree bug, we have to have at least one more entry - // after the bound. Sigh... - // See https://github.com/embree/embree/issues/124 - float_data.push_back(0.0); - m_float_data.emplace_back(std::move(float_data)); - return m_float_data.back().data(); - } - - /** Get the index data of a mesh as an array of integers. */ - const unsigned* extract_int_data(const RaycasterMesh& mesh) - { - auto int_data = mesh.indices_to_int(); - // Due to Embree bug, we have to have at least one more entry - // after the bound. Sigh... - // See https://github.com/embree/embree/issues/124 - int_data.push_back(0); - m_int_data.emplace_back(std::move(int_data)); - return m_int_data.back().data(); - } - -private: - /** - * Helper function for setting intersection filters. Does NOT commit the geometry. The caller - * must explicitly call `rtcCommitGeometry()` afterwards. - */ - void set_intersection_filter(RTCGeometry geom, FilterFunction filter, bool is_mask_supported) - { - if (is_mask_supported) { - if (filter) { - rtcSetGeometryUserData(geom, this); - rtcSetGeometryIntersectFilterFunction(geom, &wrap_filter); - } else { - rtcSetGeometryIntersectFilterFunction(geom, nullptr); - } - } else { - rtcSetGeometryUserData(geom, this); - rtcSetGeometryIntersectFilterFunction(geom, &wrap_filter_and_mask); - } - } - - /** - * Helper function for setting occlusion filters. Does NOT commit the geometry. The caller must - * explicitly call `rtcCommitGeometry()` afterwards. - */ - void set_occlusion_filter(RTCGeometry geom, FilterFunction filter, bool is_mask_supported) - { - if (is_mask_supported) { - if (filter) { - rtcSetGeometryUserData(geom, this); - rtcSetGeometryOccludedFilterFunction(geom, &wrap_filter); - } else { - rtcSetGeometryOccludedFilterFunction(geom, nullptr); - } - } else { - rtcSetGeometryUserData(geom, this); - rtcSetGeometryOccludedFilterFunction(geom, &wrap_filter_and_mask); - } - } - - /** - * Embree-compatible callback function that computes indices specific to this object and then - * delegates to the user-specified filter function. - */ - template // 0: intersection, 1: occlusion - static void wrap_filter(const RTCFilterFunctionNArguments* args) - { - // Embree never actually calls a filter callback with different geometry or instance IDs - // So we can assume they are the same for all the hits in this batch. Also, every single - // mesh in this class is instanced (never used raw), so we can ignore geomID. - const auto* obj = reinterpret_cast(args->geometryUserPtr); - auto rtc_inst_id = RTCHitN_instID(args->hit, args->N, 0, 0); - assert(rtc_inst_id < obj->m_instance_to_user_mesh.size()); - - auto filter = obj->m_filters[IntersectionOrOcclusion][rtc_inst_id]; - if (!filter) { - return; - } - - Index mesh_index = obj->m_instance_to_user_mesh[rtc_inst_id]; - assert(mesh_index + 1 < obj->m_instance_index_ranges.size()); - assert(mesh_index < safe_cast(obj->m_meshes.size())); - Index instance_index = rtc_inst_id - obj->m_instance_index_ranges[mesh_index]; - - // In case Embree's implementation changes in the future, the callback should be written - // generally, without assuming the single geometry/instance condition above. - Index4 mesh_index4; - mesh_index4.fill(mesh_index); - Index4 instance_index4; - instance_index4.fill(instance_index); - - // Call the wrapped filter with the indices specific to this object - filter(obj, mesh_index4.data(), instance_index4.data(), args); - } - - /** - * Embree-compatible callback function that checks if the intersected object is visible or not, - * computes indices specific to this object, and then delegates to the user-specified filter - * function. - */ - template // 0: intersection, 1: occlusion - static void wrap_filter_and_mask(const RTCFilterFunctionNArguments* args) - { - // Embree never actually calls a filter callback with different geometry or instance IDs - // So we can assume they are the same for all the hits in this batch. Also, every single - // mesh in this class is instanced (never used raw), so we can ignore geomID. - const auto* obj = reinterpret_cast(args->geometryUserPtr); - auto rtc_inst_id = RTCHitN_instID(args->hit, args->N, 0, 0); - if (!obj->m_visibility[rtc_inst_id]) { - // Object is invisible. Make the hits of all the rays with this object invalid. - std::fill(args->valid, args->valid + args->N, 0); - return; - } - - // Delegate to the regular filtering after having checked visibility - wrap_filter(args); - } - -protected: - RTCSceneFlags m_scene_flags; - RTCBuildQuality m_build_quality; - RTCDevice m_device; - RTCScene m_embree_world_scene; - bool m_need_rebuild; // full rebuild of the scene? - bool m_need_commit; // just call rtcCommitScene() on the scene? - - // Data reservoirs for holding temporary/casted/per-geometry data. - // Length = Number of polygonal meshes - std::vector m_float_data; - std::vector m_int_data; - std::vector> m_meshes; - std::vector m_mesh_build_qualities; - std::vector m_embree_mesh_scenes; - std::vector m_mesh_vertex_counts; // for bounds-checking of buffer updates - std::vector m_filters[2]; // 0: intersection filters, 1: occlusion filters - - // Ranges of instance indices corresponding to a specific - // Mesh. For example, in a scenario with 3 meshes each of - // which has 1, 2, 5 instances, this array would be - // [0, 1, 3, 8]. - // Length = Number of user meshes + 1 - std::vector m_instance_index_ranges; - - // Mapping from (RTC-)instanced mesh to user mesh. For - // example, in a scenario with 3 meshes each of - // which has 1, 2, 5 instances, this array would be - // [0, 1, 1, 2, 2, 2, 2, 2] - // Length = Number of instanced meshes - // Note: This array is only used internally. We shouldn't - // allow the users to access anything with the indices - // used in those RTC functions. - std::vector m_instance_to_user_mesh; - - // Data reservoirs for holding instanced mesh data - // Length = Number of (world-space) instanced meshes - std::vector m_transforms; - std::vector m_visibility; - - // error checking function used internally - void ensure_no_errors_internal() const - { -#ifdef LAGRANGE_EMBREE_DEBUG - EmbreeHelper::ensure_no_errors(m_device); -#endif - } -}; - -//////////////////////////////////////////////////////////////////////////////// -// IMPLEMENTATION -//////////////////////////////////////////////////////////////////////////////// - -template -auto EmbreeRayCaster::query_closest_point(const Point& p) const -> ClosestPoint -{ - RTCPointQuery query; - query.x = (float)(p.x()); - query.y = (float)(p.y()); - query.z = (float)(p.z()); - query.radius = std::numeric_limits::max(); - query.time = 0.f; - ensure_no_errors_internal(); - - ClosestPoint result; - - // Callback to retrieve triangle corner positions - result.populate_triangle = - [&](unsigned mesh_index, unsigned facet_index, Point& v0, Point& v1, Point& v2) { - // TODO: There's no way to call this->get_mesh<> since we need to template the function - // by the (derived) type, which we don't know here... This means our only choice is so - // use the float data instead of the (maybe) double point coordinate if available. Note - // that we could also call rtcSetGeometryPointQueryFunction() when we register our mesh, - // since we know the derived type at this point. - const unsigned* face = m_int_data[mesh_index].data() + 3 * facet_index; - const float* vertices = m_float_data[mesh_index].data(); - v0 = Point(vertices[3 * face[0]], vertices[3 * face[0] + 1], vertices[3 * face[0] + 2]); - v1 = Point(vertices[3 * face[1]], vertices[3 * face[1] + 1], vertices[3 * face[1] + 2]); - v2 = Point(vertices[3 * face[2]], vertices[3 * face[2] + 1], vertices[3 * face[2] + 2]); - }; - - { - RTCPointQueryContext context; - rtcInitPointQueryContext(&context); - rtcPointQuery( - m_embree_world_scene, - &query, - &context, - &embree_closest_point, - reinterpret_cast(&result)); - assert( - result.mesh_index != RTC_INVALID_GEOMETRY_ID || - result.facet_index != RTC_INVALID_GEOMETRY_ID); - assert( - result.mesh_index != invalid() || result.facet_index != invalid()); - } - ensure_no_errors_internal(); - - return result; -} - -} // namespace raycasting -} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/Options.h b/modules/raycasting/include/lagrange/raycasting/Options.h new file mode 100644 index 00000000..d26bc23e --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/Options.h @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace lagrange { +namespace raycasting { + +/// Main projection mode +enum class ProjectMode { + CLOSEST_VERTEX [[deprecated]] = 0, ///< @deprecated Use ClosestVertex instead. + CLOSEST_POINT [[deprecated]] = 1, ///< @deprecated Use ClosestPoint instead. + RAY_CASTING [[deprecated]] = 2, ///< @deprecated Use RayCasting instead. + ClosestVertex = 0, ///< Copy attribute from the closest vertex on the source mesh. + ClosestPoint = 1, ///< Interpolate attribute from the closest point on the source mesh. + RayCasting = + 2, ///< Copy attribute by projecting along a prescribed direction on the source mesh. +}; + +/// Ray-casting mode +enum class CastMode { + ONE_WAY [[deprecated]] = 0, ///< @deprecated Use OneWay instead. + BOTH_WAYS [[deprecated]] = 1, ///< @deprecated Use BothWays instead. + OneWay = 0, ///< Cast a ray forward in the prescribed direction. + BothWays = 1, ///< Cast a ray both forward and backward in the prescribed direction. +}; + +/// Fallback mode for vertices without a hit +enum class FallbackMode { + CONSTANT [[deprecated]] = 0, ///< @deprecated Use Constant instead. + CLOSEST_VERTEX [[deprecated]] = 1, ///< @deprecated Use ClosestVertex instead. + CLOSEST_POINT [[deprecated]] = 2, ///< @deprecated Use ClosestPoint instead. + Constant = 0, ///< Fill with a constant value (defaults to 0). + ClosestVertex = 1, ///< Copy attribute from the closest vertex on the source mesh. + ClosestPoint = 2, ///< Interpolate attribute from the closest point on the source mesh. +}; + +using WrapMode [[deprecated]] = FallbackMode; ///< @deprecated Use FallbackMode instead. + +inline const std::map& project_modes() +{ + LA_IGNORE_DEPRECATION_WARNING_BEGIN + static std::map _modes = { + {"CLOSEST_VERTEX", ProjectMode::CLOSEST_VERTEX}, + {"CLOSEST_POINT", ProjectMode::CLOSEST_POINT}, + {"RAY_CASTING", ProjectMode::RAY_CASTING}, + {"ClosestVertex", ProjectMode::ClosestVertex}, + {"ClosestPoint", ProjectMode::ClosestPoint}, + {"RayCasting", ProjectMode::RayCasting}, + }; + LA_IGNORE_DEPRECATION_WARNING_END + return _modes; +} + +inline const std::map& cast_modes() +{ + LA_IGNORE_DEPRECATION_WARNING_BEGIN + static std::map _modes = { + {"ONE_WAY", CastMode::ONE_WAY}, + {"BOTH_WAYS", CastMode::BOTH_WAYS}, + {"OneWay", CastMode::OneWay}, + {"BothWays", CastMode::BothWays}, + }; + LA_IGNORE_DEPRECATION_WARNING_END + return _modes; +} + +inline const std::map& fallback_modes() +{ + LA_IGNORE_DEPRECATION_WARNING_BEGIN + static std::map _modes = { + {"CONSTANT", FallbackMode::CONSTANT}, + {"CLOSEST_VERTEX", FallbackMode::CLOSEST_VERTEX}, + {"CLOSEST_POINT", FallbackMode::CLOSEST_POINT}, + {"Constant", FallbackMode::Constant}, + {"ClosestVertex", FallbackMode::ClosestVertex}, + {"ClosestPoint", FallbackMode::ClosestPoint}, + }; + LA_IGNORE_DEPRECATION_WARNING_END + return _modes; +} + +[[deprecated]] inline const std::map& wrap_modes() +{ + return fallback_modes(); +} + +/// +/// @addtogroup group-raycasting +/// @{ +/// + +/// +/// Common options for projection functions. +/// +struct ProjectCommonOptions +{ + /// Additional vertex attribute ids to project (beyond vertex positions). + std::vector attribute_ids; + + /// Whether to project vertex positions. Defaults to true. When true, the vertex position + /// attribute (SurfaceMesh::attr_id_vertex_to_position) is automatically appended to the list + /// of projected attributes. + bool project_vertices = true; + + /// If provided, whether to skip assignment for a target vertex or not. This can be used for + /// partial assignment (e.g., to only set boundary vertices of a mesh). + std::function skip_vertex = nullptr; +}; + +/// +/// Options for project_directional(). +/// +struct ProjectDirectionalOptions : public ProjectCommonOptions +{ + /// Raycasting direction to project attributes. + /// + /// This can be one of three types: + /// - `std::monostate` (default): Use per-vertex normals from the *target* mesh. If no vertex + /// normal attribute exists on the target mesh, one is automatically computed via + /// `compute_vertex_normal`. + /// - `Eigen::Vector3f`: A uniform direction for all rays (will be normalized internally). + /// - `AttributeId`: An attribute id referencing a per-vertex Normal attribute on the + /// *target* mesh, providing a per-vertex ray direction. + std::variant direction = {}; + + /// Whether to project forward along the ray, or both forward and backward. + CastMode cast_mode = CastMode::BothWays; + + /// Fallback mode for vertices where there is no hit. + FallbackMode fallback_mode = FallbackMode::Constant; + + /// Scalar used to fill attributes in Constant fallback mode. + double default_value = 0.0; + + /// Optional callback invoked for each target vertex with (vertex index, was_hit). Called + /// regardless of hit/miss. This is useful to determine if the directional raycast was a hit or + /// if the fallback method was used. + std::function user_callback = nullptr; +}; + +/// +/// Options for project(). +/// +struct ProjectOptions : ProjectDirectionalOptions +{ + /// Projection mode to choose from. + ProjectMode project_mode = ProjectMode::ClosestPoint; +}; + +/// @} + +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/RayCaster.h b/modules/raycasting/include/lagrange/raycasting/RayCaster.h new file mode 100644 index 00000000..deac2cd8 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/RayCaster.h @@ -0,0 +1,775 @@ +/* + * Copyright 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace lagrange::raycasting { + +/// +/// Shared base struct for ray and closest point hits. +/// +struct HitBase +{ + /// Index of the mesh that was hit. + uint32_t mesh_index = invalid(); + + /// Index of the instance that was hit (relative to the source mesh). + uint32_t instance_index = invalid(); + + /// Index of the facet that was hit. + uint32_t facet_index = invalid(); + + /// Barycentric coordinates of the hit point within the hit facet. Given a triangle (p1, p2, + /// p3), the barycentric coordinates (u, v) are such that the surface point is represented by p + /// = (1 - u - v) * p1 + u * p2 + v * p3. + Eigen::Vector2f barycentric_coord = Eigen::Vector2f::Zero(); + + /// World-space position of the hit point. + Eigen::Vector3f position = Eigen::Vector3f::Zero(); +}; + +/// +/// Result of a single closest point query. +/// +struct ClosestPointHit : public HitBase +{ + /// Distance from the query point to the closest point on the surface. + float distance = std::numeric_limits::infinity(); +}; + +/// +/// Result of a single-ray intersection query. +/// +struct RayHit : public HitBase +{ + /// Parametric distance along the ray (t value). + float ray_depth = 0; + + /// Unnormalized geometric normal at the hit point. + Eigen::Vector3f normal = Eigen::Vector3f::Zero(); +}; + +/// +/// Shared base struct for multi-ray and multi-point hits. +/// +/// @tparam N Number of rays/points in the packet (e.g., 4, 8, or 16). +/// +template +struct HitBaseN +{ + /// Bitmask indicating which rays in the packet hit something (1 bit per ray). + uint32_t valid_mask = 0; + + /// Index of the mesh that was hit. + Eigen::Vector mesh_indices = + Eigen::Vector::Constant(invalid()); + + /// Index of the instance that was hit (relative to the source mesh). + Eigen::Vector instance_indices = + Eigen::Vector::Constant(invalid()); + + /// Index of the facet that was hit. + Eigen::Vector facet_indices = + Eigen::Vector::Constant(invalid()); + + /// Barycentric coordinates of the hit points within the hit facets. Given a triangle (p1, p2, + /// p3), the barycentric coordinates (u, v) are such that the surface point is represented by p + /// = (1 - u - v) * p1 + u * p2 + v * p3. + Eigen::Matrix barycentric_coords = Eigen::Matrix::Zero(); + + /// World-space position of the hit point. + Eigen::Matrix positions = Eigen::Matrix::Zero(); + + bool is_valid(size_t i) const { return (valid_mask & (1u << i)) != 0; } +}; + +/// +/// Result of a multi-point closest point query. +/// +/// @tparam N Number of points in the packet (e.g., 4, 8, or 16). +/// +template +struct ClosestPointHitN : public HitBaseN +{ + /// Distance from the query points to the closest points on the surface. + Eigen::Vector distances = + Eigen::Vector::Constant(std::numeric_limits::infinity()); +}; + +/// +/// Result of a multi-ray intersection query. +/// +/// @tparam N Number of rays in the packet (e.g., 4, 8, or 16). +/// +template +struct RayHitN : public HitBaseN +{ + /// Parametric distance along the ray (t value). + Eigen::Vector ray_depths = Eigen::Vector::Zero(); + + /// Unnormalized geometric normal at the hit point. + Eigen::Matrix normals = Eigen::Matrix::Zero(); +}; + +/// Flags for configuring the ray caster and the underlying Embree scene. These flags are passed to +/// the RayCaster constructor and control how the BVH is built and traversed. +enum class SceneFlags { + /// No special behavior. + None = 0, + + /// Indicates that the scene will be updated frequently. + Dynamic = 1 << 0, + + /// Use a more compact BVH layout that may be faster to build but slower to traverse. + Compact = 1 << 1, + + /// Use a more robust BVH traversal algorithm that is slower but less likely to miss hits due to + /// numerical issues. + Robust = 1 << 2, + + /// Enable user-defined intersection and occlusion filters. + Filter = 1 << 3, +}; + +/// +/// Quality levels for BVH construction. Higher quality typically results in faster ray queries but +/// longer build times. +/// +enum class BuildQuality { + /// Fastest build time, lowest BVH quality. + Low = 0, + + /// Moderate build time and BVH quality. + Medium = 1, + + /// Slowest build time, highest BVH quality. + High = 2, +}; + +/// +/// A ray caster built on top of Embree that operates directly on SurfaceMesh and SimpleScene +/// objects. Supports single-ray and SIMD ray-packet queries (packets of 4, 8, and 16 rays) for +/// efficient vectorized intersection and occlusion testing. +/// +/// @note All internal computation is performed in single-precision (float32). When a +/// SurfaceMesh is provided, vertex positions and transforms are converted +/// to float, which may result in precision loss for meshes with large coordinates or +/// fine geometric detail. +/// +/// @note Query methods (cast, occluded, closest_point, closest_vertex and their packet +/// variants) are thread-safe and may be called concurrently from multiple threads. +/// However, scene update methods (add_mesh, add_instance, add_scene, update_mesh, +/// update_vertices, update_transform, update_visibility, commit_updates, and filter +/// setters) are **not** thread-safe and must not be called concurrently with each other +/// or with query methods. +/// +class RayCaster +{ +public: + /// 3D point type. + using Pointf = Eigen::Vector3f; + + /// 3D direction type. + using Directionf = Eigen::Vector3f; + + /// 4x4 affine transform type (column-major). + template + using Affine = Eigen::Transform; + + /// Hit result type. + using Hit = RayHit; + + /// @name Batch types for ray packets of size 4. + /// @{ + using Point4f = Eigen::Matrix; + using Direction4f = Eigen::Matrix; + using Float4 = Eigen::Vector; + using Mask4 = Eigen::Vector; + using RayHit4 = RayHitN<4>; + using ClosestPointHit4 = ClosestPointHitN<4>; + /// @} + + /// @name Batch types for ray packets of size 8. + /// @{ + using Point8f = Eigen::Matrix; + using Direction8f = Eigen::Matrix; + using Float8 = Eigen::Vector; + using Mask8 = Eigen::Vector; + using RayHit8 = RayHitN<8>; + using ClosestPointHit8 = ClosestPointHitN<8>; + /// @} + + /// @name Batch types for ray packets of size 16. + /// @{ + using Point16f = Eigen::Matrix; + using Direction16f = Eigen::Matrix; + using Float16 = Eigen::Vector; + using Mask16 = Eigen::Vector; + using RayHit16 = RayHitN<16>; + using ClosestPointHit16 = ClosestPointHitN<16>; + /// @} + +public: + /// @name Construction + /// @{ + + /// + /// Construct a RayCaster with the given Embree scene configuration. + /// + /// @param[in] scene_flags Embree scene flags. + /// @param[in] build_quality Embree BVH build quality. + /// + explicit RayCaster( + BitField scene_flags = SceneFlags::Robust, + BuildQuality build_quality = BuildQuality::Medium); + + /// Destructor. + ~RayCaster(); + + /// Move constructor. + RayCaster(RayCaster&& other) noexcept; + + /// Move assignment. + RayCaster& operator=(RayCaster&& other) noexcept; + + /// Non-copyable. + RayCaster(const RayCaster&) = delete; + + /// Non-copyable. + RayCaster& operator=(const RayCaster&) = delete; + + /// @} + + /// @name Scene population + /// @{ + + /// + /// Add a single mesh to the scene. The mesh must be a triangle mesh. If the optional transform + /// is provided, a single instance of the mesh is created with that transform. By default, the + /// mesh is added with an identity transform (i.e., a single instance with no transformation). + /// + /// @note When using Scalar==float or Index==uint32_t, the RayCaster is able to reuse the + /// memory buffers for the BVH construction through copy-on-write semantics. If a + /// different scalar or index type is used, a copy will be created internally with + /// the correct data type. + /// + /// @param[in] mesh Triangle mesh to add. The mesh data is moved to the raycaster and + /// buffers are converted to the appropriate types. + /// @param[in] transform Optional affine transformation applied to this instance. If not + /// provided, the mesh is not instantiated. + /// + /// @tparam Scalar Mesh scalar type. + /// @tparam Index Mesh index type. + /// + /// @return The index of the source mesh in the raycaster scene. + /// + template + uint32_t add_mesh( + SurfaceMesh mesh, + const std::optional>& transform = Affine::Identity()); + + /// + /// Add a single instance of an existing source mesh to the scene with a given affine transform. + /// + /// @note When using Scalar==float or Index==uint32_t, the RayCaster is able to reuse the + /// memory buffers for the BVH construction through copy-on-write semantics. If a + /// different scalar or index type is used, a copy will be created internally with + /// the correct data type. + /// + /// @param[in] mesh_index Index of the source mesh. + /// @param[in] transform Affine transformation applied to this instance. + /// + /// @return The index of the local instance in the raycaster scene (relative to other + /// instances from the same source mesh). + /// + uint32_t add_instance(uint32_t mesh_index, const Eigen::Affine3f& transform); + + /// + /// Add all meshes and instances from a SimpleScene. + /// + /// @param[in] simple_scene Scene containing meshes and their instances. The scene data is + /// moved to the raycaster and mesh buffers are converted to the + /// appropriate types. + /// + /// @tparam Scalar Scene scalar type. + /// @tparam Index Scene index type. + /// + template + void add_scene(scene::SimpleScene simple_scene); + + /// + /// Notify the raycaster that all pending updates have been made and the BVH can be rebuilt. + /// This must be called explicitly by the user before doing any ray-tracing or closest point + /// queries. + /// + void commit_updates(); + + /// @} + + /// @name Scene modification + /// @{ + + /// + /// Replace a mesh in the scene. All instances of the old mesh will reference the new mesh. + /// Triggers a full BVH rebuild. + /// + /// @param[in] mesh_index Index of the mesh to replace. + /// @param[in] mesh New triangle mesh. + /// + /// @tparam Scalar Mesh scalar type. + /// @tparam Index Mesh index type. + /// + template + void update_mesh(uint32_t mesh_index, const SurfaceMesh& mesh); + + /// + /// Notify the raycaster that vertices of a mesh have been modified externally. The number and + /// order of vertices must not change. + /// + /// @note The current API does not allow updating vertex positions without creating a copy + /// of the vertices positions (because of copy-on-write and value semantics). If + /// this is necessary, we should consider adding a separate method for that, based + /// on intended use cases. + /// + /// @param[in] mesh_index Index of the mesh whose vertices changed. + /// @param[in] mesh The modified mesh with updated vertex positions. + /// + /// @tparam Scalar Mesh scalar type. + /// @tparam Index Mesh index type. + /// + template + void update_vertices(uint32_t mesh_index, const SurfaceMesh& mesh); + + /// + /// Notify the raycaster that vertices of a mesh have been modified externally. The number and + /// order of vertices must not change. + /// + /// @param[in] mesh_index Index of the mesh whose vertices changed. + /// @param[in] vertices Updated vertex positions. + /// + /// @tparam Scalar Positions scalar type. + /// + template + void update_vertices(uint32_t mesh_index, span vertices); + + /// + /// Get the affine transform of a given mesh instance. + /// + /// @param[in] mesh_index Index of the source mesh. + /// @param[in] instance_index Local instance index relative to other instances of the same + /// source mesh. + /// + /// @return The current affine transform of the instance. + /// + Eigen::Affine3f get_transform(uint32_t mesh_index, uint32_t instance_index) const; + + /// + /// Update the affine transform of a given mesh instance. + /// + /// @param[in] mesh_index Index of the source mesh. + /// @param[in] instance_index Local instance index relative to other instances of the same + /// source mesh. + /// @param[in] transform New affine transform to apply. + /// + void update_transform( + uint32_t mesh_index, + uint32_t instance_index, + const Eigen::Affine3f& transform); + + /// + /// Get the visibility flag of a given mesh instance. + /// + /// @param[in] mesh_index Index of the source mesh. + /// @param[in] instance_index Local instance index relative to the source mesh. + /// + /// @return True if the instance is visible. + /// + bool get_visibility(uint32_t mesh_index, uint32_t instance_index); + + /// + /// Update the visibility of a given mesh instance. + /// + /// @param[in] mesh_index Index of the source mesh. + /// @param[in] instance_index Local instance index relative to the source mesh. + /// @param[in] visible True to make the instance visible, false to hide it. + /// + void update_visibility(uint32_t mesh_index, uint32_t instance_index, bool visible); + + /// @} + + /// @name Filtering + /// @{ + + /// + /// Get the intersection filter function currently bound to a given mesh. + /// + /// @param[in] mesh_index Index of the mesh. + /// + /// @return The current intersection filter, or an empty function if none is set. + /// + auto get_intersection_filter(uint32_t mesh_index) const + -> std::function; + + /// + /// Set an intersection filter that is called for every hit on (every instance of) a mesh during + /// an intersection query. The filter should return true to accept the hit, false to reject it. + /// + /// @param[in] mesh_index Index of the mesh. + /// @param[in] filter Filter function, or an empty function to disable filtering. + /// + void set_intersection_filter( + uint32_t mesh_index, + std::function&& filter); + + /// + /// Get the occlusion filter function currently bound to a given mesh. + /// + /// @param[in] mesh_index Index of the mesh. + /// + /// @return The current occlusion filter, or an empty function if none is set. + /// + auto get_occlusion_filter(uint32_t mesh_index) const + -> std::function; + + /// + /// Set an occlusion filter that is called for every hit on (every instance of) a mesh during an + /// occlusion query. The filter should return true to accept the hit, false to reject it. + /// + /// @param[in] mesh_index Index of the mesh. + /// @param[in] filter Filter function, or an empty function to disable filtering. + /// + void set_occlusion_filter( + uint32_t mesh_index, + std::function&& filter); + + /// @} + + /// @name Closest point queries + /// @{ + + /// + /// Find the closest point on the scene to a query point. + /// + /// @param[in] query_point The query point in world space. + /// + /// @return A hit result if a closest point was found, or std::nullopt otherwise. + /// + std::optional closest_point(const Pointf& query_point) const; + + /// + /// Find the closest point on the scene for a packet of up to 4 query points. + /// + /// @param[in] query_points Query points, one per row (4x3). + /// @param[in] active Either a per-point activity mask (true = active, false = + /// inactive), or the number of active points starting from the top + /// (e.g. 3 means points 0, 1, and 2 are active, point 3 and beyond + /// are inactive). + /// + /// @return Closest point results for each query point in the packet, with a bitmask + /// indicating valid results. + /// + ClosestPointHit4 closest_point4( + const Point4f& query_points, + const std::variant& active) const; + + /// + /// Find the closest point on the scene for a packet of up to 8 query points. + /// + /// @param[in] query_points Query points, one per row (8x3). + /// @param[in] active Either a per-point activity mask (true = active, false = + /// inactive), or the number of active points starting from the top + /// (e.g. 3 means points 0, 1, and 2 are active, point 3 and beyond + /// are inactive). + /// + /// @return Closest point results for each query point in the packet, with a bitmask + /// indicating valid results. + /// + ClosestPointHit8 closest_point8( + const Point8f& query_points, + const std::variant& active) const; + + /// + /// Find the closest point on the scene for a packet of up to 16 query points. + /// + /// @param[in] query_points Query points, one per row (16x3). + /// @param[in] active Either a per-point activity mask (true = active, false = + /// inactive), or the number of active points starting from the top + /// (e.g. 3 means points 0, 1, and 2 are active, point 3 and beyond + /// are inactive). + /// + /// @return Closest point results for each query point in the packet, with a bitmask + /// indicating valid results. + /// + ClosestPointHit16 closest_point16( + const Point16f& query_points, + const std::variant& active) const; + + /// @} + + /// @name Closest vertex queries + /// @{ + + /// + /// Find the closest vertex on the scene to a query point. Unlike closest_point(), this snaps + /// the result to the nearest triangle vertex rather than returning the closest surface point. + /// + /// @param[in] query_point The query point in world space. + /// + /// @return A hit result if a closest vertex was found, or std::nullopt otherwise. The + /// position field contains the snapped vertex position, and the barycentric + /// coordinates will have exactly one component equal to 1. + /// + std::optional closest_vertex(const Pointf& query_point) const; + + /// + /// Find the closest vertex on the scene for a packet of up to 4 query points. + /// + /// @param[in] query_points Query points, one per row (4x3). + /// @param[in] active Either a per-point activity mask (true = active, false = + /// inactive), or the number of active points starting from the top + /// (e.g. 3 means points 0, 1, and 2 are active, point 3 and beyond + /// are inactive). + /// + /// @return Closest vertex results for each query point in the packet, with a bitmask + /// indicating valid results. + /// + ClosestPointHit4 closest_vertex4( + const Point4f& query_points, + const std::variant& active) const; + + /// + /// Find the closest vertex on the scene for a packet of up to 8 query points. + /// + /// @param[in] query_points Query points, one per row (8x3). + /// @param[in] active Either a per-point activity mask (true = active, false = + /// inactive), or the number of active points starting from the top + /// (e.g. 3 means points 0, 1, and 2 are active, point 3 and beyond + /// are inactive). + /// + /// @return Closest vertex results for each query point in the packet, with a bitmask + /// indicating valid results. + /// + ClosestPointHit8 closest_vertex8( + const Point8f& query_points, + const std::variant& active) const; + + /// + /// Find the closest vertex on the scene for a packet of up to 16 query points. + /// + /// @param[in] query_points Query points, one per row (16x3). + /// @param[in] active Either a per-point activity mask (true = active, false = + /// inactive), or the number of active points starting from the top + /// (e.g. 3 means points 0, 1, and 2 are active, point 3 and beyond + /// are inactive). + /// + /// @return Closest vertex results for each query point in the packet, with a bitmask + /// indicating valid results. + /// + ClosestPointHit16 closest_vertex16( + const Point16f& query_points, + const std::variant& active) const; + + /// @} + + + /// @name Single-ray queries + /// @{ + + /// + /// Cast a single ray and find the closest intersection. + /// + /// @param[in] origin Ray origin. + /// @param[in] direction Ray direction (does not need to be normalized). + /// @param[in] tmin Minimum parametric distance. + /// @param[in] tmax Maximum parametric distance. + /// + /// @return Hit result if an intersection was found, or std::nullopt otherwise. + /// + std::optional cast( + const Pointf& origin, + const Directionf& direction, + float tmin = 0, + float tmax = std::numeric_limits::infinity()) const; + + /// + /// Test whether a single ray hits anything in the scene (occlusion query). + /// + /// @param[in] origin Ray origin. + /// @param[in] direction Ray direction (does not need to be normalized). + /// @param[in] tmin Minimum parametric distance. + /// @param[in] tmax Maximum parametric distance. + /// + /// @return True if the ray is occluded (hits something). + /// + bool occluded( + const Pointf& origin, + const Directionf& direction, + float tmin = 0, + float tmax = std::numeric_limits::infinity()) const; + + /// @} + + /// @name Ray packet queries (4-wide SIMD) + /// @{ + + /// + /// Cast a packet of up to 4 rays and find the closest intersections. + /// + /// @param[in] origins Ray origins, one per row (4x3). + /// @param[in] directions Ray directions, one per row (4x3). + /// @param[in] active Either a per-ray activity mask (true = active, false = inactive), or + /// the number of active rays starting from the top (e.g. 3 means rays + /// 0, 1, and 2 are active, ray 3 is inactive). + /// @param[in] tmin Per-ray minimum parametric distances. + /// @param[in] tmax Per-ray maximum parametric distances. + /// + /// @return Hit results for each ray in the packet, with a bitmask indicating valid hits. + /// + RayHit4 cast4( + const Point4f& origins, + const Direction4f& directions, + const std::variant& active = size_t(4), + const Float4& tmin = Float4::Zero(), + const Float4& tmax = Float4::Constant(std::numeric_limits::infinity())) const; + + /// + /// Test a packet of up to 4 rays for occlusion. + /// + /// @param[in] origins Ray origins, one per row (4x3). + /// @param[in] directions Ray directions, one per row (4x3). + /// @param[in] active Either a per-ray activity mask (true = active, false = inactive), or + /// the number of active rays starting from the top (e.g. 3 means rays + /// 0, 1, and 2 are active, ray 3 is inactive). + /// @param[in] tmin Per-ray minimum parametric distances. + /// @param[in] tmax Per-ray maximum parametric distances. + /// + /// @return Bitmask of which rays are occluded (bit i set if ray i hit something). + /// + uint32_t occluded4( + const Point4f& origins, + const Direction4f& directions, + const std::variant& active = size_t(4), + const Float4& tmin = Float4::Zero(), + const Float4& tmax = Float4::Constant(std::numeric_limits::infinity())) const; + + /// @} + + /// @name Ray packet queries (8-wide SIMD) + /// @{ + + /// + /// Cast a packet of up to 8 rays and find the closest intersections. + /// + /// @param[in] origins Ray origins, one per row (8x3). + /// @param[in] directions Ray directions, one per row (8x3). + /// @param[in] active Either a per-ray activity mask (true = active, false = inactive), or + /// the number of active rays starting from the top (e.g. 3 means rays + /// 0, 1, and 2 are active, ray 3 and beyond are inactive). + /// @param[in] tmin Per-ray minimum parametric distances. + /// @param[in] tmax Per-ray maximum parametric distances. + /// + /// @return Hit results for each ray in the packet, with a bitmask indicating valid hits. + /// + RayHit8 cast8( + const Point8f& origins, + const Direction8f& directions, + const std::variant& active = size_t(8), + const Float8& tmin = Float8::Zero(), + const Float8& tmax = Float8::Constant(std::numeric_limits::infinity())) const; + + /// + /// Test a packet of up to 8 rays for occlusion. + /// + /// @param[in] origins Ray origins, one per row (8x3). + /// @param[in] directions Ray directions, one per row (8x3). + /// @param[in] active Either a per-ray activity mask (true = active, false = inactive), or + /// the number of active rays starting from the top (e.g. 3 means rays + /// 0, 1, and 2 are active, ray 3 and beyond are inactive). + /// @param[in] tmin Per-ray minimum parametric distances. + /// @param[in] tmax Per-ray maximum parametric distances. + /// + /// @return Bitmask of which rays are occluded (bit i set if ray i hit something). + /// + uint32_t occluded8( + const Point8f& origins, + const Direction8f& directions, + const std::variant& active = size_t(8), + const Float8& tmin = Float8::Zero(), + const Float8& tmax = Float8::Constant(std::numeric_limits::infinity())) const; + + /// @} + + /// @name Ray packet queries (16-wide SIMD) + /// @{ + + /// + /// Cast a packet of up to 16 rays and find the closest intersections. + /// + /// @param[in] origins Ray origins, one per row (16x3). + /// @param[in] directions Ray directions, one per row (16x3). + /// @param[in] active Either a per-ray activity mask (true = active, false = inactive), or + /// the number of active rays starting from the top (e.g. 3 means rays + /// 0, 1, and 2 are active, ray 3 and beyond are inactive). + /// @param[in] tmin Per-ray minimum parametric distances. + /// @param[in] tmax Per-ray maximum parametric distances. + /// + /// @return Hit results for each ray in the packet, with a bitmask indicating valid hits. + /// + RayHit16 cast16( + const Point16f& origins, + const Direction16f& directions, + const std::variant& active = size_t(16), + const Float16& tmin = Float16::Zero(), + const Float16& tmax = Float16::Constant(std::numeric_limits::infinity())) const; + + /// + /// Test a packet of up to 16 rays for occlusion. + /// + /// @param[in] origins Ray origins, one per row (16x3). + /// @param[in] directions Ray directions, one per row (16x3). + /// @param[in] active Either a per-ray activity mask (true = active, false = inactive), or + /// the number of active rays starting from the top (e.g. 3 means rays + /// 0, 1, and 2 are active, ray 3 and beyond are inactive). + /// @param[in] tmin Per-ray minimum parametric distances. + /// @param[in] tmax Per-ray maximum parametric distances. + /// + /// @return Bitmask of which rays are occluded (bit i set if ray i hit something). + /// + uint32_t occluded16( + const Point16f& origins, + const Direction16f& directions, + const std::variant& active = size_t(16), + const Float16& tmin = Float16::Zero(), + const Float16& tmax = Float16::Constant(std::numeric_limits::infinity())) const; + + /// @} + +private: + struct Impl; + value_ptr m_impl; +}; + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/include/lagrange/raycasting/RayCasterMesh.h b/modules/raycasting/include/lagrange/raycasting/RayCasterMesh.h index 0fddecb4..4e06d22c 100644 --- a/modules/raycasting/include/lagrange/raycasting/RayCasterMesh.h +++ b/modules/raycasting/include/lagrange/raycasting/RayCasterMesh.h @@ -10,124 +10,7 @@ * governing permissions and limitations under the License. */ #pragma once -#include -#include -#include -namespace lagrange { -namespace raycasting { - - -class RaycasterMesh -{ -public: - using Index = size_t; - - virtual ~RaycasterMesh() {} - - virtual Index get_dim() const = 0; - virtual Index get_vertex_per_facet() const = 0; - virtual Index get_num_vertices() const = 0; - virtual Index get_num_facets() const = 0; - - virtual std::vector vertices_to_float() const = 0; - virtual std::vector indices_to_int() const = 0; - - virtual void vertices_to_float(float* buf) const = 0; // buf must be preallocated - virtual void indices_to_int(unsigned* buf) const = 0; // buf must be preallocated -}; - - -template -class RaycasterMeshDerived : public RaycasterMesh -{ -public: - using Parent = RaycasterMesh; - using Index = Parent::Index; - - RaycasterMeshDerived(std::shared_ptr mesh) - : m_mesh(std::move(mesh)) - { - la_runtime_assert(m_mesh, "Mesh cannot be null"); - } - - std::shared_ptr get_mesh_ptr() const { return m_mesh; } - - Index get_dim() const override { return m_mesh->get_dim(); } - Index get_vertex_per_facet() const override { return m_mesh->get_vertex_per_facet(); } - Index get_num_vertices() const override { return m_mesh->get_num_vertices(); }; - Index get_num_facets() const override { return m_mesh->get_num_facets(); }; - - std::vector vertices_to_float() const override - { - auto& data = m_mesh->get_vertices(); - const Index rows = safe_cast(data.rows()); - const Index cols = (get_dim() == 3 ? safe_cast(data.cols()) : 3); - const Index size = rows * cols; - - // Due to Embree bug, we have to reserve space for one extra entry. - // See https://github.com/embree/embree/issues/124 - std::vector float_data; - float_data.reserve(size + 1); - float_data.resize(size); - vertices_to_float(float_data.data()); - - return float_data; - } - - std::vector indices_to_int() const override - { - auto& data = m_mesh->get_facets(); - const Index rows = safe_cast(data.rows()); - const Index cols = safe_cast(data.cols()); - const Index size = rows * cols; - - // Due to Embree bug, we have to reserve space for one extra entry. - // See https://github.com/embree/embree/issues/124 - std::vector int_data; - int_data.reserve(size + 1); - int_data.resize(size); - indices_to_int(int_data.data()); - - return int_data; - } - - void vertices_to_float(float* buf) const override - { - auto& data = m_mesh->get_vertices(); - const Index rows = safe_cast(data.rows()); - - if (get_dim() == 3) { - const Index cols = safe_cast(data.cols()); - for (Index i = 0; i < rows; ++i) { - for (Index j = 0; j < cols; ++j) { - *(buf++) = safe_cast(data(i, j)); - } - } - } else if (get_dim() == 2) { - for (Index i = 0; i < rows; ++i) { - for (Index j = 0; j < 2; ++j) { - *(buf++) = safe_cast(data(i, j)); - } - *(buf++) = 0.0f; - } - } - } - - void indices_to_int(unsigned* buf) const override - { - auto& data = m_mesh->get_facets(); - const Index rows = safe_cast(data.rows()); - const Index cols = safe_cast(data.cols()); - for (Index i = 0; i < rows; ++i) { - for (Index j = 0; j < cols; ++j) { - *(buf++) = safe_cast(data(i, j)); - } - } - } - - std::shared_ptr m_mesh; -}; - -} // namespace raycasting -} // namespace lagrange +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/include/lagrange/raycasting/create_ray_caster.h b/modules/raycasting/include/lagrange/raycasting/create_ray_caster.h index 10621681..7d65cdfa 100644 --- a/modules/raycasting/include/lagrange/raycasting/create_ray_caster.h +++ b/modules/raycasting/include/lagrange/raycasting/create_ray_caster.h @@ -11,60 +11,6 @@ */ #pragma once -#include -#include - -#include - -namespace lagrange { -namespace raycasting { - -enum RayCasterType { - EMBREE_DEFAULT = 1, ///< Corresponds to RTC_SCENE_FLAG_NONE - EMBREE_DYNAMIC = 2, ///< Corresponds to RTC_SCENE_FLAG_DYNAMIC - EMBREE_ROBUST = 4, ///< Corresponds to RTC_SCENE_FLAG_ROBUST - EMBREE_COMPACT = 8, ///< Corresponds to RTC_SCENE_FLAG_COMPACT -}; - -enum RayCasterQuality { - BUILD_QUALITY_LOW, ///< Corresponds to RTC_BUILD_QUALITY_LOW - BUILD_QUALITY_MEDIUM, ///< Corresponds to RTC_BUILD_QUALITY_MEDIUM - BUILD_QUALITY_HIGH, ///< Corresponds to RTC_BUILD_QUALITY_HIGH -}; - -template -std::unique_ptr> create_ray_caster( - RayCasterType engine, - RayCasterQuality quality = BUILD_QUALITY_LOW) -{ - if (engine & 0b1111) { - // Translate scene flags for embree ray casters - int flags = static_cast(RTC_SCENE_FLAG_NONE); - if (engine & EMBREE_DYNAMIC) { - flags |= static_cast(RTC_SCENE_FLAG_DYNAMIC); - } - if (engine & EMBREE_ROBUST) { - flags |= static_cast(RTC_SCENE_FLAG_ROBUST); - } - if (engine & EMBREE_COMPACT) { - flags |= static_cast(RTC_SCENE_FLAG_COMPACT); - } - - // Translate build quality settings - RTCBuildQuality build = RTC_BUILD_QUALITY_LOW; - switch (quality) { - case BUILD_QUALITY_LOW: build = RTC_BUILD_QUALITY_LOW; break; - case BUILD_QUALITY_MEDIUM: build = RTC_BUILD_QUALITY_MEDIUM; break; - case BUILD_QUALITY_HIGH: build = RTC_BUILD_QUALITY_HIGH; break; - default: break; - } - - return std::make_unique>(static_cast(flags), build); - } else { - std::stringstream err_msg; - err_msg << "Unknown ray caster engine: " << engine; - throw std::runtime_error(err_msg.str()); - } -} -} // namespace raycasting -} // namespace lagrange +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/include/lagrange/raycasting/embree_closest_point.h b/modules/raycasting/include/lagrange/raycasting/embree_closest_point.h index 241135b8..65bd2561 100644 --- a/modules/raycasting/include/lagrange/raycasting/embree_closest_point.h +++ b/modules/raycasting/include/lagrange/raycasting/embree_closest_point.h @@ -1,96 +1,16 @@ -// Source: https://github.com/embree/embree/blob/master/tutorials/closest_point/closest_point.cpp -// SPDX-License-Identifier: Apache-2.0 -// -// This file has been modified by Adobe. -// -// All modifications are Copyright 2020 Adobe. - +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ #pragma once -#include - -#include - -#include -#include -#include -#include - -namespace lagrange { -namespace raycasting { - -template -bool embree_closest_point(RTCPointQueryFunctionArguments* args) -{ - using Point = typename ClosestPointResult::Point; - using MapType = Eigen::Map; - using AffineMat = Eigen::Transform; - - assert(args->userPtr); - auto result = reinterpret_cast*>(args->userPtr); - - const unsigned int geomID = args->geomID; - const unsigned int primID = args->primID; - - RTCPointQueryContext* context = args->context; - const unsigned int stack_size = args->context->instStackSize; - const unsigned int stack_ptr = stack_size - 1; - - AffineMat inst2world = - (stack_size > 0 ? AffineMat(MapType(context->inst2world[stack_ptr]).template cast()) - : AffineMat::Identity()); - - // Query position in world space - Point q(args->query->x, args->query->y, args->query->z); - - // Get triangle information in local space - Point v0, v1, v2; - assert(result->populate_triangle); - result->populate_triangle(geomID, primID, v0, v1, v2); - - // Bring query and primitive data in the same space if necessary. - if (stack_size > 0 && args->similarityScale > 0) { - // Instance transform is a similarity transform, therefore we - // can compute distance information in instance space. Therefore, - // transform query position into local instance space. - AffineMat world2inst(MapType(context->world2inst[stack_ptr]).template cast()); - q = world2inst * q; - } else if (stack_size > 0) { - // Instance transform is not a similarity tranform. We have to transform the - // primitive data into world space and perform distance computations in - // world space to ensure correctness. - v0 = inst2world * v0; - v1 = inst2world * v1; - v2 = inst2world * v2; - } else { - // Primitive is not instanced, therefore point query and primitive are - // already in the same space. - } - - // Determine distance to closest point on triangle, and transform in - // world space if necessary. - Point p; - Scalar l1, l2, l3; - Scalar d2 = point_triangle_squared_distance(q, v0, v1, v2, p, l1, l2, l3); - float d = std::sqrt(static_cast(d2)); - if (args->similarityScale > 0) { - d = d / args->similarityScale; - } - - // Store result in userPtr and update the query radius if we found a point - // closer to the query position. This is optional but allows for faster - // traversal (due to better culling). - if (d < args->query->radius) { - args->query->radius = d; - result->closest_point = (args->similarityScale > 0 ? (inst2world * p).eval() : p); - result->mesh_index = geomID; - result->facet_index = primID; - result->barycentric_coord = Point(l1, l2, l3); - return true; // Return true to indicate that the query radius changed. - } - - return false; -} - -} // namespace raycasting -} // namespace lagrange +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/ClosestPointResult.h b/modules/raycasting/include/lagrange/raycasting/legacy/ClosestPointResult.h new file mode 100644 index 00000000..23c54639 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/ClosestPointResult.h @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include + +#include + +#include + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { + +template +struct ClosestPointResult +{ + // Point type + using Point = Eigen::Matrix; + + // Callback to populate triangle corner position given a (mesh_id, facet_id) + std::function populate_triangle; + + // Current best result + unsigned mesh_index = invalid(); + unsigned facet_index = invalid(); + Point closest_point; + Point barycentric_coord; +}; + +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/EmbreeHelper.h b/modules/raycasting/include/lagrange/raycasting/legacy/EmbreeHelper.h new file mode 100644 index 00000000..c21e8416 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/EmbreeHelper.h @@ -0,0 +1,37 @@ +/* + * Copyright 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include + +#ifdef LAGRANGE_WITH_EMBREE_3 + #include +#else + #include +#endif + +RTC_NAMESPACE_USE + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { +namespace EmbreeHelper { + +LA_RAYCASTING_API +void ensure_no_errors(const RTCDevice& device); + +} // namespace EmbreeHelper +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/EmbreeRayCaster.h b/modules/raycasting/include/lagrange/raycasting/legacy/EmbreeRayCaster.h new file mode 100644 index 00000000..ae0a69cb --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/EmbreeRayCaster.h @@ -0,0 +1,1167 @@ +/* + * Copyright 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#ifdef LAGRANGE_WITH_EMBREE_3 + #include + #include + #include +#else + #include + #include + #include +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RTC_NAMESPACE_USE + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { + +/** + * A wrapper for Embree's raycasting API to compute ray intersections with (instances of) meshes. + * Supports intersection and occlusion queries on single rays and ray packets (currently only + * packets of size at most 4 are supported). Filters may be specified (per mesh, not per instance) + * to process each individual hit event during any of these queries. + */ +template +class EmbreeRayCaster +{ +public: + using Scalar = ScalarType; + using Transform = Eigen::Matrix; + using Point = Eigen::Matrix; + using Direction = Eigen::Matrix; + using Index = size_t; + using ClosestPoint = ClosestPointResult; + using TransformVector = std::vector; + + using Point4 = Eigen::Matrix; + using Direction4 = Eigen::Matrix; + using Index4 = Eigen::Matrix; + using Scalar4 = Eigen::Matrix; + using Mask4 = Eigen::Matrix; + + using FloatData = std::vector; + using IntData = std::vector; + + /** + * Interface for a hit filter function. Most information in `RTCFilterFunctionNArguments` maps + * directly to elements of the EmbreeRayCaster class, but the mesh and instance IDs need special + * conversion. `mesh_index` is an array of `args->N` EmbreeRayCaster mesh indices, and + * `instance_index` is an array of `args->N` EmbreeRayCaster instance indices, one for each + * ray/hit. For the other elements of `args`, the mappings are: + * + * @code + * facet_index <-- primID + * ray_depth <-- tfar + * barycentric_coord <-- [u, v, 1 - u - v] + * normal <-- Ng + * @endcode + */ + using FilterFunction = std::function; + +private: + /** Select between two sets of filters stored in the object. */ + enum { FILTER_INTERSECT = 0, FILTER_OCCLUDED = 1 }; + +public: + /** Constructor. */ + EmbreeRayCaster( + RTCSceneFlags scene_flags = RTC_SCENE_FLAG_DYNAMIC, + RTCBuildQuality build_quality = RTC_BUILD_QUALITY_LOW) + { +// Embree strongly recommend to have the Flush to Zero and +// Denormals are Zero mode of the MXCSR control and status +// register enabled for each thread before calling the +// rtcIntersect and rtcOccluded functions. +#ifdef _MM_SET_FLUSH_ZERO_MODE + _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); +#endif +#ifdef _MM_SET_DENORMALS_ZERO_MODE + _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON); +#endif + + m_scene_flags = scene_flags; + m_build_quality = build_quality; + m_device = rtcNewDevice(NULL); + ensure_no_errors_internal(); + m_embree_world_scene = rtcNewScene(m_device); + ensure_no_errors_internal(); + m_instance_index_ranges.push_back(safe_cast(0)); + + m_need_rebuild = true; + m_need_commit = false; + } + + /** Destructor. */ + virtual ~EmbreeRayCaster() + { + release_scenes(); + rtcReleaseDevice(m_device); + } + + EmbreeRayCaster(const EmbreeRayCaster&) = delete; + void operator=(const EmbreeRayCaster&) = delete; + +public: + /** Get the total number of meshes (not instances). */ + Index get_num_meshes() const { return safe_cast(m_meshes.size()); } + + /** Get the total number of mesh instances. */ + Index get_num_instances() const { return m_instance_index_ranges.back(); } + + /** Get the number of instances of a particular mesh. */ + Index get_num_instances(Index mesh_index) const + { + la_debug_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); + return safe_cast( + m_instance_index_ranges[mesh_index + 1] - m_instance_index_ranges[mesh_index]); + } + + /** + * Get the mesh with a given index. Requires the caller to know the original type of the mesh + * (@a MeshType) in advance. + */ + template + std::shared_ptr get_mesh(Index index) const + { + la_runtime_assert(index < safe_cast(m_meshes.size())); + la_runtime_assert(m_meshes[index] != nullptr); + return dynamic_cast&>(*m_meshes[index]).get_mesh_ptr(); + } + + /** + * Get the index of the mesh corresponding to a given instance, where the instances are indexed + * sequentially starting from the instances of the first mesh, then the instances of the second + * mesh, and so on. Use get_mesh() to map the returned index to an actual mesh. + * + * @param cumulative_instance_index An integer in the range `0` to `get_num_instances() - 1` + * (both inclusive). + */ + Index get_mesh_for_instance(Index cumulative_instance_index) const + { + la_runtime_assert(cumulative_instance_index < get_num_instances()); + return m_instance_to_user_mesh[cumulative_instance_index]; + } + + /** + * Add an instance of a mesh to the scene, with a given transformation. If another instance of + * the same mesh has been previously added to the scene, the two instances will NOT be + * considered to share the same mesh, but will be treated as separate instances of separate + * meshes. To add multiple instances of the same mesh, use add_meshes(). + */ + template + Index add_mesh( + std::shared_ptr mesh, + const Transform& trans = Transform::Identity(), + RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) + { + return add_raycasting_mesh( + std::make_unique>(mesh), + trans, + build_quality); + } + + /** + * Add multiple instances of a single mesh to the scene, with given transformations. If another + * instance of the same mesh has been previously added to the scene, the new instances will NOT + * be considered to share the same mesh as the old instance, but will be treated as instances of + * a new mesh. Add all instances in a single add_meshes() call if you want to avoid this. + */ + template + Index add_meshes( + std::shared_ptr mesh, + const TransformVector& trans_vector, + RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) + { + m_meshes.push_back(std::move(std::make_unique>(mesh))); + m_transforms.insert(m_transforms.end(), trans_vector.begin(), trans_vector.end()); + m_mesh_build_qualities.push_back(build_quality); + m_visibility.resize(m_visibility.size() + trans_vector.size(), true); + for (auto& f : m_filters) { // per-mesh, not per-instance + f.push_back(nullptr); + } + Index mesh_index = safe_cast(m_meshes.size() - 1); + la_runtime_assert(m_instance_index_ranges.size() > 0); + Index instance_index = m_instance_index_ranges.back(); + la_runtime_assert(instance_index == safe_cast(m_instance_to_user_mesh.size())); + Index new_instance_size = instance_index + trans_vector.size(); + m_instance_index_ranges.push_back(new_instance_size); + m_instance_to_user_mesh.resize(new_instance_size, mesh_index); + m_need_rebuild = true; + return mesh_index; + } + + /** + * Update a particular mesh with a new mesh object. All its instances will be affected. + * + * @note If you have changed the vertices of a mesh already in the scene, and just want the + * object to reflect that, then call update_mesh_vertices() instead. + */ + template + void update_mesh( + Index index, + std::shared_ptr mesh, + RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) + { + update_raycasting_mesh( + index, + std::make_unique>(mesh), + build_quality); + } + + /** + * Update the object to reflect external changes to the vertices of a particular mesh which is + * already in the scene. All its instances will be affected. The number of vertices in the mesh, + * and their order in the vertex array, must not change. + */ + void update_mesh_vertices(Index index) + { + la_runtime_assert(index < safe_cast(m_meshes.size())); + if (m_need_rebuild) return; + + la_runtime_assert(index < safe_cast(m_embree_mesh_scenes.size())); + auto geom = rtcGetGeometry(m_embree_mesh_scenes[index], 0); + + // Update the vertex buffer in Embree + auto const& mesh = m_meshes[index]; + la_runtime_assert( + safe_cast(mesh->get_num_vertices()) == m_mesh_vertex_counts[index]); + + auto vbuf = + reinterpret_cast(rtcGetGeometryBufferData(geom, RTC_BUFFER_TYPE_VERTEX, 0)); + la_runtime_assert(vbuf); + mesh->vertices_to_float(vbuf); + rtcUpdateGeometryBuffer(geom, RTC_BUFFER_TYPE_VERTEX, 0); + + // Re-commit the mesh geometry and scene + rtcCommitGeometry(geom); + rtcCommitScene(m_embree_mesh_scenes[index]); + + // Re-commit every instance of the mesh + for (Index instance_index = m_instance_index_ranges[index]; + instance_index < m_instance_index_ranges[index + 1]; + ++instance_index) { + Index rtc_inst_id = m_instance_index_ranges[index] + instance_index; + auto geom_inst = + rtcGetGeometry(m_embree_world_scene, static_cast(rtc_inst_id)); + rtcCommitGeometry(geom_inst); + } + + // Mark the world scene as needing a re-commit (will be called lazily) + m_need_commit = true; + } + + /** Get the transform applied to a given mesh instance. */ + Transform get_transform(Index mesh_index, Index instance_index) const + { + la_runtime_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); + Index index = m_instance_index_ranges[mesh_index] + instance_index; + la_runtime_assert(index < m_instance_index_ranges[mesh_index + 1]); + la_runtime_assert(index < safe_cast(m_transforms.size())); + return m_transforms[index]; + } + + /** Update the transform applied to a given mesh instance. */ + void update_transformation(Index mesh_index, Index instance_index, const Transform& trans) + { + la_runtime_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); + Index index = m_instance_index_ranges[mesh_index] + instance_index; + la_runtime_assert(index < m_instance_index_ranges[mesh_index + 1]); + la_runtime_assert(index < safe_cast(m_transforms.size())); + m_transforms[index] = trans; + if (!m_need_rebuild) { + auto geom = rtcGetGeometry(m_embree_world_scene, static_cast(index)); + Eigen::Matrix T = trans.template cast(); + rtcSetGeometryTransform(geom, 0, RTC_FORMAT_FLOAT4X4_COLUMN_MAJOR, T.eval().data()); + rtcCommitGeometry(geom); + m_need_commit = true; + } + } + + /** Get the visibility flag of a given mesh instance. */ + bool get_visibility(Index mesh_index, Index instance_index) const + { + la_runtime_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); + Index index = m_instance_index_ranges[mesh_index] + instance_index; + la_runtime_assert(index < m_instance_index_ranges[mesh_index + 1]); + la_runtime_assert(index < safe_cast(m_visibility.size())); + return m_visibility[index]; + } + + /** Update the visibility of a given mesh index (true for visible, false for invisible). */ + void update_visibility(Index mesh_index, Index instance_index, bool visible) + { + la_runtime_assert(mesh_index + 1 < safe_cast(m_instance_index_ranges.size())); + Index index = m_instance_index_ranges[mesh_index] + instance_index; + la_runtime_assert(index < m_instance_index_ranges[mesh_index + 1]); + la_runtime_assert(index < safe_cast(m_visibility.size())); + m_visibility[index] = visible; + if (!m_need_rebuild && + rtcGetDeviceProperty(m_device, RTC_DEVICE_PROPERTY_RAY_MASK_SUPPORTED)) { + // ^^^ else, visibility will be checked by the already bound filter + + auto geom = rtcGetGeometry(m_embree_world_scene, static_cast(index)); + rtcSetGeometryMask(geom, visible ? 0xFFFFFFFF : 0x00000000); + rtcCommitGeometry(geom); + m_need_commit = true; + } + } + + /** + * Set an intersection filter that is called for every hit on (every instance of) a mesh during + * an intersection query. The @a filter function must be callable as: + * + * @code + * void filter(const EmbreeRayCaster* obj, const Index* mesh_index, const Index* instance_index, + * const RTCFilterFunctionNArguments* args); + * @endcode + * + * It functions exactly like Embree's `rtcSetGeometryIntersectFilterFunction`, except it also + * receives a handle to this object, and mesh and instance indices specific to this object. A + * null @a filter disables intersection filtering for this mesh. + * + * @note Embree dictates that filters can be associated only with meshes (raw geometries), not + * instances. + */ + void set_intersection_filter(Index mesh_index, FilterFunction filter) + { + la_runtime_assert(mesh_index < m_filters[FILTER_INTERSECT].size()); + m_filters[FILTER_INTERSECT][mesh_index] = filter; + m_need_rebuild = true; + } + + /** + * Get the intersection filter function currently bound to a given mesh. + * + * @note Embree dictates that filters can be associated only with meshes (raw geometries), not + * instances. + */ + FilterFunction get_intersection_filter(Index mesh_index) const + { + la_runtime_assert(mesh_index < m_filters[FILTER_INTERSECT].size()); + return m_filters[FILTER_INTERSECT][mesh_index]; + } + + /** + * Set an occlusion filter that is called for every hit on (every instance of) a mesh during an + * occlusion query. The @a filter function must be callable as: + * + * @code + * void filter(const EmbreeRayCaster* obj, const Index* mesh_index, const Index* instance_index, + * const RTCFilterFunctionNArguments* args); + * @endcode + * + * It functions exactly like Embree's `rtcSetGeometryOccludedFilterFunction`, except it also + * receives a handle to this object, and mesh and instance indices specific to this object. A + * null @a filter disables occlusion filtering for this mesh. + * + * @note Embree dictates that filters can be associated only with meshes (raw geometries), not + * instances. + */ + void set_occlusion_filter(Index mesh_index, FilterFunction filter) + { + la_runtime_assert(mesh_index < m_filters[FILTER_OCCLUDED].size()); + m_filters[FILTER_OCCLUDED][mesh_index] = filter; + m_need_rebuild = true; + } + + /** + * Get the occlusion filter function currently bound to a given mesh. + * + * @note Embree dictates that filters can be associated only with meshes (raw geometries), not + * instances. + */ + FilterFunction get_occlusion_filter(Index mesh_index) const + { + la_runtime_assert(mesh_index < m_filters[FILTER_OCCLUDED].size()); + return m_filters[FILTER_OCCLUDED][mesh_index]; + } + + /** + * Call `rtcCommitScene()` on the overall scene, if it has been marked as modified. + * + * @todo Now that this is automatically called by update_internal() based on a dirty flag, can + * we make this a protected/private function? That would break the API so maybe reserve it + * for a major version. + */ + void commit_scene_changes() + { + if (!m_need_commit) return; + + rtcCommitScene(m_embree_world_scene); + m_need_commit = false; + } + + /** Throw an exception if an Embree error has occurred.*/ + void ensure_no_errors() const { EmbreeHelper::ensure_no_errors(m_device); } + + /** + * Cast a packet of up to 4 rays through the scene, returning full data of the closest + * intersections including normals and instance indices. + */ + uint32_t cast4( + uint32_t batch_size, + const Point4& origin, + const Direction4& direction, + const Mask4& mask, + Index4& mesh_index, + Index4& instance_index, + Index4& facet_index, + Scalar4& ray_depth, + Point4& barycentric_coord, + Point4& normal, + const Scalar4& tmin = Scalar4::Zero(), + const Scalar4& tmax = Scalar4::Constant(std::numeric_limits::infinity())) + { + la_debug_assert(batch_size <= 4); + + update_internal(); + + RTCRayHit4 embree_raypacket; + for (int i = 0; i < static_cast(batch_size); ++i) { + // Set ray origins + embree_raypacket.ray.org_x[i] = static_cast(origin(i, 0)); + embree_raypacket.ray.org_y[i] = static_cast(origin(i, 1)); + embree_raypacket.ray.org_z[i] = static_cast(origin(i, 2)); + + // Set ray directions + embree_raypacket.ray.dir_x[i] = static_cast(direction(i, 0)); + embree_raypacket.ray.dir_y[i] = static_cast(direction(i, 1)); + embree_raypacket.ray.dir_z[i] = static_cast(direction(i, 2)); + + // Misc + embree_raypacket.ray.tnear[i] = static_cast(tmin[i]); + embree_raypacket.ray.tfar[i] = std::isinf(tmax[i]) ? std::numeric_limits::max() + : static_cast(tmax[i]); + embree_raypacket.ray.mask[i] = 0xFFFFFFFF; + embree_raypacket.ray.id[i] = static_cast(i); + embree_raypacket.ray.flags[i] = 0; + + // Required initialization of the hit substructure + embree_raypacket.hit.geomID[i] = RTC_INVALID_GEOMETRY_ID; + embree_raypacket.hit.primID[i] = RTC_INVALID_GEOMETRY_ID; + embree_raypacket.hit.instID[0][i] = RTC_INVALID_GEOMETRY_ID; + } + + // Modify the mask to make 100% sure extra rays in the packet will be ignored + auto packet_mask = mask; + for (int i = static_cast(batch_size); i < 4; ++i) packet_mask[i] = 0; + + ensure_no_errors_internal(); +#ifdef LAGRANGE_WITH_EMBREE_3 + RTCIntersectContext context; + rtcInitIntersectContext(&context); + rtcIntersect4(packet_mask.data(), m_embree_world_scene, &context, &embree_raypacket); +#else + rtcIntersect4(packet_mask.data(), m_embree_world_scene, &embree_raypacket); +#endif + ensure_no_errors_internal(); + + uint32_t is_hits = 0; + for (int i = 0; i < static_cast(batch_size); ++i) { + if (embree_raypacket.hit.geomID[i] != RTC_INVALID_GEOMETRY_ID) { + Index rtc_inst_id = embree_raypacket.hit.instID[0][i]; + Index rtc_mesh_id = (rtc_inst_id == RTC_INVALID_GEOMETRY_ID) + ? embree_raypacket.hit.geomID[i] + : rtc_inst_id; + assert(rtc_mesh_id < m_instance_to_user_mesh.size()); + assert(m_visibility[rtc_mesh_id]); + mesh_index[i] = m_instance_to_user_mesh[rtc_mesh_id]; + assert(mesh_index[i] + 1 < m_instance_index_ranges.size()); + assert(mesh_index[i] < safe_cast(m_meshes.size())); + instance_index[i] = rtc_mesh_id - m_instance_index_ranges[mesh_index[i]]; + facet_index[i] = embree_raypacket.hit.primID[i]; + ray_depth[i] = embree_raypacket.ray.tfar[i]; + barycentric_coord(i, 0) = + 1.0f - embree_raypacket.hit.u[i] - embree_raypacket.hit.v[i]; + barycentric_coord(i, 1) = embree_raypacket.hit.u[i]; + barycentric_coord(i, 2) = embree_raypacket.hit.v[i]; + normal(i, 0) = embree_raypacket.hit.Ng_x[i]; + normal(i, 1) = embree_raypacket.hit.Ng_y[i]; + normal(i, 2) = embree_raypacket.hit.Ng_z[i]; + is_hits = is_hits | (1 << i); + } + } + + return is_hits; + } + + /** + * Cast a packet of up to 4 rays through the scene, returning data of the closest intersections + * excluding normals and instance indices. + */ + uint32_t cast4( + uint32_t batch_size, + const Point4& origin, + const Direction4& direction, + const Mask4& mask, + Index4& mesh_index, + Index4& facet_index, + Scalar4& ray_depth, + Point4& barycentric_coord, + const Scalar4& tmin = Scalar4::Zero(), + const Scalar4& tmax = Scalar4::Constant(std::numeric_limits::infinity())) + { + Index4 instance_index; + Point4 normal; + return cast4( + batch_size, + origin, + direction, + mask, + mesh_index, + instance_index, + facet_index, + ray_depth, + barycentric_coord, + normal, + tmin, + tmax); + } + + /** + * Cast a packet of up to 4 rays through the scene and check whether they hit anything or not. + */ + uint32_t cast4( + uint32_t batch_size, + const Point4& origin, + const Direction4& direction, + const Mask4& mask, + const Scalar4& tmin = Scalar4::Zero(), + const Scalar4& tmax = Scalar4::Constant(std::numeric_limits::infinity())) + { + la_debug_assert(batch_size <= 4); + + update_internal(); + + RTCRay4 embree_raypacket; + for (int i = 0; i < static_cast(batch_size); ++i) { + // Set ray origins + embree_raypacket.org_x[i] = static_cast(origin(i, 0)); + embree_raypacket.org_y[i] = static_cast(origin(i, 1)); + embree_raypacket.org_z[i] = static_cast(origin(i, 2)); + + // Set ray directions + embree_raypacket.dir_x[i] = static_cast(direction(i, 0)); + embree_raypacket.dir_y[i] = static_cast(direction(i, 1)); + embree_raypacket.dir_z[i] = static_cast(direction(i, 2)); + + // Misc + embree_raypacket.tnear[i] = static_cast(tmin[i]); + embree_raypacket.tfar[i] = std::isinf(tmax[i]) ? std::numeric_limits::max() + : static_cast(tmax[i]); + embree_raypacket.mask[i] = 0xFFFFFFFF; + embree_raypacket.id[i] = static_cast(i); + embree_raypacket.flags[i] = 0; + } + + // Modify the mask to make 100% sure extra rays in the packet will be ignored + auto packet_mask = mask; + for (int i = static_cast(batch_size); i < 4; ++i) packet_mask[i] = 0; + + ensure_no_errors_internal(); +#ifdef LAGRANGE_WITH_EMBREE_3 + RTCIntersectContext context; + rtcInitIntersectContext(&context); + rtcOccluded4(packet_mask.data(), m_embree_world_scene, &context, &embree_raypacket); +#else + rtcOccluded4(packet_mask.data(), m_embree_world_scene, &embree_raypacket); +#endif + ensure_no_errors_internal(); + + // If hit, the tfar field will be set to -inf. + uint32_t is_hits = 0; + for (uint32_t i = 0; i < batch_size; ++i) + if (!std::isfinite(embree_raypacket.tfar[i])) is_hits = is_hits | (1 << i); + + return is_hits; + } + + /** + * Cast a single ray through the scene, returning full data of the closest intersection + * including the normal and the instance index. + */ + bool cast( + const Point& origin, + const Direction& direction, + Index& mesh_index, + Index& instance_index, + Index& facet_index, + Scalar& ray_depth, + Point& barycentric_coord, + Point& normal, + Scalar tmin = 0, + Scalar tmax = std::numeric_limits::infinity()) + { + // Overloaded when specializing tnear and tfar + + update_internal(); + + RTCRayHit embree_rayhit; + embree_rayhit.ray.org_x = static_cast(origin.x()); + embree_rayhit.ray.org_y = static_cast(origin.y()); + embree_rayhit.ray.org_z = static_cast(origin.z()); + embree_rayhit.ray.dir_x = static_cast(direction.x()); + embree_rayhit.ray.dir_y = static_cast(direction.y()); + embree_rayhit.ray.dir_z = static_cast(direction.z()); + embree_rayhit.ray.tnear = static_cast(tmin); + embree_rayhit.ray.tfar = + std::isinf(tmax) ? std::numeric_limits::max() : static_cast(tmax); + embree_rayhit.hit.geomID = RTC_INVALID_GEOMETRY_ID; + embree_rayhit.hit.primID = RTC_INVALID_GEOMETRY_ID; + embree_rayhit.hit.instID[0] = RTC_INVALID_GEOMETRY_ID; + embree_rayhit.ray.mask = 0xFFFFFFFF; + embree_rayhit.ray.id = 0; + embree_rayhit.ray.flags = 0; + ensure_no_errors_internal(); +#ifdef LAGRANGE_WITH_EMBREE_3 + RTCIntersectContext context; + rtcInitIntersectContext(&context); + rtcIntersect1(m_embree_world_scene, &context, &embree_rayhit); +#else + rtcIntersect1(m_embree_world_scene, &embree_rayhit); +#endif + ensure_no_errors_internal(); + + if (embree_rayhit.hit.geomID != RTC_INVALID_GEOMETRY_ID) { + Index rtc_inst_id = embree_rayhit.hit.instID[0]; + Index rtc_mesh_id = + (rtc_inst_id == RTC_INVALID_GEOMETRY_ID) ? embree_rayhit.hit.geomID : rtc_inst_id; + assert(rtc_mesh_id < m_instance_to_user_mesh.size()); + assert(m_visibility[rtc_mesh_id]); + mesh_index = m_instance_to_user_mesh[rtc_mesh_id]; + assert(mesh_index + 1 < m_instance_index_ranges.size()); + assert(mesh_index < safe_cast(m_meshes.size())); + instance_index = rtc_mesh_id - m_instance_index_ranges[mesh_index]; + facet_index = embree_rayhit.hit.primID; + ray_depth = embree_rayhit.ray.tfar; + barycentric_coord[0] = 1.0f - embree_rayhit.hit.u - embree_rayhit.hit.v; + barycentric_coord[1] = embree_rayhit.hit.u; + barycentric_coord[2] = embree_rayhit.hit.v; + normal[0] = embree_rayhit.hit.Ng_x; + normal[1] = embree_rayhit.hit.Ng_y; + normal[2] = embree_rayhit.hit.Ng_z; + return true; + } else { + // Ray missed. + mesh_index = invalid(); + instance_index = invalid(); + facet_index = invalid(); + return false; + } + } + + /** + * Cast a single ray through the scene, returning data of the closest intersection excluding the + * normal and the instance index. + */ + bool cast( + const Point& origin, + const Direction& direction, + Index& mesh_index, + Index& facet_index, + Scalar& ray_depth, + Point& barycentric_coord, + Scalar tmin = 0, + Scalar tmax = std::numeric_limits::infinity()) + { + Index instance_index; + Point normal; + return cast( + origin, + direction, + mesh_index, + instance_index, + facet_index, + ray_depth, + barycentric_coord, + normal, + tmin, + tmax); + } + + /** Cast a single ray through the scene and check whether it hits anything or not. */ + bool cast( + const Point& origin, + const Direction& direction, + Scalar tmin = 0, + Scalar tmax = std::numeric_limits::infinity()) + { + update_internal(); + + RTCRay embree_ray; + embree_ray.org_x = static_cast(origin.x()); + embree_ray.org_y = static_cast(origin.y()); + embree_ray.org_z = static_cast(origin.z()); + embree_ray.dir_x = static_cast(direction.x()); + embree_ray.dir_y = static_cast(direction.y()); + embree_ray.dir_z = static_cast(direction.z()); + embree_ray.tnear = static_cast(tmin); + embree_ray.tfar = + std::isinf(tmax) ? std::numeric_limits::max() : static_cast(tmax); + embree_ray.mask = 0xFFFFFFFF; + embree_ray.id = 0; + embree_ray.flags = 0; + + ensure_no_errors_internal(); +#ifdef LAGRANGE_WITH_EMBREE_3 + RTCIntersectContext context; + rtcInitIntersectContext(&context); + rtcOccluded1(m_embree_world_scene, &context, &embree_ray); +#else + rtcOccluded1(m_embree_world_scene, &embree_ray); +#endif + ensure_no_errors_internal(); + + // If hit, the tfar field will be set to -inf. + return !std::isfinite(embree_ray.tfar); + } + + /** Use the underlying BVH to find the point closest to a query point. */ + ClosestPoint query_closest_point(const Point& p) const; + + /** Add raycasting utilities **/ + Index add_raycasting_mesh( + std::unique_ptr mesh, + const Transform& trans = Transform::Identity(), + RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) + { + m_meshes.push_back(std::move(mesh)); + m_transforms.push_back(trans); + m_mesh_build_qualities.push_back(build_quality); + m_visibility.push_back(true); + for (auto& f : m_filters) { // per-mesh, not per-instance + f.push_back(nullptr); + } + Index mesh_index = safe_cast(m_meshes.size() - 1); + la_runtime_assert(m_instance_index_ranges.size() > 0); + Index instance_index = m_instance_index_ranges.back(); + la_runtime_assert(instance_index == safe_cast(m_instance_to_user_mesh.size())); + m_instance_index_ranges.push_back(instance_index + 1); + m_instance_to_user_mesh.resize(instance_index + 1, mesh_index); + m_need_rebuild = true; + return mesh_index; + } + + void update_raycasting_mesh( + Index index, + std::unique_ptr mesh, + RTCBuildQuality build_quality = RTC_BUILD_QUALITY_MEDIUM) + { + la_runtime_assert(mesh->get_dim() == 3); + la_runtime_assert(mesh->get_vertex_per_facet() == 3); + la_runtime_assert(index < safe_cast(m_meshes.size())); + m_meshes[index] = std::move(mesh); + m_mesh_build_qualities[index] = build_quality; + m_need_rebuild = true; // TODO: Make this more fine-grained so only the affected part of + // the Embree scene is updated + } + +protected: + /** Release internal Embree scenes */ + void release_scenes() + { + for (auto& s : m_embree_mesh_scenes) { + rtcReleaseScene(s); + } + rtcReleaseScene(m_embree_world_scene); + } + + /** Get the Embree scene flags. */ + virtual RTCSceneFlags get_scene_flags() const { return m_scene_flags; } + + /** Get the Embree geometry build quality. */ + virtual RTCBuildQuality get_scene_build_quality() const { return m_build_quality; } + + /** Update all internal structures based on the current dirty flags. */ + void update_internal() + { + if (m_need_rebuild) + generate_scene(); // full rebuild + else if (m_need_commit) + commit_scene_changes(); // just call rtcCommitScene() + } + + /** + * Build the whole Embree scene from the specified meshes, instances, etc. + * + * @todo Make the dirty flags more fine-grained so that only the changed meshes are re-sent to + * Embree. + */ + void generate_scene() + { + if (!m_need_rebuild) return; + + // Scene needs to be updated + release_scenes(); + m_embree_world_scene = rtcNewScene(m_device); + auto scene_flags = get_scene_flags(); // FIXME: or just m_scene_flags? + auto scene_build_quality = get_scene_build_quality(); // FIXME: or just m_build_quality? + rtcSetSceneFlags( + m_embree_world_scene, + scene_flags); // TODO: maybe also set RTC_SCENE_FLAG_CONTEXT_FILTER_FUNCTION + rtcSetSceneBuildQuality(m_embree_world_scene, scene_build_quality); + m_float_data.clear(); + m_int_data.clear(); + const auto num_meshes = m_meshes.size(); + la_runtime_assert(num_meshes + 1 == m_instance_index_ranges.size()); + m_embree_mesh_scenes.resize(num_meshes); + m_mesh_vertex_counts.resize(num_meshes); + ensure_no_errors_internal(); + + bool is_mask_supported = + rtcGetDeviceProperty(m_device, RTC_DEVICE_PROPERTY_RAY_MASK_SUPPORTED); + for (size_t i = 0; i < num_meshes; i++) { + // Initialize RTC meshes + const auto& mesh = m_meshes[i]; + // const auto& vertices = mesh->get_vertices(); + // const auto& facets = mesh->get_facets(); + const Index num_vertices = m_mesh_vertex_counts[i] = + safe_cast(mesh->get_num_vertices()); + const Index num_facets = safe_cast(mesh->get_num_facets()); + + auto& embree_mesh_scene = m_embree_mesh_scenes[i]; + embree_mesh_scene = rtcNewScene(m_device); + + rtcSetSceneFlags(embree_mesh_scene, scene_flags); + rtcSetSceneBuildQuality(embree_mesh_scene, scene_build_quality); + ensure_no_errors_internal(); + + RTCGeometry geom = rtcNewGeometry( + m_device, + RTC_GEOMETRY_TYPE_TRIANGLE); // EMBREE_FIXME: check if geometry gets properly + // committed + rtcSetGeometryBuildQuality(geom, m_mesh_build_qualities[i]); + // rtcSetGeometryTimeStepCount(geom, 1); + + const float* vertex_data = extract_float_data(*mesh); + const unsigned* facet_data = extract_int_data(*mesh); + + rtcSetSharedGeometryBuffer( + geom, + RTC_BUFFER_TYPE_VERTEX, + 0, + RTC_FORMAT_FLOAT3, + vertex_data, + 0, + sizeof(float) * 3, + num_vertices); + rtcSetSharedGeometryBuffer( + geom, + RTC_BUFFER_TYPE_INDEX, + 0, + RTC_FORMAT_UINT3, + facet_data, + 0, + sizeof(int) * 3, + num_facets); + + set_intersection_filter(geom, m_filters[FILTER_INTERSECT][i], is_mask_supported); + set_occlusion_filter(geom, m_filters[FILTER_OCCLUDED][i], is_mask_supported); + + rtcCommitGeometry(geom); + rtcAttachGeometry(embree_mesh_scene, geom); + rtcReleaseGeometry(geom); + ensure_no_errors_internal(); + + // Initialize RTC instances + for (Index instance_index = m_instance_index_ranges[i]; + instance_index < m_instance_index_ranges[i + 1]; + ++instance_index) { + const auto& trans = m_transforms[instance_index]; + + RTCGeometry geom_inst = rtcNewGeometry( + m_device, + RTC_GEOMETRY_TYPE_INSTANCE); // EMBREE_FIXME: check if geometry gets properly + // committed + rtcSetGeometryInstancedScene(geom_inst, embree_mesh_scene); + rtcSetGeometryTimeStepCount(geom_inst, 1); + + Eigen::Matrix T = trans.template cast(); + rtcSetGeometryTransform(geom_inst, 0, RTC_FORMAT_FLOAT4X4_COLUMN_MAJOR, T.data()); + ensure_no_errors_internal(); + + if (is_mask_supported) { + rtcSetGeometryMask( + geom_inst, + m_visibility[instance_index] ? 0xFFFFFFFF : 0x00000000); + } + ensure_no_errors_internal(); + + rtcCommitGeometry(geom_inst); + unsigned rtc_instance_id = rtcAttachGeometry(m_embree_world_scene, geom_inst); + rtcReleaseGeometry(geom_inst); + la_runtime_assert(safe_cast(rtc_instance_id) == instance_index); + ensure_no_errors_internal(); + } + + rtcCommitScene(embree_mesh_scene); + ensure_no_errors_internal(); + } + rtcCommitScene(m_embree_world_scene); + ensure_no_errors_internal(); + + m_need_rebuild = m_need_commit = false; + } + + /** Get the vertex data of a mesh as an array of floats. */ + const float* extract_float_data(const RaycasterMesh& mesh) + { + auto float_data = mesh.vertices_to_float(); + // Due to Embree bug, we have to have at least one more entry + // after the bound. Sigh... + // See https://github.com/embree/embree/issues/124 + float_data.push_back(0.0); + m_float_data.emplace_back(std::move(float_data)); + return m_float_data.back().data(); + } + + /** Get the index data of a mesh as an array of integers. */ + const unsigned* extract_int_data(const RaycasterMesh& mesh) + { + auto int_data = mesh.indices_to_int(); + // Due to Embree bug, we have to have at least one more entry + // after the bound. Sigh... + // See https://github.com/embree/embree/issues/124 + int_data.push_back(0); + m_int_data.emplace_back(std::move(int_data)); + return m_int_data.back().data(); + } + +private: + /** + * Helper function for setting intersection filters. Does NOT commit the geometry. The caller + * must explicitly call `rtcCommitGeometry()` afterwards. + */ + void set_intersection_filter(RTCGeometry geom, FilterFunction filter, bool is_mask_supported) + { + if (is_mask_supported) { + if (filter) { + rtcSetGeometryUserData(geom, this); + rtcSetGeometryIntersectFilterFunction(geom, &wrap_filter); + } else { + rtcSetGeometryIntersectFilterFunction(geom, nullptr); + } + } else { + rtcSetGeometryUserData(geom, this); + rtcSetGeometryIntersectFilterFunction(geom, &wrap_filter_and_mask); + } + } + + /** + * Helper function for setting occlusion filters. Does NOT commit the geometry. The caller must + * explicitly call `rtcCommitGeometry()` afterwards. + */ + void set_occlusion_filter(RTCGeometry geom, FilterFunction filter, bool is_mask_supported) + { + if (is_mask_supported) { + if (filter) { + rtcSetGeometryUserData(geom, this); + rtcSetGeometryOccludedFilterFunction(geom, &wrap_filter); + } else { + rtcSetGeometryOccludedFilterFunction(geom, nullptr); + } + } else { + rtcSetGeometryUserData(geom, this); + rtcSetGeometryOccludedFilterFunction(geom, &wrap_filter_and_mask); + } + } + + /** + * Embree-compatible callback function that computes indices specific to this object and then + * delegates to the user-specified filter function. + */ + template // 0: intersection, 1: occlusion + static void wrap_filter(const RTCFilterFunctionNArguments* args) + { + // Embree never actually calls a filter callback with different geometry or instance IDs + // So we can assume they are the same for all the hits in this batch. Also, every single + // mesh in this class is instanced (never used raw), so we can ignore geomID. + const auto* obj = reinterpret_cast(args->geometryUserPtr); + auto rtc_inst_id = RTCHitN_instID(args->hit, args->N, 0, 0); + assert(rtc_inst_id < obj->m_instance_to_user_mesh.size()); + + auto filter = obj->m_filters[IntersectionOrOcclusion][rtc_inst_id]; + if (!filter) { + return; + } + + Index mesh_index = obj->m_instance_to_user_mesh[rtc_inst_id]; + assert(mesh_index + 1 < obj->m_instance_index_ranges.size()); + assert(mesh_index < safe_cast(obj->m_meshes.size())); + Index instance_index = rtc_inst_id - obj->m_instance_index_ranges[mesh_index]; + + // In case Embree's implementation changes in the future, the callback should be written + // generally, without assuming the single geometry/instance condition above. + Index4 mesh_index4; + mesh_index4.fill(mesh_index); + Index4 instance_index4; + instance_index4.fill(instance_index); + + // Call the wrapped filter with the indices specific to this object + filter(obj, mesh_index4.data(), instance_index4.data(), args); + } + + /** + * Embree-compatible callback function that checks if the intersected object is visible or not, + * computes indices specific to this object, and then delegates to the user-specified filter + * function. + */ + template // 0: intersection, 1: occlusion + static void wrap_filter_and_mask(const RTCFilterFunctionNArguments* args) + { + // Embree never actually calls a filter callback with different geometry or instance IDs + // So we can assume they are the same for all the hits in this batch. Also, every single + // mesh in this class is instanced (never used raw), so we can ignore geomID. + const auto* obj = reinterpret_cast(args->geometryUserPtr); + auto rtc_inst_id = RTCHitN_instID(args->hit, args->N, 0, 0); + if (!obj->m_visibility[rtc_inst_id]) { + // Object is invisible. Make the hits of all the rays with this object invalid. + std::fill(args->valid, args->valid + args->N, 0); + return; + } + + // Delegate to the regular filtering after having checked visibility + wrap_filter(args); + } + +protected: + RTCSceneFlags m_scene_flags; + RTCBuildQuality m_build_quality; + RTCDevice m_device; + RTCScene m_embree_world_scene; + bool m_need_rebuild; // full rebuild of the scene? + bool m_need_commit; // just call rtcCommitScene() on the scene? + + // Data reservoirs for holding temporary/casted/per-geometry data. + // Length = Number of polygonal meshes + std::vector m_float_data; + std::vector m_int_data; + std::vector> m_meshes; + std::vector m_mesh_build_qualities; + std::vector m_embree_mesh_scenes; + std::vector m_mesh_vertex_counts; // for bounds-checking of buffer updates + std::vector m_filters[2]; // 0: intersection filters, 1: occlusion filters + + // Ranges of instance indices corresponding to a specific + // Mesh. For example, in a scenario with 3 meshes each of + // which has 1, 2, 5 instances, this array would be + // [0, 1, 3, 8]. + // Length = Number of user meshes + 1 + std::vector m_instance_index_ranges; + + // Mapping from (RTC-)instanced mesh to user mesh. For + // example, in a scenario with 3 meshes each of + // which has 1, 2, 5 instances, this array would be + // [0, 1, 1, 2, 2, 2, 2, 2] + // Length = Number of instanced meshes + // Note: This array is only used internally. We shouldn't + // allow the users to access anything with the indices + // used in those RTC functions. + std::vector m_instance_to_user_mesh; + + // Data reservoirs for holding instanced mesh data + // Length = Number of (world-space) instanced meshes + std::vector m_transforms; + std::vector m_visibility; + + // error checking function used internally + void ensure_no_errors_internal() const + { +#ifdef LAGRANGE_EMBREE_DEBUG + EmbreeHelper::ensure_no_errors(m_device); +#endif + } +}; + +//////////////////////////////////////////////////////////////////////////////// +// IMPLEMENTATION +//////////////////////////////////////////////////////////////////////////////// + +template +auto EmbreeRayCaster::query_closest_point(const Point& p) const -> ClosestPoint +{ + RTCPointQuery query; + query.x = (float)(p.x()); + query.y = (float)(p.y()); + query.z = (float)(p.z()); + query.radius = std::numeric_limits::max(); + query.time = 0.f; + ensure_no_errors_internal(); + + ClosestPoint result; + + // Callback to retrieve triangle corner positions + result.populate_triangle = + [&](unsigned mesh_index, unsigned facet_index, Point& v0, Point& v1, Point& v2) { + // TODO: There's no way to call this->get_mesh<> since we need to template the function + // by the (derived) type, which we don't know here... This means our only choice is so + // use the float data instead of the (maybe) double point coordinate if available. Note + // that we could also call rtcSetGeometryPointQueryFunction() when we register our mesh, + // since we know the derived type at this point. + const unsigned* face = m_int_data[mesh_index].data() + 3 * facet_index; + const float* vertices = m_float_data[mesh_index].data(); + v0 = Point(vertices[3 * face[0]], vertices[3 * face[0] + 1], vertices[3 * face[0] + 2]); + v1 = Point(vertices[3 * face[1]], vertices[3 * face[1] + 1], vertices[3 * face[1] + 2]); + v2 = Point(vertices[3 * face[2]], vertices[3 * face[2] + 1], vertices[3 * face[2] + 2]); + }; + + { + RTCPointQueryContext context; + rtcInitPointQueryContext(&context); + rtcPointQuery( + m_embree_world_scene, + &query, + &context, + &embree_closest_point, + reinterpret_cast(&result)); + assert( + result.mesh_index != RTC_INVALID_GEOMETRY_ID || + result.facet_index != RTC_INVALID_GEOMETRY_ID); + assert( + result.mesh_index != invalid() || result.facet_index != invalid()); + } + ensure_no_errors_internal(); + + return result; +} + +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/RayCasterMesh.h b/modules/raycasting/include/lagrange/raycasting/legacy/RayCasterMesh.h new file mode 100644 index 00000000..c6ffa61b --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/RayCasterMesh.h @@ -0,0 +1,137 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once +#include +#include +#include +#include + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { + + +class RaycasterMesh +{ +public: + using Index = size_t; + + virtual ~RaycasterMesh() {} + + virtual Index get_dim() const = 0; + virtual Index get_vertex_per_facet() const = 0; + virtual Index get_num_vertices() const = 0; + virtual Index get_num_facets() const = 0; + + virtual std::vector vertices_to_float() const = 0; + virtual std::vector indices_to_int() const = 0; + + virtual void vertices_to_float(float* buf) const = 0; // buf must be preallocated + virtual void indices_to_int(unsigned* buf) const = 0; // buf must be preallocated +}; + + +template +class RaycasterMeshDerived : public RaycasterMesh +{ +public: + using Parent = RaycasterMesh; + using Index = Parent::Index; + + RaycasterMeshDerived(std::shared_ptr mesh) + : m_mesh(std::move(mesh)) + { + la_runtime_assert(m_mesh, "Mesh cannot be null"); + } + + std::shared_ptr get_mesh_ptr() const { return m_mesh; } + + Index get_dim() const override { return m_mesh->get_dim(); } + Index get_vertex_per_facet() const override { return m_mesh->get_vertex_per_facet(); } + Index get_num_vertices() const override { return m_mesh->get_num_vertices(); }; + Index get_num_facets() const override { return m_mesh->get_num_facets(); }; + + std::vector vertices_to_float() const override + { + auto& data = m_mesh->get_vertices(); + const Index rows = safe_cast(data.rows()); + const Index cols = (get_dim() == 3 ? safe_cast(data.cols()) : 3); + const Index size = rows * cols; + + // Due to Embree bug, we have to reserve space for one extra entry. + // See https://github.com/embree/embree/issues/124 + std::vector float_data; + float_data.reserve(size + 1); + float_data.resize(size); + vertices_to_float(float_data.data()); + + return float_data; + } + + std::vector indices_to_int() const override + { + auto& data = m_mesh->get_facets(); + const Index rows = safe_cast(data.rows()); + const Index cols = safe_cast(data.cols()); + const Index size = rows * cols; + + // Due to Embree bug, we have to reserve space for one extra entry. + // See https://github.com/embree/embree/issues/124 + std::vector int_data; + int_data.reserve(size + 1); + int_data.resize(size); + indices_to_int(int_data.data()); + + return int_data; + } + + void vertices_to_float(float* buf) const override + { + auto& data = m_mesh->get_vertices(); + const Index rows = safe_cast(data.rows()); + + if (get_dim() == 3) { + const Index cols = safe_cast(data.cols()); + for (Index i = 0; i < rows; ++i) { + for (Index j = 0; j < cols; ++j) { + *(buf++) = safe_cast(data(i, j)); + } + } + } else if (get_dim() == 2) { + for (Index i = 0; i < rows; ++i) { + for (Index j = 0; j < 2; ++j) { + *(buf++) = safe_cast(data(i, j)); + } + *(buf++) = 0.0f; + } + } + } + + void indices_to_int(unsigned* buf) const override + { + auto& data = m_mesh->get_facets(); + const Index rows = safe_cast(data.rows()); + const Index cols = safe_cast(data.cols()); + for (Index i = 0; i < rows; ++i) { + for (Index j = 0; j < cols; ++j) { + *(buf++) = safe_cast(data(i, j)); + } + } + } + + std::shared_ptr m_mesh; +}; + +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/create_ray_caster.h b/modules/raycasting/include/lagrange/raycasting/legacy/create_ray_caster.h new file mode 100644 index 00000000..cfaf9a6d --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/create_ray_caster.h @@ -0,0 +1,74 @@ +/* + * Copyright 2017 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +#include + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { + +enum RayCasterType { + EMBREE_DEFAULT = 1, ///< Corresponds to RTC_SCENE_FLAG_NONE + EMBREE_DYNAMIC = 2, ///< Corresponds to RTC_SCENE_FLAG_DYNAMIC + EMBREE_ROBUST = 4, ///< Corresponds to RTC_SCENE_FLAG_ROBUST + EMBREE_COMPACT = 8, ///< Corresponds to RTC_SCENE_FLAG_COMPACT +}; + +enum RayCasterQuality { + BUILD_QUALITY_LOW, ///< Corresponds to RTC_BUILD_QUALITY_LOW + BUILD_QUALITY_MEDIUM, ///< Corresponds to RTC_BUILD_QUALITY_MEDIUM + BUILD_QUALITY_HIGH, ///< Corresponds to RTC_BUILD_QUALITY_HIGH +}; + +template +std::unique_ptr> create_ray_caster( + RayCasterType engine, + RayCasterQuality quality = BUILD_QUALITY_LOW) +{ + if (engine & 0b1111) { + // Translate scene flags for embree ray casters + int flags = static_cast(RTC_SCENE_FLAG_NONE); + if (engine & EMBREE_DYNAMIC) { + flags |= static_cast(RTC_SCENE_FLAG_DYNAMIC); + } + if (engine & EMBREE_ROBUST) { + flags |= static_cast(RTC_SCENE_FLAG_ROBUST); + } + if (engine & EMBREE_COMPACT) { + flags |= static_cast(RTC_SCENE_FLAG_COMPACT); + } + + // Translate build quality settings + RTCBuildQuality build = RTC_BUILD_QUALITY_LOW; + switch (quality) { + case BUILD_QUALITY_LOW: build = RTC_BUILD_QUALITY_LOW; break; + case BUILD_QUALITY_MEDIUM: build = RTC_BUILD_QUALITY_MEDIUM; break; + case BUILD_QUALITY_HIGH: build = RTC_BUILD_QUALITY_HIGH; break; + default: break; + } + + return std::make_unique>(static_cast(flags), build); + } else { + std::stringstream err_msg; + err_msg << "Unknown ray caster engine: " << engine; + throw std::runtime_error(err_msg.str()); + } +} +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/embree_closest_point.h b/modules/raycasting/include/lagrange/raycasting/legacy/embree_closest_point.h new file mode 100644 index 00000000..7e27a5cf --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/embree_closest_point.h @@ -0,0 +1,109 @@ +// Source: https://github.com/embree/embree/blob/master/tutorials/closest_point/closest_point.cpp +// SPDX-License-Identifier: Apache-2.0 +// +// This file has been modified by Adobe. +// +// All modifications are Copyright 2020 Adobe. + +#pragma once + +#include +#include + +#include + +#ifdef LAGRANGE_WITH_EMBREE_3 + #include + #include + #include +#else + #include + #include + #include +#endif + +#include + +RTC_NAMESPACE_USE + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { + +template +bool embree_closest_point(RTCPointQueryFunctionArguments* args) +{ + using Point = typename ClosestPointResult::Point; + using MapType = Eigen::Map; + using AffineMat = Eigen::Transform; + + assert(args->userPtr); + auto result = reinterpret_cast*>(args->userPtr); + + const unsigned int geomID = args->geomID; + const unsigned int primID = args->primID; + + RTCPointQueryContext* context = args->context; + const unsigned int stack_size = args->context->instStackSize; + const unsigned int stack_ptr = stack_size - 1; + + AffineMat inst2world = + (stack_size > 0 ? AffineMat(MapType(context->inst2world[stack_ptr]).template cast()) + : AffineMat::Identity()); + + // Query position in world space + Point q(args->query->x, args->query->y, args->query->z); + + // Get triangle information in local space + Point v0, v1, v2; + assert(result->populate_triangle); + result->populate_triangle(geomID, primID, v0, v1, v2); + + // Bring query and primitive data in the same space if necessary. + if (stack_size > 0 && args->similarityScale > 0) { + // Instance transform is a similarity transform, therefore we + // can compute distance information in instance space. Therefore, + // transform query position into local instance space. + AffineMat world2inst(MapType(context->world2inst[stack_ptr]).template cast()); + q = world2inst * q; + } else if (stack_size > 0) { + // Instance transform is not a similarity tranform. We have to transform the + // primitive data into world space and perform distance computations in + // world space to ensure correctness. + v0 = inst2world * v0; + v1 = inst2world * v1; + v2 = inst2world * v2; + } else { + // Primitive is not instanced, therefore point query and primitive are + // already in the same space. + } + + // Determine distance to closest point on triangle, and transform in + // world space if necessary. + Point p; + Scalar l1, l2, l3; + Scalar d2 = point_triangle_squared_distance(q, v0, v1, v2, p, l1, l2, l3); + float d = std::sqrt(static_cast(d2)); + if (args->similarityScale > 0) { + d = d / args->similarityScale; + } + + // Store result in userPtr and update the query radius if we found a point + // closer to the query position. This is optional but allows for faster + // traversal (due to better culling). + if (d < args->query->radius) { + args->query->radius = d; + result->closest_point = (args->similarityScale > 0 ? (inst2world * p).eval() : p); + result->mesh_index = geomID; + result->facet_index = primID; + result->barycentric_coord = Point(l1, l2, l3); + return true; // Return true to indicate that the query radius changed. + } + + return false; +} + +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes.h b/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes.h new file mode 100644 index 00000000..a19fa364 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes.h @@ -0,0 +1,101 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { + +/// +/// Project vertex attributes from one mesh to another. Different projection modes can be prescribed. +/// +/// @param[in] source Source mesh. +/// @param[in,out] target Target mesh to be modified. +/// @param[in] names Name of the vertex attributes to transfer. +/// @param[in] project_mode Projection mode to choose from. +/// @param[in] direction Raycasting direction to project attributes. +/// @param[in] cast_mode Whether to project forward along the ray, or to project along the +/// whole ray (both forward and backward). +/// @param[in] wrap_mode Wrapping mode for values where there is no hit. +/// @param[in] default_value Scalar used to fill attributes in CONSTANT wrapping mode. +/// @param[in] user_callback Optional user callback that can be used to set attribute values +/// depending on whether there is a hit or not. +/// @param[in,out] ray_caster If provided, the use ray_caster to perform the queries instead. +/// The source mesh will assume to have been added to `ray_caster` in +/// advance, and this function will not try to add it. This allows to +/// use a different ray caster than the one computed by this +/// function, and allows to nest function calls. +/// @param[in] skip_vertex If provided, whether to skip assignment for a target vertex or +/// not. This can be used for partial assignment (e.g. to only set +/// boundary vertices of a mesh). +/// +/// @tparam SourceMeshType Source mesh type. +/// @tparam TargetMeshType Target mesh type. +/// @tparam DerivedVector Vector type for the direction. +/// @tparam DefaultScalar Scalar type used to fill attributes. +/// +template < + typename SourceMeshType, + typename TargetMeshType, + typename DerivedVector = Eigen::Matrix, 3, 1>, + typename DefaultScalar = typename SourceMeshType::Scalar> +void project_attributes( + const SourceMeshType& source, + TargetMeshType& target, + const std::vector& names, + ProjectMode project_mode, + const Eigen::MatrixBase& direction = DerivedVector(0, 0, 1), + CastMode cast_mode = CastMode::BothWays, + FallbackMode wrap_mode = FallbackMode::Constant, + DefaultScalar default_value = DefaultScalar(0), + std::function user_callback = nullptr, + EmbreeRayCaster>* ray_caster = nullptr, + std::function)> skip_vertex = nullptr) +{ + static_assert(MeshTrait::is_mesh(), "Input type is not Mesh"); + static_assert(MeshTrait::is_mesh(), "Output type is not Mesh"); + la_runtime_assert(source.get_vertex_per_facet() == 3); + + switch (project_mode) { + case ProjectMode::ClosestVertex: + ::lagrange::bvh::project_attributes_closest_vertex(source, target, names, skip_vertex); + break; + case ProjectMode::ClosestPoint: + project_attributes_closest_point(source, target, names, ray_caster, skip_vertex); + break; + case ProjectMode::RayCasting: + project_attributes_directional( + source, + target, + names, + direction, + cast_mode, + wrap_mode, + default_value, + user_callback, + ray_caster, + skip_vertex); + break; + default: throw std::runtime_error("Not implemented"); + } +} + +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_closest_point.h b/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_closest_point.h new file mode 100644 index 00000000..ccb8ce54 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_closest_point.h @@ -0,0 +1,135 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { + +/// +/// Project vertex attributes from one mesh to another, by copying attributes from the closest point +/// on the input mesh. Values are linearly interpolated from the face corners. +/// +/// @param[in] source Source mesh. +/// @param[in,out] target Target mesh to be modified. +/// @param[in] names Name of the vertex attributes to transfer. +/// @param[in,out] ray_caster If provided, the use ray_caster to perform the queries instead. +/// The source mesh will assume to have been added to `ray_caster` in +/// advance, and this function will not try to add it. This allows to +/// use a different ray caster than the one computed by this +/// function, and allows to nest function calls. +/// @param[in] skip_vertex If provided, whether to skip assignment for a target vertex or +/// not. This can be used for partial assignment (e.g. to only set +/// boundary vertices of a mesh). +/// +/// @tparam SourceMeshType Source mesh type. +/// @tparam TargetMeshType Target mesh type. +/// +template +void project_attributes_closest_point( + const SourceMeshType& source, + TargetMeshType& target, + const std::vector& names, + EmbreeRayCaster>* ray_caster = nullptr, + std::function)> skip_vertex = nullptr) +{ + static_assert(MeshTrait::is_mesh(), "Input type is not Mesh"); + static_assert(MeshTrait::is_mesh(), "Output type is not Mesh"); + la_runtime_assert(source.get_vertex_per_facet() == 3); + + // Typedef festival because templates... + using Scalar = typename SourceMeshType::Scalar; + using Index = typename TargetMeshType::Index; + using SourceArray = typename SourceMeshType::AttributeArray; + using TargetArray = typename SourceMeshType::AttributeArray; + using Point = typename EmbreeRayCaster::Point; + using Direction = typename EmbreeRayCaster::Direction; + + // We need to convert to a shared_ptr AND the ray caster will make another copy of the data.. + std::unique_ptr> engine; + if (!ray_caster) { + auto mesh = lagrange::to_shared_ptr( + lagrange::create_mesh(source.get_vertices(), source.get_facets())); + // Robust mode gives slightly more accurate results... + engine = create_ray_caster(EMBREE_ROBUST, BUILD_QUALITY_HIGH); + + // Gosh why do I need to specify a transform here? + engine->add_mesh(mesh, Eigen::Matrix::Identity()); + + // Do a dummy raycast to trigger scene update, otherwise `cast()` will not work in + // multithread mode... (this is why we need const-safety...) + engine->cast(Point(0, 0, 0), Direction(0, 0, 1)); + ray_caster = engine.get(); + } else { + logger().debug("Using provided ray-caster"); + } + + // Store pointer to source/target arrays + std::vector source_attrs(names.size()); + std::vector target_attrs(names.size()); + for (size_t k = 0; k < names.size(); ++k) { + const auto& name = names[k]; + la_runtime_assert(source.has_vertex_attribute(name)); + source_attrs[k] = &source.get_vertex_attribute(name); + if (target.has_vertex_attribute(name)) { + target.export_vertex_attribute(name, target_attrs[k]); + } else { + target_attrs[k].resize(target.get_num_vertices(), source_attrs[k]->cols()); + } + } + + tbb::parallel_for(Index(0), target.get_num_vertices(), [&](Index i) { + if (skip_vertex && skip_vertex(i)) { + logger().trace("skipping vertex: {}", i); + return; + } + Point query = target.get_vertices().row(i).transpose(); + auto res = ray_caster->query_closest_point(query); + la_runtime_assert( + res.facet_index >= 0 && res.facet_index < (unsigned)source.get_num_facets()); + auto face = source.get_facets().row(res.facet_index).eval(); + Point bary = res.barycentric_coord; + + for (size_t k = 0; k < source_attrs.size(); ++k) { + target_attrs[k].row(i).setZero(); + for (int lv = 0; lv < 3; ++lv) { + target_attrs[k].row(i) += source_attrs[k]->row(face[lv]) * bary[lv]; + } + } + }); + + // Not super pretty way, we still need to separately add/create the attribute, + // THEN import it without copy. Would be better if we could get a ref to it. + for (size_t k = 0; k < names.size(); ++k) { + const auto& name = names[k]; + target.add_vertex_attribute(name); + target.import_vertex_attribute(name, target_attrs[k]); + } +} + +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_directional.h b/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_directional.h new file mode 100644 index 00000000..a90b85fc --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_directional.h @@ -0,0 +1,306 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +// clang-format on + +#include + +#include +#include +#include +#include + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { + +/// +/// Project vertex attributes from one mesh to another, by projecting target vertices along a +/// prescribed direction, and interpolating surface values from facet corners of the source mesh. +/// +/// @note In the future may want to support using a vector field instead of a constant +/// direction for projection. +/// +/// @param[in] source Source mesh. +/// @param[in,out] target Target mesh to be modified. +/// @param[in] names Name of the vertex attributes to transfer. +/// @param[in] direction Raycasting direction to project attributes. +/// @param[in] cast_mode Whether to project forward along the ray, or to project along the +/// whole ray (both forward and backward). +/// @param[in] wrap_mode Wrapping mode for values where there is no hit. +/// @param[in] default_value Scalar used to fill attributes in CONSTANT wrapping mode. +/// @param[in] user_callback Optional user callback that can be used to set attribute values +/// depending on whether there is a hit or not. +/// @param[in,out] ray_caster If provided, the use ray_caster to perform the queries instead. +/// The source mesh will assume to have been added to `ray_caster` in +/// advance, and this function will not try to add it. This allows to +/// use a different ray caster than the one computed by this +/// function, and allows to nest function calls. +/// @param[in] skip_vertex If provided, whether to skip assignment for a target vertex or +/// not. This can be used for partial assignment (e.g. to only set +/// boundary vertices of a mesh). +/// +/// @tparam SourceMeshType Source mesh type. +/// @tparam TargetMeshType Target mesh type. +/// @tparam DerivedVector Vector type for the direction. +/// @tparam DefaultScalar Scalar type used to fill attributes. +/// +template < + typename SourceMeshType, + typename TargetMeshType, + typename DerivedVector, + typename DefaultScalar = typename SourceMeshType::Scalar> +void project_attributes_directional( + const SourceMeshType& source, + TargetMeshType& target, + const std::vector& names, + const Eigen::MatrixBase& direction, + CastMode cast_mode = CastMode::BothWays, + FallbackMode wrap_mode = FallbackMode::Constant, + DefaultScalar default_value = DefaultScalar(0), + std::function user_callback = nullptr, + EmbreeRayCaster>* ray_caster = nullptr, + std::function)> skip_vertex = nullptr) +{ + static_assert(MeshTrait::is_mesh(), "Input type is not Mesh"); + static_assert(MeshTrait::is_mesh(), "Output type is not Mesh"); + la_runtime_assert(source.get_vertex_per_facet() == 3); + + // Typedef festival because templates... + using Scalar = typename SourceMeshType::Scalar; + using Index = typename TargetMeshType::Index; + using SourceArray = typename SourceMeshType::AttributeArray; + using TargetArray = typename SourceMeshType::AttributeArray; + using Point = typename EmbreeRayCaster::Point; + using Direction = typename EmbreeRayCaster::Direction; + using RayCasterIndex = typename EmbreeRayCaster::Index; + using Scalar4 = typename EmbreeRayCaster::Scalar4; + using Index4 = typename EmbreeRayCaster::Index4; + using Point4 = typename EmbreeRayCaster::Point4; + using Direction4 = typename EmbreeRayCaster::Direction4; + using Mask4 = typename EmbreeRayCaster::Mask4; + + // We need to convert to a shared_ptr AND the ray caster will make another copy of the data.. + std::unique_ptr> engine; + if (!ray_caster) { + auto mesh = lagrange::to_shared_ptr( + lagrange::create_mesh(source.get_vertices(), source.get_facets())); + // Robust mode gives slightly more accurate results... + engine = create_ray_caster(EMBREE_ROBUST, BUILD_QUALITY_HIGH); + + // Gosh why do I need to specify a transform here? + engine->add_mesh(mesh, Eigen::Matrix::Identity()); + + // Do a dummy raycast to trigger scene update, otherwise `cast()` will not work in + // multithread mode... (this is why we need const-safety...) + engine->cast(Point(0, 0, 0), Direction(0, 0, 1)); + ray_caster = engine.get(); + } else { + logger().debug("Using provided ray-caster"); + } + + // Store pointer to source/target arrays + std::vector source_attrs; + source_attrs.reserve(names.size()); + std::vector target_attrs(names.size()); + for (size_t k = 0; k < names.size(); ++k) { + const auto& name = names[k]; + la_runtime_assert(source.has_vertex_attribute(name)); + source_attrs.push_back(&source.get_vertex_attribute(name)); + if (target.has_vertex_attribute(name)) { + target.export_vertex_attribute(name, target_attrs[k]); + } else { + target_attrs[k].resize(target.get_num_vertices(), source_attrs[k]->cols()); + } + } + + auto diag = igl::bounding_box_diagonal(source.get_vertices()); + + // Using `char` instead of `bool` because we write concurrently to this + std::vector is_hit; + if (wrap_mode != FallbackMode::Constant) { + is_hit.assign(target.get_num_vertices(), false); + } + + Index num_vertices = target.get_num_vertices(); + Index num_packets = (num_vertices + 3) / 4; + + Direction4 dirs; + dirs.row(0) = direction.normalized().transpose(); + for (int i = 1; i < 4; ++i) { + dirs.row(i) = dirs.row(0); + } + Direction4 dirs2 = -dirs; + + tbb::parallel_for(Index(0), num_packets, [&](Index packet_index) { + Index batchsize = std::min(num_vertices - (packet_index << 2), 4); + Mask4 mask = Mask4::Constant(-1); + Point4 origins; + + int num_skipped_in_packet = 0; + for (Index b = 0; b < batchsize; ++b) { + Index i = (packet_index << 2) + b; + if (skip_vertex && skip_vertex(i)) { + logger().trace("skipping vertex: {}", i); + if (!is_hit.empty()) { + is_hit[i] = true; + } + mask(b) = 0; + ++num_skipped_in_packet; + continue; + } + + origins.row(b) = target.get_vertices().row(i); + } + + if (num_skipped_in_packet == batchsize) return; + + for (Index b = batchsize; b < 4; ++b) { + mask(b) = 0; + } + + Index4 mesh_indices; + Index4 instance_indices; + Index4 facet_indices; + Scalar4 ray_depths; + Point4 barys; + Point4 normals; + uint8_t hits = ray_caster->cast4( + batchsize, + origins, + dirs, + mask, + mesh_indices, + instance_indices, + facet_indices, + ray_depths, + barys, + normals); + + if (cast_mode == CastMode::BothWays) { + // Try again in the other direction. Slightly offset ray origin, and keep closest point. + Point4 origins2 = origins + Scalar(1e-6) * diag * dirs; + Index4 mesh_indices2; + Index4 instance_indices2; + Index4 facet_indices2; + Scalar4 ray_depths2; + Point4 barys2; + Point4 norms2; + uint8_t hits2 = ray_caster->cast4( + batchsize, + origins2, + dirs2, + mask, + mesh_indices2, + instance_indices2, + facet_indices2, + ray_depths2, + barys2, + norms2); + + for (Index b = 0; b < batchsize; ++b) { + if (!mask(b)) continue; + auto len2 = std::abs(Scalar(1e-6) * diag - ray_depths2(b)); + bool hit = hits & (1 << b); + bool hit2 = hits2 & (1 << b); + if (hit2 && (!hit || len2 < ray_depths(b))) { + hits |= 1 << b; + mesh_indices(b) = mesh_indices2(b); + facet_indices(b) = facet_indices2(b); + barys.row(b) = barys2.row(b); + } + } + } + for (Index b = 0; b < batchsize; ++b) { + if (!mask(b)) continue; + bool hit = hits & (1 << b); + Index i = (packet_index << 2) + b; + if (hit) { + // Hit occurred, interpolate data + la_runtime_assert( + facet_indices(b) >= 0 && + facet_indices(b) < static_cast(source.get_num_facets())); + auto face = source.get_facets().row(facet_indices(b)); + for (size_t k = 0; k < source_attrs.size(); ++k) { + target_attrs[k].row(i).setZero(); + for (int lv = 0; lv < 3; ++lv) { + target_attrs[k].row(i) += source_attrs[k]->row(face[lv]) * barys(b, lv); + } + } + } else { + // Not hit. Set values according to wrap_mode + call user-callback at the end if + // needed + for (size_t k = 0; k < source_attrs.size(); ++k) { + switch (wrap_mode) { + case FallbackMode::Constant: + target_attrs[k].row(i).setConstant(safe_cast(default_value)); + break; + default: break; + } + } + } + if (!is_hit.empty()) { + is_hit[i] = hit; + } + if (user_callback) { + user_callback(i, hit); + } + } + }); + + // Not super pretty way, we still need to separately add/create the attribute, + // THEN import it without copy. Would be better if we could get a ref to it. + for (size_t k = 0; k < names.size(); ++k) { + const auto& name = names[k]; + target.add_vertex_attribute(name); + target.import_vertex_attribute(name, target_attrs[k]); + } + + // If there is any vertex without a hit, we defer to the relevant functions with filtering + if (wrap_mode != FallbackMode::Constant) { + bool all_hit = std::all_of(is_hit.begin(), is_hit.end(), [](char x) { return bool(x); }); + if (!all_hit) { + if (wrap_mode == FallbackMode::ClosestPoint) { + project_attributes_closest_point(source, target, names, ray_caster, [&](Index i) { + return bool(is_hit[i]); + }); + } else if (wrap_mode == FallbackMode::ClosestVertex) { + ::lagrange::bvh::project_attributes_closest_vertex( + source, + target, + names, + [&](Index i) { return bool(is_hit[i]); }); + } else { + throw std::runtime_error("not implemented"); + } + } + } +} + +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/project_particles_directional.h b/modules/raycasting/include/lagrange/raycasting/legacy/project_particles_directional.h new file mode 100644 index 00000000..a719a296 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/legacy/project_particles_directional.h @@ -0,0 +1,237 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +// clang-format on + +#include + +#include +#include +#include +#include + +namespace lagrange { +namespace raycasting { +LAGRANGE_LEGACY_INLINE +namespace legacy { + +/// +/// Project particles (a particle contains origin and tangent info) to another mesh, by projecting +/// their positions along a prescribed direction. The returned results are particles whose transform +/// is recorded in the local coordinate. +/// +/// @note How to compute the particle transformation after the projection: +/// +/// Assuming the projection transformation is P and the cumulated parents transformation +/// is C, The matrix M that transforms local to world coordinates is given by: +/// +/// M = P * C +/// = C * (C^-1 * P * C) +/// +/// Where the matrix in parenthesis is the new axis system matrix after projection: +/// P' = C^-1 * P * C +/// +/// We compute P * C, and then deduce P' +/// +/// @param[in] origins Origin of the particles. +/// @param[in] mesh_proj_on Mesh to be projected on. +/// @param[in] direction Raycasting direction to project attributes. +/// @param[out] out_origins Output origin of the particles. +/// @param[out] out_normals Output normal of the polygon at intersections. +/// @param[in] parent_transforms Cumulated parent transforms applied on the particles +/// @param[in,out] ray_caster If provided, the use ray_caster to perform the queries instead. +/// The target mesh will assume to have been added to `ray_caster` in +/// advance, and this function will not try to add it. This allows to +/// use a different ray caster than the one computed by this +/// function, and allows to nest function calls. +/// @param[in] has_normals Whether to compute and output normals +/// +/// @tparam ParticleDataType Particle data vector type. +/// @tparam MeshType Mesh type. +/// @tparam DefaultScalar Scalar type used in computation. +/// @tparam MatrixType Matrix type for the transform. +/// @tparam VectorType Vector type for the direction. +/// +template < + typename ParticleDataType, + typename MeshType, + typename DefaultScalar = typename ParticleDataType::value_type::Scalar, + typename MatrixType = typename Eigen::Matrix, + typename VectorType = typename Eigen::Matrix> +void project_particles_directional( + const ParticleDataType& origins, + const MeshType& mesh_proj_on, + const VectorType& direction, + ParticleDataType& out_origins, + ParticleDataType& out_normals, + const MatrixType& parent_transforms = MatrixType::Identity(), + EmbreeRayCaster>* ray_caster = nullptr, + bool has_normals = true) +{ + static_assert(MeshTrait::is_mesh(), "Mesh type is wrong"); + + // Typedef festival because templates... + using Scalar = DefaultScalar; + using Index = typename MeshType::Index; + using Point = typename EmbreeRayCaster::Point; + using Direction = typename EmbreeRayCaster::Direction; + using Scalar4 = typename EmbreeRayCaster::Scalar4; + using Index4 = typename EmbreeRayCaster::Index4; + using Point4 = typename EmbreeRayCaster::Point4; + using Direction4 = typename EmbreeRayCaster::Direction4; + using Mask4 = typename EmbreeRayCaster::Mask4; + + // We need to convert to a shared_ptr AND the ray caster will make another copy of the data.. + std::unique_ptr> engine; + if (!ray_caster) { + auto mesh = lagrange::to_shared_ptr( + lagrange::create_mesh(mesh_proj_on.get_vertices(), mesh_proj_on.get_facets())); + // Robust mode gives slightly more accurate results... + engine = create_ray_caster(EMBREE_ROBUST, BUILD_QUALITY_HIGH); + + // Gosh why do I need to specify a transform here? + engine->add_mesh(mesh, Eigen::Matrix::Identity()); + + // Do a dummy raycast to trigger scene update, otherwise `cast()` will not work in + // multithread mode... (this is why we need const-safety...) + engine->cast(Point(0, 0, 0), Direction(0, 0, 1)); + ray_caster = engine.get(); + } else { + logger().debug("Using provided ray-caster"); + } + + Index num_particles = safe_cast(origins.size()); + + // C^-1 + MatrixType parent_transforms_inv = parent_transforms.inverse(); + bool use_parent_transforms = !parent_transforms.isIdentity(); + + VectorType dir = direction.normalized(); + Index num_ray_packets = (num_particles + 3) / 4; + Direction4 dirs; + dirs.row(0) = dir.transpose(); + for (int i = 1; i < 4; ++i) { + dirs.row(i) = dirs.row(0); + } + + std::vector vector_hits(num_ray_packets, safe_cast(0u)); + + ParticleDataType projected_origins; + ParticleDataType projected_normals; + + out_origins.resize(num_particles); + if (has_normals) out_normals.resize(num_particles); + + tbb::parallel_for(Index(0), num_ray_packets, [&](Index packet_index) { + uint32_t batchsize = + safe_cast(std::min(num_particles - (packet_index << 2), safe_cast(4))); + Mask4 mask = Mask4::Constant(-1); + Point4 ray_origins; + + for (uint32_t b = 0; b < batchsize; ++b) { + Index i = (packet_index << 2) + b; + + // C * L + if (use_parent_transforms) { + ray_origins.row(b) = + (parent_transforms * origins[i].homogeneous()).hnormalized().transpose(); + } else { + ray_origins.row(b) = origins[i].transpose(); + } + } + + for (Index b = batchsize; b < 4; ++b) { + mask(b) = 0; + } + + Index4 mesh_indices; + Index4 instance_indices; + Index4 facet_indices; + Scalar4 ray_depths; + Point4 barys; + Point4 normals; + uint8_t hits = ray_caster->cast4( + batchsize, + ray_origins, + dirs, + mask, + mesh_indices, + instance_indices, + facet_indices, + ray_depths, + barys, + normals); + + if (!hits) return; + + vector_hits[packet_index] = hits; + + for (uint32_t b = 0; b < batchsize; ++b) { + bool hit = hits & (1 << b); + if (hit) { + Index i = (packet_index << 2) + b; + if (has_normals) { + VectorType norm = + (ray_caster->get_transform(mesh_indices[b], instance_indices[b]) + .template topLeftCorner<3, 3>() * + normals.row(b).transpose()) + .normalized(); + if (use_parent_transforms) { + norm = parent_transforms_inv.template topLeftCorner<3, 3>() * norm; + } + out_normals[i] = norm; + } + + VectorType old_pos = ray_origins.row(b).transpose(); + out_origins[i] = old_pos + dir * ray_depths(b); + if (use_parent_transforms) { + out_origins[i] = + (parent_transforms_inv * out_origins[i].homogeneous()).hnormalized(); + } + } + } + }); + + // remove redundant output data + Index i = 0; + auto remove_func = [&](const VectorType&) -> bool { + Index packet_index = i >> 2; + Index b = i++ - (packet_index << 2); + return !(vector_hits[packet_index] & (1 << b)); + }; + + out_origins.erase( + std::remove_if(out_origins.begin(), out_origins.end(), remove_func), + out_origins.end()); + + if (has_normals) { + i = 0; + out_normals.erase( + std::remove_if(out_normals.begin(), out_normals.end(), remove_func), + out_normals.end()); + } +} +} // namespace legacy +} // namespace raycasting +} // namespace lagrange diff --git a/modules/raycasting/include/lagrange/raycasting/project.h b/modules/raycasting/include/lagrange/raycasting/project.h new file mode 100644 index 00000000..4a0ed349 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/project.h @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +namespace lagrange::raycasting { + +class RayCaster; + +/// +/// @addtogroup group-raycasting +/// @{ +/// + +/// +/// Project vertex attributes from one mesh to another. Different projection modes can be prescribed. +/// +/// By default, vertex positions are projected. Additional attributes to project can be specified via +/// `options.attribute_ids`. Set `options.project_vertices` to false to skip vertex positions. +/// +/// @param[in] source Source mesh (must be a triangle mesh). +/// @param[in,out] target Target mesh to be modified. +/// @param[in] options Projection options. +/// @param[in] ray_caster If provided, use this ray caster to perform the queries. The +/// source mesh must have been added to the ray caster in advance, and +/// the scene must have been committed. If nullptr, a temporary ray +/// caster will be created internally. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_RAYCASTING_API void project( + const SurfaceMesh& source, + SurfaceMesh& target, + const ProjectOptions& options = {}, + const RayCaster* ray_caster = nullptr); + +/// @} + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/include/lagrange/raycasting/project_attributes.h b/modules/raycasting/include/lagrange/raycasting/project_attributes.h index 496aaf3f..7aad8cb1 100644 --- a/modules/raycasting/include/lagrange/raycasting/project_attributes.h +++ b/modules/raycasting/include/lagrange/raycasting/project_attributes.h @@ -11,87 +11,6 @@ */ #pragma once -#include -#include -#include -#include - -namespace lagrange { -namespace raycasting { - -/// -/// Project vertex attributes from one mesh to another. Different projection modes can be prescribed. -/// -/// @param[in] source Source mesh. -/// @param[in,out] target Target mesh to be modified. -/// @param[in] names Name of the vertex attributes to transfer. -/// @param[in] project_mode Projection mode to choose from. -/// @param[in] direction Raycasting direction to project attributes. -/// @param[in] cast_mode Whether to project forward along the ray, or to project along the -/// whole ray (both forward and backward). -/// @param[in] wrap_mode Wrapping mode for values where there is no hit. -/// @param[in] default_value Scalar used to fill attributes in CONSTANT wrapping mode. -/// @param[in] user_callback Optional user callback that can be used to set attribute values -/// depending on whether there is a hit or not. -/// @param[in,out] ray_caster If provided, the use ray_caster to perform the queries instead. -/// The source mesh will assume to have been added to `ray_caster` in -/// advance, and this function will not try to add it. This allows to -/// use a different ray caster than the one computed by this -/// function, and allows to nest function calls. -/// @param[in] skip_vertex If provided, whether to skip assignment for a target vertex or -/// not. This can be used for partial assignment (e.g. to only set -/// boundary vertices of a mesh). -/// -/// @tparam SourceMeshType Source mesh type. -/// @tparam TargetMeshType Target mesh type. -/// @tparam DerivedVector Vector type for the direction. -/// @tparam DefaultScalar Scalar type used to fill attributes. -/// -template < - typename SourceMeshType, - typename TargetMeshType, - typename DerivedVector = Eigen::Matrix, 3, 1>, - typename DefaultScalar = typename SourceMeshType::Scalar> -void project_attributes( - const SourceMeshType& source, - TargetMeshType& target, - const std::vector& names, - ProjectMode project_mode, - const Eigen::MatrixBase& direction = DerivedVector(0, 0, 1), - CastMode cast_mode = CastMode::BOTH_WAYS, - WrapMode wrap_mode = WrapMode::CONSTANT, - DefaultScalar default_value = DefaultScalar(0), - std::function user_callback = nullptr, - EmbreeRayCaster>* ray_caster = nullptr, - std::function)> skip_vertex = nullptr) -{ - static_assert(MeshTrait::is_mesh(), "Input type is not Mesh"); - static_assert(MeshTrait::is_mesh(), "Output type is not Mesh"); - la_runtime_assert(source.get_vertex_per_facet() == 3); - - switch (project_mode) { - case ProjectMode::CLOSEST_VERTEX: - ::lagrange::bvh::project_attributes_closest_vertex(source, target, names, skip_vertex); - break; - case ProjectMode::CLOSEST_POINT: - project_attributes_closest_point(source, target, names, ray_caster, skip_vertex); - break; - case ProjectMode::RAY_CASTING: - project_attributes_directional( - source, - target, - names, - direction, - cast_mode, - wrap_mode, - default_value, - user_callback, - ray_caster, - skip_vertex); - break; - default: throw std::runtime_error("Not implemented"); - } -} - -} // namespace raycasting -} // namespace lagrange +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/include/lagrange/raycasting/project_attributes_closest_point.h b/modules/raycasting/include/lagrange/raycasting/project_attributes_closest_point.h index 8a7f2121..83c4c97f 100644 --- a/modules/raycasting/include/lagrange/raycasting/project_attributes_closest_point.h +++ b/modules/raycasting/include/lagrange/raycasting/project_attributes_closest_point.h @@ -11,121 +11,6 @@ */ #pragma once -#include -#include -#include -#include -#include - -#include - -#include -#include -#include - -namespace lagrange { -namespace raycasting { - -/// -/// Project vertex attributes from one mesh to another, by copying attributes from the closest point -/// on the input mesh. Values are linearly interpolated from the face corners. -/// -/// @param[in] source Source mesh. -/// @param[in,out] target Target mesh to be modified. -/// @param[in] names Name of the vertex attributes to transfer. -/// @param[in,out] ray_caster If provided, the use ray_caster to perform the queries instead. -/// The source mesh will assume to have been added to `ray_caster` in -/// advance, and this function will not try to add it. This allows to -/// use a different ray caster than the one computed by this -/// function, and allows to nest function calls. -/// @param[in] skip_vertex If provided, whether to skip assignment for a target vertex or -/// not. This can be used for partial assignment (e.g. to only set -/// boundary vertices of a mesh). -/// -/// @tparam SourceMeshType Source mesh type. -/// @tparam TargetMeshType Target mesh type. -/// -template -void project_attributes_closest_point( - const SourceMeshType& source, - TargetMeshType& target, - const std::vector& names, - EmbreeRayCaster>* ray_caster = nullptr, - std::function)> skip_vertex = nullptr) -{ - static_assert(MeshTrait::is_mesh(), "Input type is not Mesh"); - static_assert(MeshTrait::is_mesh(), "Output type is not Mesh"); - la_runtime_assert(source.get_vertex_per_facet() == 3); - - // Typedef festival because templates... - using Scalar = typename SourceMeshType::Scalar; - using Index = typename TargetMeshType::Index; - using SourceArray = typename SourceMeshType::AttributeArray; - using TargetArray = typename SourceMeshType::AttributeArray; - using Point = typename EmbreeRayCaster::Point; - using Direction = typename EmbreeRayCaster::Direction; - - // We need to convert to a shared_ptr AND the ray caster will make another copy of the data.. - std::unique_ptr> engine; - if (!ray_caster) { - auto mesh = lagrange::to_shared_ptr( - lagrange::create_mesh(source.get_vertices(), source.get_facets())); - // Robust mode gives slightly more accurate results... - engine = create_ray_caster(EMBREE_ROBUST, BUILD_QUALITY_HIGH); - - // Gosh why do I need to specify a transform here? - engine->add_mesh(mesh, Eigen::Matrix::Identity()); - - // Do a dummy raycast to trigger scene update, otherwise `cast()` will not work in - // multithread mode... (this is why we need const-safety...) - engine->cast(Point(0, 0, 0), Direction(0, 0, 1)); - ray_caster = engine.get(); - } else { - logger().debug("Using provided ray-caster"); - } - - // Store pointer to source/target arrays - std::vector source_attrs(names.size()); - std::vector target_attrs(names.size()); - for (size_t k = 0; k < names.size(); ++k) { - const auto& name = names[k]; - la_runtime_assert(source.has_vertex_attribute(name)); - source_attrs[k] = &source.get_vertex_attribute(name); - if (target.has_vertex_attribute(name)) { - target.export_vertex_attribute(name, target_attrs[k]); - } else { - target_attrs[k].resize(target.get_num_vertices(), source_attrs[k]->cols()); - } - } - - tbb::parallel_for(Index(0), target.get_num_vertices(), [&](Index i) { - if (skip_vertex && skip_vertex(i)) { - logger().trace("skipping vertex: {}", i); - return; - } - Point query = target.get_vertices().row(i).transpose(); - auto res = ray_caster->query_closest_point(query); - la_runtime_assert( - res.facet_index >= 0 && res.facet_index < (unsigned)source.get_num_facets()); - auto face = source.get_facets().row(res.facet_index).eval(); - Point bary = res.barycentric_coord; - - for (size_t k = 0; k < source_attrs.size(); ++k) { - target_attrs[k].row(i).setZero(); - for (int lv = 0; lv < 3; ++lv) { - target_attrs[k].row(i) += source_attrs[k]->row(face[lv]) * bary[lv]; - } - } - }); - - // Not super pretty way, we still need to separately add/create the attribute, - // THEN import it without copy. Would be better if we could get a ref to it. - for (size_t k = 0; k < names.size(); ++k) { - const auto& name = names[k]; - target.add_vertex_attribute(name); - target.import_vertex_attribute(name, target_attrs[k]); - } -} - -} // namespace raycasting -} // namespace lagrange +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/include/lagrange/raycasting/project_attributes_directional.h b/modules/raycasting/include/lagrange/raycasting/project_attributes_directional.h index a3727cb9..edbaad88 100644 --- a/modules/raycasting/include/lagrange/raycasting/project_attributes_directional.h +++ b/modules/raycasting/include/lagrange/raycasting/project_attributes_directional.h @@ -11,292 +11,6 @@ */ #pragma once -#include -#include -#include -#include -#include -#include -#include - -// clang-format off -#include -#include -#include -// clang-format on - -#include - -#include -#include -#include -#include - -namespace lagrange { -namespace raycasting { - -/// -/// Project vertex attributes from one mesh to another, by projecting target vertices along a -/// prescribed direction, and interpolating surface values from facet corners of the source mesh. -/// -/// @note In the future may want to support using a vector field instead of a constant -/// direction for projection. -/// -/// @param[in] source Source mesh. -/// @param[in,out] target Target mesh to be modified. -/// @param[in] names Name of the vertex attributes to transfer. -/// @param[in] direction Raycasting direction to project attributes. -/// @param[in] cast_mode Whether to project forward along the ray, or to project along the -/// whole ray (both forward and backward). -/// @param[in] wrap_mode Wrapping mode for values where there is no hit. -/// @param[in] default_value Scalar used to fill attributes in CONSTANT wrapping mode. -/// @param[in] user_callback Optional user callback that can be used to set attribute values -/// depending on whether there is a hit or not. -/// @param[in,out] ray_caster If provided, the use ray_caster to perform the queries instead. -/// The source mesh will assume to have been added to `ray_caster` in -/// advance, and this function will not try to add it. This allows to -/// use a different ray caster than the one computed by this -/// function, and allows to nest function calls. -/// @param[in] skip_vertex If provided, whether to skip assignment for a target vertex or -/// not. This can be used for partial assignment (e.g. to only set -/// boundary vertices of a mesh). -/// -/// @tparam SourceMeshType Source mesh type. -/// @tparam TargetMeshType Target mesh type. -/// @tparam DerivedVector Vector type for the direction. -/// @tparam DefaultScalar Scalar type used to fill attributes. -/// -template < - typename SourceMeshType, - typename TargetMeshType, - typename DerivedVector, - typename DefaultScalar = typename SourceMeshType::Scalar> -void project_attributes_directional( - const SourceMeshType& source, - TargetMeshType& target, - const std::vector& names, - const Eigen::MatrixBase& direction, - CastMode cast_mode = CastMode::BOTH_WAYS, - WrapMode wrap_mode = WrapMode::CONSTANT, - DefaultScalar default_value = DefaultScalar(0), - std::function user_callback = nullptr, - EmbreeRayCaster>* ray_caster = nullptr, - std::function)> skip_vertex = nullptr) -{ - static_assert(MeshTrait::is_mesh(), "Input type is not Mesh"); - static_assert(MeshTrait::is_mesh(), "Output type is not Mesh"); - la_runtime_assert(source.get_vertex_per_facet() == 3); - - // Typedef festival because templates... - using Scalar = typename SourceMeshType::Scalar; - using Index = typename TargetMeshType::Index; - using SourceArray = typename SourceMeshType::AttributeArray; - using TargetArray = typename SourceMeshType::AttributeArray; - using Point = typename EmbreeRayCaster::Point; - using Direction = typename EmbreeRayCaster::Direction; - using RayCasterIndex = typename EmbreeRayCaster::Index; - using Scalar4 = typename EmbreeRayCaster::Scalar4; - using Index4 = typename EmbreeRayCaster::Index4; - using Point4 = typename EmbreeRayCaster::Point4; - using Direction4 = typename EmbreeRayCaster::Direction4; - using Mask4 = typename EmbreeRayCaster::Mask4; - - // We need to convert to a shared_ptr AND the ray caster will make another copy of the data.. - std::unique_ptr> engine; - if (!ray_caster) { - auto mesh = lagrange::to_shared_ptr( - lagrange::create_mesh(source.get_vertices(), source.get_facets())); - // Robust mode gives slightly more accurate results... - engine = create_ray_caster(EMBREE_ROBUST, BUILD_QUALITY_HIGH); - - // Gosh why do I need to specify a transform here? - engine->add_mesh(mesh, Eigen::Matrix::Identity()); - - // Do a dummy raycast to trigger scene update, otherwise `cast()` will not work in - // multithread mode... (this is why we need const-safety...) - engine->cast(Point(0, 0, 0), Direction(0, 0, 1)); - ray_caster = engine.get(); - } else { - logger().debug("Using provided ray-caster"); - } - - // Store pointer to source/target arrays - std::vector source_attrs; - source_attrs.reserve(names.size()); - std::vector target_attrs(names.size()); - for (size_t k = 0; k < names.size(); ++k) { - const auto& name = names[k]; - la_runtime_assert(source.has_vertex_attribute(name)); - source_attrs.push_back(&source.get_vertex_attribute(name)); - if (target.has_vertex_attribute(name)) { - target.export_vertex_attribute(name, target_attrs[k]); - } else { - target_attrs[k].resize(target.get_num_vertices(), source_attrs[k]->cols()); - } - } - - auto diag = igl::bounding_box_diagonal(source.get_vertices()); - - // Using `char` instead of `bool` because we write concurrently to this - std::vector is_hit; - if (wrap_mode != WrapMode::CONSTANT) { - is_hit.assign(target.get_num_vertices(), false); - } - - Index num_vertices = target.get_num_vertices(); - Index num_packets = (num_vertices + 3) / 4; - - Direction4 dirs; - dirs.row(0) = direction.normalized().transpose(); - for (int i = 1; i < 4; ++i) { - dirs.row(i) = dirs.row(0); - } - Direction4 dirs2 = -dirs; - - tbb::parallel_for(Index(0), num_packets, [&](Index packet_index) { - Index batchsize = std::min(num_vertices - (packet_index << 2), 4); - Mask4 mask = Mask4::Constant(-1); - Point4 origins; - - int num_skipped_in_packet = 0; - for (Index b = 0; b < batchsize; ++b) { - Index i = (packet_index << 2) + b; - if (skip_vertex && skip_vertex(i)) { - logger().trace("skipping vertex: {}", i); - if (!is_hit.empty()) { - is_hit[i] = true; - } - mask(b) = 0; - ++num_skipped_in_packet; - continue; - } - - origins.row(b) = target.get_vertices().row(i); - } - - if (num_skipped_in_packet == batchsize) return; - - for (Index b = batchsize; b < 4; ++b) { - mask(b) = 0; - } - - Index4 mesh_indices; - Index4 instance_indices; - Index4 facet_indices; - Scalar4 ray_depths; - Point4 barys; - Point4 normals; - uint8_t hits = ray_caster->cast4( - batchsize, - origins, - dirs, - mask, - mesh_indices, - instance_indices, - facet_indices, - ray_depths, - barys, - normals); - - if (cast_mode == CastMode::BOTH_WAYS) { - // Try again in the other direction. Slightly offset ray origin, and keep closest point. - Point4 origins2 = origins + Scalar(1e-6) * diag * dirs; - Index4 mesh_indices2; - Index4 instance_indices2; - Index4 facet_indices2; - Scalar4 ray_depths2; - Point4 barys2; - Point4 norms2; - uint8_t hits2 = ray_caster->cast4( - batchsize, - origins2, - dirs2, - mask, - mesh_indices2, - instance_indices2, - facet_indices2, - ray_depths2, - barys2, - norms2); - - for (Index b = 0; b < batchsize; ++b) { - if (!mask(b)) continue; - auto len2 = std::abs(Scalar(1e-6) * diag - ray_depths2(b)); - bool hit = hits & (1 << b); - bool hit2 = hits2 & (1 << b); - if (hit2 && (!hit || len2 < ray_depths(b))) { - hits |= 1 << b; - mesh_indices(b) = mesh_indices2(b); - facet_indices(b) = facet_indices2(b); - barys.row(b) = barys2.row(b); - } - } - } - for (Index b = 0; b < batchsize; ++b) { - if (!mask(b)) continue; - bool hit = hits & (1 << b); - Index i = (packet_index << 2) + b; - if (hit) { - // Hit occurred, interpolate data - la_runtime_assert( - facet_indices(b) >= 0 && - facet_indices(b) < static_cast(source.get_num_facets())); - auto face = source.get_facets().row(facet_indices(b)); - for (size_t k = 0; k < source_attrs.size(); ++k) { - target_attrs[k].row(i).setZero(); - for (int lv = 0; lv < 3; ++lv) { - target_attrs[k].row(i) += source_attrs[k]->row(face[lv]) * barys(b, lv); - } - } - } else { - // Not hit. Set values according to wrap_mode + call user-callback at the end if - // needed - for (size_t k = 0; k < source_attrs.size(); ++k) { - switch (wrap_mode) { - case WrapMode::CONSTANT: - target_attrs[k].row(i).setConstant(safe_cast(default_value)); - break; - default: break; - } - } - } - if (!is_hit.empty()) { - is_hit[i] = hit; - } - if (user_callback) { - user_callback(i, hit); - } - } - }); - - // Not super pretty way, we still need to separately add/create the attribute, - // THEN import it without copy. Would be better if we could get a ref to it. - for (size_t k = 0; k < names.size(); ++k) { - const auto& name = names[k]; - target.add_vertex_attribute(name); - target.import_vertex_attribute(name, target_attrs[k]); - } - - // If there is any vertex without a hit, we defer to the relevant functions with filtering - if (wrap_mode != WrapMode::CONSTANT) { - bool all_hit = std::all_of(is_hit.begin(), is_hit.end(), [](char x) { return bool(x); }); - if (!all_hit) { - if (wrap_mode == WrapMode::CLOSEST_POINT) { - project_attributes_closest_point(source, target, names, ray_caster, [&](Index i) { - return bool(is_hit[i]); - }); - } else if (wrap_mode == WrapMode::CLOSEST_VERTEX) { - ::lagrange::bvh::project_attributes_closest_vertex( - source, - target, - names, - [&](Index i) { return bool(is_hit[i]); }); - } else { - throw std::runtime_error("not implemented"); - } - } - } -} - -} // namespace raycasting -} // namespace lagrange +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/include/lagrange/raycasting/project_closest_point.h b/modules/raycasting/include/lagrange/raycasting/project_closest_point.h new file mode 100644 index 00000000..281a79a8 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/project_closest_point.h @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace lagrange::raycasting { + +class RayCaster; + +/// +/// @addtogroup group-raycasting +/// @{ +/// + +/// +/// Project vertex attributes from one mesh to another, by copying attributes from the closest point +/// on the input mesh. Values are linearly interpolated from the face corners. +/// +/// By default, vertex positions are projected. Additional attributes to project can be specified via +/// `options.attribute_ids`. Set `options.project_vertices` to false to skip vertex positions. +/// +/// @param[in] source Source mesh (must be a triangle mesh). +/// @param[in,out] target Target mesh to be modified. +/// @param[in] options Projection options. +/// @param[in] ray_caster If provided, use this ray caster to perform the queries. The +/// source mesh must have been added to the ray caster in advance, and +/// the scene must have been committed. If nullptr, a temporary ray +/// caster will be created internally. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_RAYCASTING_API void project_closest_point( + const SurfaceMesh& source, + SurfaceMesh& target, + const ProjectCommonOptions& options = {}, + const RayCaster* ray_caster = nullptr); + +/// @} + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/include/lagrange/raycasting/project_closest_vertex.h b/modules/raycasting/include/lagrange/raycasting/project_closest_vertex.h new file mode 100644 index 00000000..2400b99f --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/project_closest_vertex.h @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace lagrange::raycasting { + +class RayCaster; + +/// +/// @addtogroup group-raycasting +/// @{ +/// + +/// +/// Project vertex attributes from one mesh to another, by copying attributes from the closest +/// vertex on the source mesh surface. The closest surface point is found via a closest-point query, +/// then snapped to the nearest vertex of the hit triangle (the vertex with the largest barycentric +/// weight). +/// +/// By default, vertex positions are projected. Additional attributes to project can be specified via +/// `options.attribute_ids`. Set `options.project_vertices` to false to skip vertex positions. +/// +/// @param[in] source Source mesh (must be a triangle mesh). +/// @param[in,out] target Target mesh to be modified. +/// @param[in] options Projection options. +/// @param[in] ray_caster If provided, use this ray caster to perform the queries. The +/// source mesh must have been added to the ray caster in advance, and +/// the scene must have been committed. If nullptr, a temporary ray +/// caster will be created internally. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_RAYCASTING_API void project_closest_vertex( + const SurfaceMesh& source, + SurfaceMesh& target, + const ProjectCommonOptions& options = {}, + const RayCaster* ray_caster = nullptr); + +/// @} + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/include/lagrange/raycasting/project_directional.h b/modules/raycasting/include/lagrange/raycasting/project_directional.h new file mode 100644 index 00000000..0e829d4e --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/project_directional.h @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +namespace lagrange::raycasting { + +class RayCaster; + +/// +/// @addtogroup group-raycasting +/// @{ +/// + +/// +/// Project vertex attributes from one mesh to another, by projecting target vertices along a +/// prescribed direction, and interpolating surface values from facet corners of the source mesh. +/// +/// By default, vertex positions are projected. Additional attributes to project can be specified via +/// `options.attribute_ids`. Set `options.project_vertices` to false to skip vertex positions. +/// +/// @param[in] source Source mesh (must be a triangle mesh). +/// @param[in,out] target Target mesh to be modified. +/// @param[in] options Projection options. +/// @param[in] ray_caster If provided, use this ray caster to perform the queries. The +/// source mesh must have been added to the ray caster in advance, and +/// the scene must have been committed. If nullptr, a temporary ray +/// caster will be created internally. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_RAYCASTING_API void project_directional( + const SurfaceMesh& source, + SurfaceMesh& target, + const ProjectDirectionalOptions& options = {}, + const RayCaster* ray_caster = nullptr); + +/// @} + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/include/lagrange/raycasting/project_options.h b/modules/raycasting/include/lagrange/raycasting/project_options.h index 436ea3a7..a23694ef 100644 --- a/modules/raycasting/include/lagrange/raycasting/project_options.h +++ b/modules/raycasting/include/lagrange/raycasting/project_options.h @@ -11,60 +11,7 @@ */ #pragma once -#include -#include - -namespace lagrange { -namespace raycasting { - -/// Main projection mode -enum class ProjectMode { - CLOSEST_VERTEX, ///< Copy attribute from the closest vertex on the source mesh. - CLOSEST_POINT, ///< Interpolate attribute from the closest point on the source mesh. - RAY_CASTING, ///< Copy attribute by projecting along a prescribed direction on the source mesh. -}; - -/// Ray-casting mode -enum class CastMode { - ONE_WAY, ///< Cast a ray forward in the prescribed direction. - BOTH_WAYS, ///< Cast a ray both forward and backward in the prescribed direction. -}; - -/// Wraping mode for vertices without a hit -enum class WrapMode { - CONSTANT, ///< Fill with a constant value (defaults to 0). - CLOSEST_VERTEX, ///< Copy attribute from the closest vertex on the source mesh. - CLOSEST_POINT, ///< Interpolate attribute from the closest point on the source mesh. -}; - -inline const std::map& project_modes() -{ - static std::map _modes = { - {"CLOSEST_VERTEX", ProjectMode::CLOSEST_VERTEX}, - {"CLOSEST_POINT", ProjectMode::CLOSEST_POINT}, - {"RAY_CASTING", ProjectMode::RAY_CASTING}, - }; - return _modes; -} - -inline const std::map& cast_modes() -{ - static std::map _modes = { - {"ONE_WAY", CastMode::ONE_WAY}, - {"BOTH_WAYS", CastMode::BOTH_WAYS}, - }; - return _modes; -} - -inline const std::map& wrap_modes() -{ - static std::map _modes = { - {"CONSTANT", WrapMode::CONSTANT}, - {"CLOSEST_VERTEX", WrapMode::CLOSEST_VERTEX}, - {"CLOSEST_POINT", WrapMode::CLOSEST_POINT}, - }; - return _modes; -} - -} // namespace raycasting -} // namespace lagrange +// Deprecated: include instead. +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/include/lagrange/raycasting/project_particles_directional.h b/modules/raycasting/include/lagrange/raycasting/project_particles_directional.h index 03c29415..727e9560 100644 --- a/modules/raycasting/include/lagrange/raycasting/project_particles_directional.h +++ b/modules/raycasting/include/lagrange/raycasting/project_particles_directional.h @@ -11,223 +11,7 @@ */ #pragma once -#include -#include -#include -#include -#include - -// clang-format off -#include -#include -#include -// clang-format on - -#include - -#include -#include -#include -#include - -namespace lagrange { -namespace raycasting { - -/// -/// Project particles (a particle contains origin and tangent info) to another mesh, by projecting -/// their positions along a prescribed direction. The returned results are particles whose transform -/// is recorded in the local coordinate. -/// -/// @note How to compute the particle transformation after the projection: -/// -/// Assuming the projection transformation is P and the cumulated parents transformation -/// is C, The matrix M that transforms local to world coordinates is given by: -/// -/// M = P * C -/// = C * (C^-1 * P * C) -/// -/// Where the matrix in parenthesis is the new axis system matrix after projection: -/// P' = C^-1 * P * C -/// -/// We compute P * C, and then deduce P' -/// -/// @param[in] origins Origin of the particles. -/// @param[in] mesh_proj_on Mesh to be projected on. -/// @param[in] direction Raycasting direction to project attributes. -/// @param[out] out_origins Output origin of the particles. -/// @param[out] out_normals Output normal of the polygon at intersections. -/// @param[in] parent_transforms Cumulated parent transforms applied on the particles -/// @param[in,out] ray_caster If provided, the use ray_caster to perform the queries instead. -/// The target mesh will assume to have been added to `ray_caster` in -/// advance, and this function will not try to add it. This allows to -/// use a different ray caster than the one computed by this -/// function, and allows to nest function calls. -/// @param[in] has_normals Whether to compute and output normals -/// -/// @tparam ParticleDataType Particle data vector type. -/// @tparam MeshType Mesh type. -/// @tparam DefaultScalar Scalar type used in computation. -/// @tparam MatrixType Matrix type for the transform. -/// @tparam VectorType Vector type for the direction. -/// -template < - typename ParticleDataType, - typename MeshType, - typename DefaultScalar = typename ParticleDataType::value_type::Scalar, - typename MatrixType = typename Eigen::Matrix, - typename VectorType = typename Eigen::Matrix> -void project_particles_directional( - const ParticleDataType& origins, - const MeshType& mesh_proj_on, - const VectorType& direction, - ParticleDataType& out_origins, - ParticleDataType& out_normals, - const MatrixType& parent_transforms = MatrixType::Identity(), - EmbreeRayCaster>* ray_caster = nullptr, - bool has_normals = true) -{ - static_assert(MeshTrait::is_mesh(), "Mesh type is wrong"); - - // Typedef festival because templates... - using Scalar = DefaultScalar; - using Index = typename MeshType::Index; - using Point = typename EmbreeRayCaster::Point; - using Direction = typename EmbreeRayCaster::Direction; - using Scalar4 = typename EmbreeRayCaster::Scalar4; - using Index4 = typename EmbreeRayCaster::Index4; - using Point4 = typename EmbreeRayCaster::Point4; - using Direction4 = typename EmbreeRayCaster::Direction4; - using Mask4 = typename EmbreeRayCaster::Mask4; - - // We need to convert to a shared_ptr AND the ray caster will make another copy of the data.. - std::unique_ptr> engine; - if (!ray_caster) { - auto mesh = lagrange::to_shared_ptr( - lagrange::create_mesh(mesh_proj_on.get_vertices(), mesh_proj_on.get_facets())); - // Robust mode gives slightly more accurate results... - engine = create_ray_caster(EMBREE_ROBUST, BUILD_QUALITY_HIGH); - - // Gosh why do I need to specify a transform here? - engine->add_mesh(mesh, Eigen::Matrix::Identity()); - - // Do a dummy raycast to trigger scene update, otherwise `cast()` will not work in - // multithread mode... (this is why we need const-safety...) - engine->cast(Point(0, 0, 0), Direction(0, 0, 1)); - ray_caster = engine.get(); - } else { - logger().debug("Using provided ray-caster"); - } - - Index num_particles = safe_cast(origins.size()); - - // C^-1 - MatrixType parent_transforms_inv = parent_transforms.inverse(); - bool use_parent_transforms = !parent_transforms.isIdentity(); - - VectorType dir = direction.normalized(); - Index num_ray_packets = (num_particles + 3) / 4; - Direction4 dirs; - dirs.row(0) = dir.transpose(); - for (int i = 1; i < 4; ++i) { - dirs.row(i) = dirs.row(0); - } - - std::vector vector_hits(num_ray_packets, safe_cast(0u)); - - ParticleDataType projected_origins; - ParticleDataType projected_normals; - - out_origins.resize(num_particles); - if (has_normals) out_normals.resize(num_particles); - - tbb::parallel_for(Index(0), num_ray_packets, [&](Index packet_index) { - uint32_t batchsize = - safe_cast(std::min(num_particles - (packet_index << 2), safe_cast(4))); - Mask4 mask = Mask4::Constant(-1); - Point4 ray_origins; - - for (uint32_t b = 0; b < batchsize; ++b) { - Index i = (packet_index << 2) + b; - - // C * L - if (use_parent_transforms) { - ray_origins.row(b) = - (parent_transforms * origins[i].homogeneous()).hnormalized().transpose(); - } else { - ray_origins.row(b) = origins[i].transpose(); - } - } - - for (Index b = batchsize; b < 4; ++b) { - mask(b) = 0; - } - - Index4 mesh_indices; - Index4 instance_indices; - Index4 facet_indices; - Scalar4 ray_depths; - Point4 barys; - Point4 normals; - uint8_t hits = ray_caster->cast4( - batchsize, - ray_origins, - dirs, - mask, - mesh_indices, - instance_indices, - facet_indices, - ray_depths, - barys, - normals); - - if (!hits) return; - - vector_hits[packet_index] = hits; - - for (uint32_t b = 0; b < batchsize; ++b) { - bool hit = hits & (1 << b); - if (hit) { - Index i = (packet_index << 2) + b; - if (has_normals) { - VectorType norm = - (ray_caster->get_transform(mesh_indices[b], instance_indices[b]) - .template topLeftCorner<3, 3>() * - normals.row(b).transpose()) - .normalized(); - if (use_parent_transforms) { - norm = parent_transforms_inv.template topLeftCorner<3, 3>() * norm; - } - out_normals[i] = norm; - } - - VectorType old_pos = ray_origins.row(b).transpose(); - out_origins[i] = old_pos + dir * ray_depths(b); - if (use_parent_transforms) { - out_origins[i] = - (parent_transforms_inv * out_origins[i].homogeneous()).hnormalized(); - } - } - } - }); - - // remove redundant output data - Index i = 0; - auto remove_func = [&](const VectorType&) -> bool { - Index packet_index = i >> 2; - Index b = i++ - (packet_index << 2); - return !(vector_hits[packet_index] & (1 << b)); - }; - - out_origins.erase( - std::remove_if(out_origins.begin(), out_origins.end(), remove_func), - out_origins.end()); - - if (has_normals) { - i = 0; - out_normals.erase( - std::remove_if(out_normals.begin(), out_normals.end(), remove_func), - out_normals.end()); - } -} -} // namespace raycasting -} // namespace lagrange +// Deprecated. Use project_directional instead. +#ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS + #include +#endif diff --git a/modules/raycasting/python/CMakeLists.txt b/modules/raycasting/python/CMakeLists.txt new file mode 100644 index 00000000..884be284 --- /dev/null +++ b/modules/raycasting/python/CMakeLists.txt @@ -0,0 +1,12 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +lagrange_add_python_binding() diff --git a/modules/raycasting/python/include/lagrange/python/raycasting.h b/modules/raycasting/python/include/lagrange/python/raycasting.h new file mode 100644 index 00000000..d86c9e58 --- /dev/null +++ b/modules/raycasting/python/include/lagrange/python/raycasting.h @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include + +namespace lagrange::python { + +/// Populate the ``raycasting`` Python submodule. +void populate_raycasting_module(nanobind::module_& m); + +} // namespace lagrange::python diff --git a/modules/raycasting/python/src/raycasting.cpp b/modules/raycasting/python/src/raycasting.cpp new file mode 100644 index 00000000..b8d9dac5 --- /dev/null +++ b/modules/raycasting/python/src/raycasting.cpp @@ -0,0 +1,671 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace nb = nanobind; +using namespace nb::literals; + +namespace lagrange::python { + +namespace { + +using Scalar = double; +using Index = uint32_t; +using MeshType = SurfaceMesh; + +using NDArray3D = nb::ndarray, nb::c_contig, nb::device::cpu>; + +std::tuple, Shape, Stride> tensor_to_span(NDArray3D tensor) +{ + Shape shape; + Stride stride; + size_t size = 1; + for (size_t i = 0; i < tensor.ndim(); i++) { + shape.push_back(tensor.shape(i)); + stride.push_back(tensor.stride(i)); + size *= tensor.shape(i); + } + span data(static_cast(tensor.data()), size); + return {data, shape, stride}; +} + +MeshType make_point_mesh(NDArray3D tensor) +{ + MeshType mesh; + auto [values, shape, stride] = tensor_to_span(tensor); + la_runtime_assert(is_dense(shape, stride)); + la_runtime_assert(check_shape(shape, invalid(), mesh.get_dimension())); + Index num_vertices = static_cast(shape[0]); + + auto owner = std::make_shared(nb::find(tensor)); + auto id = mesh.wrap_as_vertices(values, num_vertices); + auto& attr = mesh.template ref_attribute(id); + attr.set_growth_policy(AttributeGrowthPolicy::ErrorIfExternal); + attr.set_copy_policy(AttributeCopyPolicy::ErrorIfExternal); + return mesh; +}; + +// TODO: Handle Vector3d and NDArray3f as well? +std::variant resolve_direction( + std::variant direction, + SurfaceMesh& mesh) +{ + std::variant result; + std::visit( + [&](auto&& arg) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + result = arg; + } else { + // NDArray3D: wrap as a const per-vertex normal attribute. + auto [values, shape, stride] = tensor_to_span(arg); + la_runtime_assert(is_dense(shape, stride)); + la_runtime_assert(check_shape(shape, invalid(), mesh.get_dimension())); + auto id = mesh.template wrap_as_const_attribute( + "@direction", + AttributeElement::Vertex, + AttributeUsage::Normal, + 3, + values); + result = id; + } + }, + direction); + return result; +} + +} // namespace + +void populate_raycasting_module(nb::module_& m) +{ + // ========================================================================= + // Enums + // ========================================================================= + + nb::enum_( + m, + "SceneFlags", + nb::is_arithmetic(), + "Flags for configuring the Embree scene.") + .value("Empty", raycasting::SceneFlags::None, "No special behavior.") + .value("Dynamic", raycasting::SceneFlags::Dynamic, "Scene will be updated frequently.") + .value("Compact", raycasting::SceneFlags::Compact, "Compact BVH layout.") + .value("Robust", raycasting::SceneFlags::Robust, "Robust BVH traversal.") + .value("Filter", raycasting::SceneFlags::Filter, "Enable user-defined filters."); + + nb::enum_(m, "BuildQuality", "BVH construction quality level.") + .value("Low", raycasting::BuildQuality::Low, "Fastest build time, lowest BVH quality.") + .value("Medium", raycasting::BuildQuality::Medium, "Moderate build time and BVH quality.") + .value("High", raycasting::BuildQuality::High, "Slowest build time, highest BVH quality."); + + nb::enum_(m, "CastMode", "Ray-casting direction mode.") + .value("OneWay", raycasting::CastMode::OneWay, "Cast forward only.") + .value("BothWays", raycasting::CastMode::BothWays, "Cast forward and backward."); + + nb::enum_( + m, + "FallbackMode", + "Fallback mode for vertices without a ray hit.") + .value("Constant", raycasting::FallbackMode::Constant, "Fill with a constant value.") + .value( + "ClosestVertex", + raycasting::FallbackMode::ClosestVertex, + "Copy from the closest vertex.") + .value( + "ClosestPoint", + raycasting::FallbackMode::ClosestPoint, + "Interpolate from the closest surface point."); + + nb::enum_(m, "ProjectMode", "Main projection mode.") + .value( + "ClosestVertex", + raycasting::ProjectMode::ClosestVertex, + "Copy from the closest vertex.") + .value( + "ClosestPoint", + raycasting::ProjectMode::ClosestPoint, + "Interpolate from the closest surface point.") + .value( + "RayCasting", + raycasting::ProjectMode::RayCasting, + "Project along a prescribed direction."); + + // ========================================================================= + // RayCaster class (construction and scene population only) + // ========================================================================= + + nb::class_( + m, + "RayCaster", + R"(A ray caster built on top of Embree. + +This class manages an Embree BVH scene for efficient spatial queries. In the +Python API it is exposed purely as a *caching* object: build the scene once, +then pass it to the various ``project_*`` functions to avoid rebuilding the BVH +on every call. + +Example:: + + caster = lagrange.raycasting.RayCaster() + caster.add_mesh(source) + caster.commit_updates() + + lagrange.raycasting.project_closest_point( + source, target, attribute_ids=[attr_id], project_vertices=False, + ray_caster=caster) + lagrange.raycasting.project_closest_vertex( + source, target, attribute_ids=[attr_id], project_vertices=False, + ray_caster=caster) +)") + .def( + "__init__", + [](raycasting::RayCaster* self, + int scene_flags, + raycasting::BuildQuality build_quality) { + new (self) raycasting::RayCaster( + BitField( + static_cast>(scene_flags)), + build_quality); + }, + "scene_flags"_a = static_cast(raycasting::SceneFlags::Robust), + "build_quality"_a = raycasting::BuildQuality::Medium, + R"(Construct a RayCaster. + +:param scene_flags: Embree scene flags (default: ``SceneFlags.Robust``). +:param build_quality: BVH build quality (default: ``BuildQuality.Medium``).)") + + .def( + "add_mesh", + [](raycasting::RayCaster& self, + MeshType mesh, + std::optional transform_matrix) -> uint32_t { + using Affine = Eigen::Transform; + if (transform_matrix) { + Affine t; + t.matrix() = transform_matrix->template cast(); + return self.add_mesh(std::move(mesh), std::optional(t)); + } else { + return self.add_mesh(std::move(mesh), std::optional(std::nullopt)); + } + }, + "mesh"_a, + "transform"_a.none() = Eigen::Matrix4f(Eigen::Matrix4f::Identity()), + R"(Add a triangle mesh to the scene. + +The mesh is moved into the ray caster. Call :meth:`commit_updates` after +adding all meshes. + +By default a single instance with an identity transform is created. Pass +``None`` to add the mesh without any instance (use :meth:`add_instance` to +create instances later). + +:param mesh: Triangle mesh. +:param transform: 4×4 affine transformation matrix (float32), or ``None`` + to add the mesh without creating an instance. +:return: Index of the source mesh in the scene.)") + + .def( + "add_scene", + [](raycasting::RayCaster& self, scene::SimpleScene simple_scene) { + self.add_scene(std::move(simple_scene)); + }, + "simple_scene"_a, + R"(Add all meshes and instances from a SimpleScene. + +:param simple_scene: Scene containing meshes and their instances.)") + + .def( + "commit_updates", + &raycasting::RayCaster::commit_updates, + R"(Rebuild the BVH after adding or modifying meshes. + +Must be called before any query or project function.)") + + .def( + "add_instance", + [](raycasting::RayCaster& self, + uint32_t mesh_index, + const Eigen::Matrix4f& transform_matrix) -> uint32_t { + Eigen::Affine3f t; + t.matrix() = transform_matrix; + return self.add_instance(mesh_index, t); + }, + "mesh_index"_a, + "transform"_a, + R"(Add an instance of an existing mesh with a given transform. + +:param mesh_index: Index of the source mesh (returned by :meth:`add_mesh`). +:param transform: 4×4 affine transformation matrix (float32). +:return: Local instance index relative to the source mesh.)") + + .def( + "update_mesh", + [](raycasting::RayCaster& self, uint32_t mesh_index, const MeshType& mesh) { + self.update_mesh(mesh_index, mesh); + }, + "mesh_index"_a, + "mesh"_a, + R"(Replace a mesh in the scene. + +All instances of the old mesh will reference the new mesh. + +:param mesh_index: Index of the mesh to replace. +:param mesh: New triangle mesh.)") + .def( + "update_vertices", + [](raycasting::RayCaster& self, uint32_t mesh_index, const MeshType& mesh) { + self.update_vertices(mesh_index, mesh); + }, + "mesh_index"_a, + "mesh"_a, + R"(Notify that vertices of a mesh have been modified. + +The number and order of vertices must not change. + +:param mesh_index: Index of the mesh whose vertices changed. +:param mesh: The modified mesh with updated vertex positions.)") + .def( + "get_transform", + [](const raycasting::RayCaster& self, + uint32_t mesh_index, + uint32_t instance_index) -> Eigen::Matrix4f { + return self.get_transform(mesh_index, instance_index).matrix(); + }, + "mesh_index"_a, + "instance_index"_a, + R"(Get the affine transform of a mesh instance. + +:param mesh_index: Index of the source mesh. +:param instance_index: Local instance index. +:return: 4×4 affine transformation matrix (float32).)") + + .def( + "update_transform", + [](raycasting::RayCaster& self, + uint32_t mesh_index, + uint32_t instance_index, + const Eigen::Matrix4f& transform_matrix) { + Eigen::Affine3f t; + t.matrix() = transform_matrix; + self.update_transform(mesh_index, instance_index, t); + }, + "mesh_index"_a, + "instance_index"_a, + "transform"_a, + R"(Update the affine transform of a mesh instance. + +:param mesh_index: Index of the source mesh. +:param instance_index: Local instance index. +:param transform: New 4×4 affine transformation matrix (float32).)") + + .def( + "get_visibility", + &raycasting::RayCaster::get_visibility, + "mesh_index"_a, + "instance_index"_a, + R"(Get the visibility flag of a mesh instance. + +:param mesh_index: Index of the source mesh. +:param instance_index: Local instance index. +:return: True if the instance is visible.)") + + .def( + "update_visibility", + &raycasting::RayCaster::update_visibility, + "mesh_index"_a, + "instance_index"_a, + "visible"_a, + R"(Update the visibility of a mesh instance. + +:param mesh_index: Index of the source mesh. +:param instance_index: Local instance index. +:param visible: True to make visible, False to hide.)"); + + // ========================================================================= + // project_attributes (combined convenience function) + // ========================================================================= + + m.def( + "project", + [](const MeshType& source, + MeshType& target, + std::vector attribute_ids, + bool project_vertices, + raycasting::ProjectMode project_mode, + nb::object direction, + raycasting::CastMode cast_mode, + raycasting::FallbackMode fallback_mode, + double default_value, + const raycasting::RayCaster* ray_caster) { + raycasting::ProjectOptions opts; + opts.attribute_ids = std::move(attribute_ids); + opts.project_vertices = project_vertices; + opts.project_mode = project_mode; + if (direction.is_none()) { + opts.direction = std::monostate{}; + } else if (nb::isinstance(direction)) { + opts.direction = nb::cast(direction); + } else { + opts.direction = nb::cast(direction); + } + opts.cast_mode = cast_mode; + opts.fallback_mode = fallback_mode; + opts.default_value = default_value; + raycasting::project(source, target, opts, ray_caster); + }, + "source"_a, + "target"_a, + "attribute_ids"_a = std::vector{}, + "project_vertices"_a = true, + "project_mode"_a = raycasting::ProjectMode::ClosestPoint, + "direction"_a = nb::none(), + "cast_mode"_a = raycasting::CastMode::BothWays, + "fallback_mode"_a = raycasting::FallbackMode::Constant, + "default_value"_a = 0.0, + "ray_caster"_a = nullptr, + R"(Project vertex attributes from one mesh to another. + +:param source: Source triangle mesh. +:param target: Target mesh (modified in place). +:param attribute_ids: List of additional source vertex attribute ids to transfer. +:param project_vertices: If True (default), vertex positions are automatically projected. +:param project_mode: Projection mode (default: ``ProjectMode.ClosestPoint``). +:param direction: Ray direction. Can be None (default, uses vertex normals), a 3D vector, + or an AttributeId for a per-vertex direction attribute on the target mesh. + Only used with ``ProjectMode.RayCasting``. +:param cast_mode: Forward-only or both directions (only used with ``ProjectMode.RayCasting``). +:param fallback_mode: Fallback for missed vertices (only used with ``ProjectMode.RayCasting``). +:param default_value: Fill value for ``FallbackMode.Constant`` (default: 0). +:param ray_caster: Optional pre-built :class:`RayCaster` for caching.)"); + + // ========================================================================= + // project_attributes_directional + // ========================================================================= + + m.def( + "project_directional", + [](const MeshType& source, + MeshType& target, + std::vector attribute_ids, + bool project_vertices, + nb::object direction, + raycasting::CastMode cast_mode, + raycasting::FallbackMode fallback_mode, + double default_value, + const raycasting::RayCaster* ray_caster) { + raycasting::ProjectDirectionalOptions opts; + opts.attribute_ids = std::move(attribute_ids); + opts.project_vertices = project_vertices; + if (direction.is_none()) { + opts.direction = std::monostate{}; + } else if (nb::isinstance(direction)) { + opts.direction = nb::cast(direction); + } else { + opts.direction = nb::cast(direction); + } + opts.cast_mode = cast_mode; + opts.fallback_mode = fallback_mode; + opts.default_value = default_value; + raycasting::project_directional(source, target, opts, ray_caster); + }, + "source"_a, + "target"_a, + "attribute_ids"_a = std::vector{}, + "project_vertices"_a = true, + "direction"_a = nb::none(), + "cast_mode"_a = raycasting::CastMode::BothWays, + "fallback_mode"_a = raycasting::FallbackMode::Constant, + "default_value"_a = 0.0, + "ray_caster"_a = nullptr, + R"(Project vertex attributes along a prescribed direction. + +For each target vertex, a ray is cast in the given direction. If it hits the +source mesh, attribute values are interpolated from the hit triangle. + +:param source: Source triangle mesh. +:param target: Target mesh (modified in place). +:param attribute_ids: List of additional source vertex attribute ids to transfer. +:param project_vertices: If True (default), vertex positions are automatically projected. +:param direction: Ray direction. Can be None (default, uses vertex normals), a 3D vector, + or an AttributeId for a per-vertex direction attribute on the target mesh. +:param cast_mode: Forward-only or both directions (default: ``CastMode.BothWays``). +:param fallback_mode: Fallback for missed vertices (default: ``FallbackMode.Constant``). +:param default_value: Fill value for ``FallbackMode.Constant`` (default: 0). +:param ray_caster: Optional pre-built :class:`RayCaster` for caching.)"); + + // ========================================================================= + // project_attributes_closest_point + // ========================================================================= + + m.def( + "project_closest_point", + [](const MeshType& source, + MeshType& target, + std::vector attribute_ids, + bool project_vertices, + const raycasting::RayCaster* ray_caster) { + raycasting::ProjectCommonOptions opts; + opts.attribute_ids = std::move(attribute_ids); + opts.project_vertices = project_vertices; + raycasting::project_closest_point(source, target, opts, ray_caster); + }, + "source"_a, + "target"_a, + "attribute_ids"_a = std::vector{}, + "project_vertices"_a = true, + "ray_caster"_a = nullptr, + R"(Project vertex attributes by closest-point interpolation. + +For each target vertex, the closest point on the source mesh surface is found +and attribute values are linearly interpolated from the face corners. + +:param source: Source triangle mesh. +:param target: Target mesh (modified in place). +:param attribute_ids: List of additional source vertex attribute ids to transfer. +:param project_vertices: If True (default), vertex positions are automatically projected. +:param ray_caster: Optional pre-built :class:`RayCaster` for caching.)"); + + // ========================================================================= + // project_attributes_closest_vertex + // ========================================================================= + + m.def( + "project_closest_vertex", + [](const MeshType& source, + MeshType& target, + std::vector attribute_ids, + bool project_vertices, + const raycasting::RayCaster* ray_caster) { + raycasting::ProjectCommonOptions opts; + opts.attribute_ids = std::move(attribute_ids); + opts.project_vertices = project_vertices; + raycasting::project_closest_vertex(source, target, opts, ray_caster); + }, + "source"_a, + "target"_a, + "attribute_ids"_a = std::vector{}, + "project_vertices"_a = true, + "ray_caster"_a = nullptr, + R"(Project vertex attributes by closest-vertex snapping. + +For each target vertex, the closest surface point is found and snapped to the +nearest vertex of the hit triangle. Attribute values are copied directly from +that source vertex (no interpolation). + +:param source: Source triangle mesh. +:param target: Target mesh (modified in place). +:param attribute_ids: List of additional source vertex attribute ids to transfer. +:param project_vertices: If True (default), vertex positions are automatically projected. +:param ray_caster: Optional pre-built :class:`RayCaster` for caching.)"); + + // ========================================================================= + // NumPy-array overloads + // + // Instead of a SurfaceMesh target, accept an (N,3) NumPy array of query points (and optionally + // directions). A temporary SurfaceMesh is created by wrapping the input buffer via + // wrap_as_vertices (zero-copy). The input buffer is modified in place and returned by value. + // ========================================================================= + + // ----- project_closest_point (NumPy overload) ---------------------------- + + m.def( + "project_closest_point", + [](const MeshType& source, + NDArray3D points, + const raycasting::RayCaster* ray_caster) -> NDArray3D { + auto target = make_point_mesh(points); + raycasting::ProjectCommonOptions opts; + opts.project_vertices = true; + raycasting::project_closest_point(source, target, opts, ray_caster); + return points; + }, + "source"_a, + "points"_a, + "ray_caster"_a = nullptr, + R"(Project query points onto a source mesh by closest-point interpolation. + +For each query point, the closest point on the source mesh surface is found. + +:param source: Source triangle mesh. +:param points: (N, 3) NumPy array of query point positions (float64). Modified in place to store the projected positions. +:param ray_caster: Optional pre-built :class:`RayCaster` for caching. +:return: (N, 3) NumPy array of projected positions (float64).)"); + + // ----- project_closest_vertex (NumPy overload) --------------------------- + + m.def( + "project_closest_vertex", + [](const MeshType& source, + NDArray3D points, + const raycasting::RayCaster* ray_caster) -> NDArray3D { + auto target = make_point_mesh(points); + raycasting::ProjectCommonOptions opts; + opts.project_vertices = true; + raycasting::project_closest_vertex(source, target, opts, ray_caster); + return points; + }, + "source"_a, + "points"_a, + "ray_caster"_a = nullptr, + R"(Project query points onto a source mesh by closest-vertex snapping. + +For each query point, the closest surface point is found and snapped to the +nearest source vertex. + +:param source: Source triangle mesh. +:param points: (N, 3) NumPy array of query point positions (float64). Modified in place to store the projected positions. +:param ray_caster: Optional pre-built :class:`RayCaster` for caching. +:return: (N, 3) NumPy array of projected positions (float64).)"); + + // ----- project_directional (NumPy overload) ------------------------------ + + m.def( + "project_directional", + [](const MeshType& source, + NDArray3D points, + std::variant direction, + raycasting::CastMode cast_mode, + raycasting::FallbackMode fallback_mode, + double default_value, + const raycasting::RayCaster* ray_caster) -> NDArray3D { + auto target = make_point_mesh(points); + raycasting::ProjectDirectionalOptions opts; + opts.cast_mode = cast_mode; + opts.fallback_mode = fallback_mode; + opts.default_value = default_value; + opts.direction = resolve_direction(direction, target); + raycasting::project_directional(source, target, opts, ray_caster); + return points; + }, + "source"_a, + "points"_a, + "direction"_a, + "cast_mode"_a = raycasting::ProjectDirectionalOptions().cast_mode, + "fallback_mode"_a = raycasting::ProjectDirectionalOptions().fallback_mode, + "default_value"_a = raycasting::ProjectDirectionalOptions().default_value, + "ray_caster"_a = nullptr, + R"(Project query points onto a source mesh along a prescribed direction. + +For each query point, a ray is cast in the given direction. If it hits the +source mesh, vertex positions are set from the hit. + +:param source: Source triangle mesh. +:param points: (N, 3) NumPy array of query point positions (float64). Modified in place to store the projected positions. +:param direction: Ray direction. Can be a 3D vector, or a numpy array of shape (N, 3) for per-vertex directions, +:param cast_mode: Forward-only or both directions (default: ``CastMode.BothWays``). +:param fallback_mode: Fallback for missed vertices (default: ``FallbackMode.Constant``). +:param default_value: Fill value for ``FallbackMode.Constant`` (default: 0). +:param ray_caster: Optional pre-built :class:`RayCaster` for caching. +:return: (N, 3) NumPy array of projected positions (float64).)"); + + // ----- project (NumPy overload) ------------------------------------------ + + m.def( + "project", + [](const MeshType& source, + NDArray3D points, + raycasting::ProjectMode project_mode, + std::optional> direction, + raycasting::CastMode cast_mode, + raycasting::FallbackMode fallback_mode, + double default_value, + const raycasting::RayCaster* ray_caster) -> NDArray3D { + auto target = make_point_mesh(points); + raycasting::ProjectOptions opts; + opts.project_vertices = true; + opts.project_mode = project_mode; + opts.cast_mode = cast_mode; + opts.fallback_mode = fallback_mode; + opts.default_value = default_value; + if (direction.has_value()) { + opts.direction = resolve_direction(direction.value(), target); + } + raycasting::project(source, target, opts, ray_caster); + return points; + }, + "source"_a, + "points"_a, + "project_mode"_a, + "direction"_a = nb::none(), + "cast_mode"_a = raycasting::ProjectOptions().cast_mode, + "fallback_mode"_a = raycasting::ProjectOptions().fallback_mode, + "default_value"_a = raycasting::ProjectOptions().default_value, + "ray_caster"_a = nullptr, + R"(Project query points onto a source mesh. + +:param source: Source triangle mesh. +:param points: (N, 3) NumPy array of query point positions (float64). Modified in place to store the projected positions. +:param project_mode: Projection mode (default: ``ProjectMode.ClosestPoint``). +:param direction: Ray direction. Can be a 3D vector, or a numpy array of shape (N, 3) for per-vertex directions. + Only used with ``ProjectMode.RayCasting``. +:param cast_mode: Forward-only or both directions (only used with ``ProjectMode.RayCasting``). +:param fallback_mode: Fallback for missed vertices (only used with ``ProjectMode.RayCasting``). +:param default_value: Fill value for ``FallbackMode.Constant`` (default: 0). +:param ray_caster: Optional pre-built :class:`RayCaster` for caching. + +:return: (N, 3) NumPy array of projected positions (float64).)"); +} + +} // namespace lagrange::python diff --git a/modules/raycasting/python/tests/test_raycasting.py b/modules/raycasting/python/tests/test_raycasting.py new file mode 100644 index 00000000..6fac3952 --- /dev/null +++ b/modules/raycasting/python/tests/test_raycasting.py @@ -0,0 +1,493 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +import copy + +import lagrange +import numpy as np +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def unit_cube(): + """Axis-aligned unit cube [0,1]^3 with 12 triangles.""" + vertices = np.array( + [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + [0, 0, 1], + [1, 0, 1], + [1, 1, 1], + [0, 1, 1], + ], + dtype=float, + ) + facets = np.array( + [ + [0, 3, 2], + [2, 1, 0], + [4, 5, 6], + [6, 7, 4], + [1, 2, 6], + [6, 5, 1], + [4, 7, 3], + [3, 0, 4], + [2, 3, 7], + [7, 6, 2], + [0, 1, 5], + [5, 4, 0], + ], + dtype=np.uint32, + ) + mesh = lagrange.SurfaceMesh() + mesh.vertices = vertices + mesh.facets = facets + return mesh + + +@pytest.fixture +def shifted_cube(): + """Unit cube shifted by (0.1, 0.1, 0.1) so no vertex coincides with unit_cube.""" + vertices = np.array( + [ + [0.1, 0.1, 0.1], + [1.1, 0.1, 0.1], + [1.1, 1.1, 0.1], + [0.1, 1.1, 0.1], + [0.1, 0.1, 1.1], + [1.1, 0.1, 1.1], + [1.1, 1.1, 1.1], + [0.1, 1.1, 1.1], + ], + dtype=float, + ) + facets = np.array( + [ + [0, 3, 2], + [2, 1, 0], + [4, 5, 6], + [6, 7, 4], + [1, 2, 6], + [6, 5, 1], + [4, 7, 3], + [3, 0, 4], + [2, 3, 7], + [7, 6, 2], + [0, 1, 5], + [5, 4, 0], + ], + dtype=np.uint32, + ) + mesh = lagrange.SurfaceMesh() + mesh.vertices = vertices + mesh.facets = facets + return mesh + + +def _add_vertex_color(mesh, name="color"): + """Add a per-vertex RGB color attribute whose value equals the vertex position.""" + attr_id = mesh.create_attribute( + name, + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Color, + initial_values=mesh.vertices.astype(np.float64), + num_channels=3, + ) + return attr_id + + +# --------------------------------------------------------------------------- +# Enum smoke tests +# --------------------------------------------------------------------------- + + +class TestEnums: + def test_scene_flags_defaults(self): + empty_flag = lagrange.raycasting.SceneFlags.Empty + assert int(empty_flag) == 0 + # Arithmetic flag operations (nb::is_arithmetic) + combined = int(lagrange.raycasting.SceneFlags.Robust) | int( + lagrange.raycasting.SceneFlags.Filter + ) + assert combined != 0 + + def test_build_quality(self): + assert lagrange.raycasting.BuildQuality.Low is not None + assert lagrange.raycasting.BuildQuality.Medium is not None + assert lagrange.raycasting.BuildQuality.High is not None + + def test_cast_mode(self): + assert lagrange.raycasting.CastMode.OneWay is not None + assert lagrange.raycasting.CastMode.BothWays is not None + + def test_fallback_mode(self): + assert lagrange.raycasting.FallbackMode.Constant is not None + assert lagrange.raycasting.FallbackMode.ClosestVertex is not None + assert lagrange.raycasting.FallbackMode.ClosestPoint is not None + + def test_project_mode(self): + assert lagrange.raycasting.ProjectMode.ClosestVertex is not None + assert lagrange.raycasting.ProjectMode.ClosestPoint is not None + assert lagrange.raycasting.ProjectMode.RayCasting is not None + + +# --------------------------------------------------------------------------- +# RayCaster construction +# --------------------------------------------------------------------------- + + +class TestRayCaster: + def test_default_construction(self): + rc = lagrange.raycasting.RayCaster() + assert rc is not None + + def test_construction_with_args(self): + rc = lagrange.raycasting.RayCaster( + scene_flags=int(lagrange.raycasting.SceneFlags.Robust), + build_quality=lagrange.raycasting.BuildQuality.High, + ) + assert rc is not None + + def test_add_mesh_and_commit(self, unit_cube): + rc = lagrange.raycasting.RayCaster() + idx = rc.add_mesh(unit_cube) + assert idx == 0 + rc.commit_updates() + + def test_add_mesh_without_instance(self, unit_cube): + """add_mesh with transform=None adds the mesh without creating an instance.""" + rc = lagrange.raycasting.RayCaster() + mesh_idx = rc.add_mesh(unit_cube, transform=None) + assert mesh_idx == 0 + # Manually add an instance — should be instance 0 since none was created above + inst_idx = rc.add_instance(mesh_idx, np.eye(4, dtype=np.float32)) + assert inst_idx == 0 + rc.commit_updates() + + def test_add_instance(self, unit_cube): + rc = lagrange.raycasting.RayCaster() + mesh_idx = rc.add_mesh(unit_cube) + transform = np.eye(4, dtype=np.float32) + transform[0, 3] = 2.0 # translate x by 2 + inst_idx = rc.add_instance(mesh_idx, transform) + assert inst_idx == 1 # instance 0 is the original from add_mesh + rc.commit_updates() + + def test_add_scene(self, unit_cube): + """add_scene should accept a SimpleScene3D.""" + scene = lagrange.scene.SimpleScene3D() + scene.add_mesh(unit_cube) + inst = lagrange.scene.MeshInstance3D() + inst.mesh_index = 0 + inst.transform = np.eye(4, dtype=np.float32) + scene.add_instance(inst) + rc = lagrange.raycasting.RayCaster() + rc.add_scene(scene) + rc.commit_updates() + + def test_get_set_transform(self, unit_cube): + rc = lagrange.raycasting.RayCaster() + mesh_idx = rc.add_mesh(unit_cube) + rc.commit_updates() + + t = rc.get_transform(mesh_idx, 0) + assert t.shape == (4, 4) + np.testing.assert_allclose(t, np.eye(4, dtype=np.float32), atol=1e-6) + + new_t = np.eye(4, dtype=np.float32) + new_t[1, 3] = 5.0 + rc.update_transform(mesh_idx, 0, new_t) + rc.commit_updates() + got = rc.get_transform(mesh_idx, 0) + np.testing.assert_allclose(got, new_t, atol=1e-6) + + def test_get_set_visibility(self, unit_cube): + rc = lagrange.raycasting.RayCaster() + mesh_idx = rc.add_mesh(unit_cube) + rc.commit_updates() + + assert rc.get_visibility(mesh_idx, 0) is True + rc.update_visibility(mesh_idx, 0, False) + rc.commit_updates() + assert rc.get_visibility(mesh_idx, 0) is False + + def test_update_mesh(self, unit_cube, shifted_cube): + rc = lagrange.raycasting.RayCaster() + mesh_idx = rc.add_mesh(unit_cube) + rc.commit_updates() + + rc.update_mesh(mesh_idx, shifted_cube) + rc.commit_updates() + + def test_update_vertices(self, unit_cube): + rc = lagrange.raycasting.RayCaster() + mesh_idx = rc.add_mesh(unit_cube) + rc.commit_updates() + + rc.update_vertices(mesh_idx, unit_cube) + rc.commit_updates() + + +# --------------------------------------------------------------------------- +# project_closest_point +# --------------------------------------------------------------------------- + + +class TestProjectClosestPoint: + def test_self_transfer(self, unit_cube): + """Projecting a mesh onto itself should reproduce the attribute exactly.""" + attr_id = _add_vertex_color(unit_cube) + target = copy.copy(unit_cube) + lagrange.raycasting.project_closest_point( + unit_cube, target, attribute_ids=[attr_id], project_vertices=False + ) + assert target.has_attribute("color") + src_data = unit_cube.attribute(attr_id).data + dst_data = target.attribute("color").data + np.testing.assert_allclose(dst_data, src_data, atol=1e-5) + + def test_with_cached_raycaster(self, unit_cube): + """Using a pre-built RayCaster should give the same result.""" + attr_id = _add_vertex_color(unit_cube) + rc = lagrange.raycasting.RayCaster() + rc.add_mesh(unit_cube) + rc.commit_updates() + + target = copy.copy(unit_cube) + lagrange.raycasting.project_closest_point( + unit_cube, target, attribute_ids=[attr_id], project_vertices=False, ray_caster=rc + ) + src_data = unit_cube.attribute(attr_id).data + dst_data = target.attribute("color").data + np.testing.assert_allclose(dst_data, src_data, atol=1e-5) + + +# --------------------------------------------------------------------------- +# project_closest_vertex +# --------------------------------------------------------------------------- + + +class TestProjectClosestVertex: + def test_self_transfer(self, unit_cube): + """Closest vertex on self → exact copy.""" + attr_id = _add_vertex_color(unit_cube) + target = copy.copy(unit_cube) + lagrange.raycasting.project_closest_vertex( + unit_cube, target, attribute_ids=[attr_id], project_vertices=False + ) + src_data = unit_cube.attribute(attr_id).data + dst_data = target.attribute("color").data + np.testing.assert_allclose(dst_data, src_data, atol=1e-5) + + +# --------------------------------------------------------------------------- +# project_directional +# --------------------------------------------------------------------------- + + +class TestProjectDirectional: + def test_along_z(self, unit_cube): + """Project along z onto the unit cube; every cube vertex should get a hit.""" + attr_id = _add_vertex_color(unit_cube) + target = copy.copy(unit_cube) + lagrange.raycasting.project_directional( + unit_cube, + target, + attribute_ids=[attr_id], + project_vertices=False, + direction=np.array([0, 0, 1], dtype=np.float32), + ) + dst_data = target.attribute("color").data + # All vertices lie on the cube, so every ray should hit and produce valid colors. + assert dst_data.shape == (unit_cube.num_vertices, 3) + assert not np.any(np.isnan(dst_data)) + + +# --------------------------------------------------------------------------- +# project (combined convenience function) +# --------------------------------------------------------------------------- + + +class TestProject: + def test_closest_point_mode(self, unit_cube): + attr_id = _add_vertex_color(unit_cube) + target = copy.copy(unit_cube) + lagrange.raycasting.project( + unit_cube, + target, + attribute_ids=[attr_id], + project_vertices=False, + project_mode=lagrange.raycasting.ProjectMode.ClosestPoint, + ) + src_data = unit_cube.attribute(attr_id).data + dst_data = target.attribute("color").data + np.testing.assert_allclose(dst_data, src_data, atol=1e-5) + + def test_closest_vertex_mode(self, unit_cube): + attr_id = _add_vertex_color(unit_cube) + target = copy.copy(unit_cube) + lagrange.raycasting.project( + unit_cube, + target, + attribute_ids=[attr_id], + project_vertices=False, + project_mode=lagrange.raycasting.ProjectMode.ClosestVertex, + ) + src_data = unit_cube.attribute(attr_id).data + dst_data = target.attribute("color").data + np.testing.assert_allclose(dst_data, src_data, atol=1e-5) + + def test_raycasting_mode(self, unit_cube): + attr_id = _add_vertex_color(unit_cube) + target = copy.copy(unit_cube) + lagrange.raycasting.project( + unit_cube, + target, + attribute_ids=[attr_id], + project_vertices=False, + project_mode=lagrange.raycasting.ProjectMode.RayCasting, + direction=np.array([0, 0, 1], dtype=np.float32), + ) + dst_data = target.attribute("color").data + assert dst_data.shape == (unit_cube.num_vertices, 3) + + +# --------------------------------------------------------------------------- +# NumPy-array overloads +# --------------------------------------------------------------------------- + + +class TestProjectClosestPointNumpy: + def test_project_points_onto_cube(self, unit_cube): + """Query points outside the cube are projected to the closest surface point.""" + query = np.array( + [ + [0.5, 0.5, 2.0], # above top face → should land at z=1 + [0.5, 0.5, -1.0], # below bottom face → z=0 + [2.0, 0.5, 0.5], # right of cube → x=1 + ], + dtype=np.float64, + ) + result = lagrange.raycasting.project_closest_point(unit_cube, query) + assert isinstance(result, np.ndarray) + assert result.shape == (3, 3) + np.testing.assert_allclose(result[0], [0.5, 0.5, 1.0], atol=1e-5) + np.testing.assert_allclose(result[1], [0.5, 0.5, 0.0], atol=1e-5) + np.testing.assert_allclose(result[2], [1.0, 0.5, 0.5], atol=1e-5) + + def test_modifies_input_in_place(self, unit_cube): + """The input query array is modified in place with projected positions.""" + query = np.array([[0.5, 0.5, 2.0]], dtype=np.float64) + result = lagrange.raycasting.project_closest_point(unit_cube, query) + np.testing.assert_allclose(query[0], [0.5, 0.5, 1.0], atol=1e-5) + np.testing.assert_array_equal(result, query) + + def test_with_raycaster(self, unit_cube): + """Pre-built RayCaster should give the same result.""" + rc = lagrange.raycasting.RayCaster() + rc.add_mesh(unit_cube) + rc.commit_updates() + + query = np.array([[0.5, 0.5, 2.0]], dtype=np.float64) + result = lagrange.raycasting.project_closest_point(unit_cube, query, ray_caster=rc) + assert isinstance(result, np.ndarray) + np.testing.assert_allclose(result[0], [0.5, 0.5, 1.0], atol=1e-5) + + +class TestProjectClosestVertexNumpy: + def test_snap_to_vertex(self, unit_cube): + """Query points are snapped to the nearest cube vertex.""" + query = np.array( + [ + [0.1, 0.1, 0.1], # nearest vertex is (0,0,0) + [0.9, 0.9, 0.9], # nearest vertex is (1,1,1) + ], + dtype=np.float64, + ) + result = lagrange.raycasting.project_closest_vertex(unit_cube, query) + assert isinstance(result, np.ndarray) + assert result.shape == (2, 3) + np.testing.assert_allclose(result[0], [0.0, 0.0, 0.0], atol=1e-5) + np.testing.assert_allclose(result[1], [1.0, 1.0, 1.0], atol=1e-5) + + +class TestProjectDirectionalNumpy: + def test_along_z(self, unit_cube): + """Points above the cube, projecting along -z, should hit z=1.""" + query = np.array( + [ + [0.5, 0.5, 5.0], + [0.25, 0.75, 5.0], + ], + dtype=np.float64, + ) + result = lagrange.raycasting.project_directional( + unit_cube, + query, + direction=np.array([0, 0, -1], dtype=np.float32), + cast_mode=lagrange.raycasting.CastMode.OneWay, + ) + assert isinstance(result, np.ndarray) + assert result.shape == (2, 3) + np.testing.assert_allclose(result[:, 2], [1.0, 1.0], atol=1e-5) + + def test_both_ways_fallback(self, unit_cube): + """BothWays + ClosestPoint fallback should still produce valid output.""" + query = np.array([[0.5, 0.5, 0.5]], dtype=np.float64) + result = lagrange.raycasting.project_directional( + unit_cube, + query, + direction=np.array([0, 0, 1], dtype=np.float32), + cast_mode=lagrange.raycasting.CastMode.BothWays, + fallback_mode=lagrange.raycasting.FallbackMode.ClosestPoint, + ) + assert isinstance(result, np.ndarray) + assert result.shape == (1, 3) + + +class TestProjectNumpy: + def test_closest_point_mode(self, unit_cube): + query = np.array([[0.5, 0.5, 2.0]], dtype=np.float64) + result = lagrange.raycasting.project( + unit_cube, + query, + project_mode=lagrange.raycasting.ProjectMode.ClosestPoint, + ) + assert isinstance(result, np.ndarray) + np.testing.assert_allclose(result[0], [0.5, 0.5, 1.0], atol=1e-5) + + def test_closest_vertex_mode(self, unit_cube): + query = np.array([[0.1, 0.1, 0.1]], dtype=np.float64) + result = lagrange.raycasting.project( + unit_cube, + query, + project_mode=lagrange.raycasting.ProjectMode.ClosestVertex, + ) + assert isinstance(result, np.ndarray) + np.testing.assert_allclose(result[0], [0.0, 0.0, 0.0], atol=1e-5) + + def test_raycasting_mode(self, unit_cube): + query = np.array([[0.5, 0.5, 5.0]], dtype=np.float64) + result = lagrange.raycasting.project( + unit_cube, + query, + project_mode=lagrange.raycasting.ProjectMode.RayCasting, + direction=np.array([0, 0, -1], dtype=np.float32), + ) + assert isinstance(result, np.ndarray) + np.testing.assert_allclose(result[0, 2], 1.0, atol=1e-5) diff --git a/modules/raycasting/src/RayCaster.cpp b/modules/raycasting/src/RayCaster.cpp new file mode 100644 index 00000000..cda2d67d --- /dev/null +++ b/modules/raycasting/src/RayCaster.cpp @@ -0,0 +1,1557 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef LAGRANGE_WITH_EMBREE_3 + #include + #include + #include +#else + #include + #include + #include +#endif + +#if LAGRANGE_TARGET_PLATFORM(x86_64) + #include + #include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RTC_NAMESPACE_USE + +namespace lagrange::raycasting { + +// ============================================================================ +// Helpers +// ============================================================================ + +namespace { + +using SimpleScene32f = scene::SimpleScene32f3; +using MeshInstance = SimpleScene32f::InstanceType; + +template +using PointNf = Eigen::Matrix; +template +using DirectionNf = Eigen::Matrix; +template +using FloatN = Eigen::Vector; +template +using MaskN = Eigen::Vector; +template +using RTCRayHitN = + std::conditional_t>; +template +using RTCRayN = std::conditional_t>; +template +using RTCPointQueryN = std::conditional_t< + N <= 4, + RTCPointQuery4, + std::conditional_t>; + +void check_errors_runtime(const RTCDevice& device) +{ + auto err = rtcGetDeviceError(device); + switch (err) { + case RTC_ERROR_NONE: return; + case RTC_ERROR_UNKNOWN: throw Error("Embree: unknown error"); + case RTC_ERROR_INVALID_ARGUMENT: throw Error("Embree: invalid argument"); + case RTC_ERROR_INVALID_OPERATION: throw Error("Embree: invalid operation"); + case RTC_ERROR_OUT_OF_MEMORY: throw Error("Embree: out of memory"); + case RTC_ERROR_UNSUPPORTED_CPU: throw Error("Embree: your CPU does not support SSE2"); + case RTC_ERROR_CANCELLED: throw Error("Embree: cancelled"); + default: + throw Error( + fmt::format( + "Embree: unknown error code: {}", + static_cast>(err))); + } +} + +void check_errors_debug([[maybe_unused]] const RTCDevice& device) +{ +#if LAGRANGE_TARGET_BUILD_TYPE(DEBUG) + check_errors_runtime(device); +#endif +} + +inline float to_embree_tfar(float tmax) +{ + return std::isinf(tmax) ? std::numeric_limits::max() : tmax; +} + +constexpr RTCSceneFlags to_embree_scene_flags(BitField flags) +{ + RTCSceneFlags embree_flags(RTC_SCENE_FLAG_NONE); + if (flags & SceneFlags::Dynamic) { + embree_flags = embree_flags | RTC_SCENE_FLAG_DYNAMIC; + } + if (flags & SceneFlags::Compact) { + embree_flags = embree_flags | RTC_SCENE_FLAG_COMPACT; + } + if (flags & SceneFlags::Robust) { + embree_flags = embree_flags | RTC_SCENE_FLAG_ROBUST; + } + if (flags & SceneFlags::Filter) { +#if LAGRANGE_WITH_EMBREE_3 + embree_flags = embree_flags | RTC_SCENE_FLAG_CONTEXT_FILTER_FUNCTION; +#else + embree_flags = embree_flags | RTC_SCENE_FLAG_FILTER_FUNCTION_IN_ARGUMENTS; +#endif + } + return embree_flags; +} + +constexpr bool is_embree_flag_set(RTCSceneFlags embree_flags, SceneFlags flag) +{ + switch (flag) { + case SceneFlags::Dynamic: return (embree_flags & RTC_SCENE_FLAG_DYNAMIC) != 0; + case SceneFlags::Compact: return (embree_flags & RTC_SCENE_FLAG_COMPACT) != 0; + case SceneFlags::Robust: return (embree_flags & RTC_SCENE_FLAG_ROBUST) != 0; + case SceneFlags::Filter: +#if LAGRANGE_WITH_EMBREE_3 + return (embree_flags & RTC_SCENE_FLAG_CONTEXT_FILTER_FUNCTION) != 0; +#else + return (embree_flags & RTC_SCENE_FLAG_FILTER_FUNCTION_IN_ARGUMENTS) != 0; +#endif + default: la_runtime_assert(false, "Invalid SceneFlags value."); return false; + } +} + +constexpr RTCBuildQuality to_embree_build_quality(BuildQuality quality) +{ + switch (quality) { + case BuildQuality::Low: return RTC_BUILD_QUALITY_LOW; + case BuildQuality::Medium: return RTC_BUILD_QUALITY_MEDIUM; + case BuildQuality::High: return RTC_BUILD_QUALITY_HIGH; + default: la_runtime_assert(false, "Invalid build quality."); return RTC_BUILD_QUALITY_MEDIUM; + } +} + +/// Convert a bool-typed Eigen mask to an int32_t array for Embree. +/// Embree expects 0 (inactive) / -1 (active, i.e. 0xFFFFFFFF) as int32_t. +template +void mask_to_embree(const Eigen::Matrix& mask, std::array& out) +{ + for (int i = 0; i < N; ++i) { + out[i] = mask[i] ? -1 : 0; + } +} + +/// Build an Embree int32_t mask from a count: the first `count` lanes are active. +template +void count_to_embree_mask(size_t count, std::array& out) +{ + for (int i = 0; i < N; ++i) { + out[i] = (static_cast(i) < count) ? -1 : 0; + } +} + +/// Resolve a std::variant into an Embree-ready int32_t mask. +template +void resolve_active_mask( + const std::variant, size_t>& active, + std::array& out) +{ + if (auto* m = std::get_if>(&active)) { + mask_to_embree(*m, out); + } else { + count_to_embree_mask(std::get(active), out); + } +} + +/// Mapping from Embree instance geometry ID to user-facing mesh/instance indices. +struct InstanceIndices +{ + uint32_t mesh_index; + uint32_t instance_index; +}; + +/// User data passed to the Embree point query callback for closest-point queries on an instanced +/// scene. Holds a pointer to the scene so that the callback can retrieve triangle vertices. +struct ClosestPointUserData +{ + const SimpleScene32f* scene = nullptr; + bool snap_to_vertex = false; + + // Current best result + float distance = std::numeric_limits::infinity(); + uint32_t mesh_index = invalid(); + uint32_t instance_index = invalid(); + uint32_t facet_index = invalid(); + Eigen::Vector3f closest_point = Eigen::Vector3f::Zero(); + Eigen::Vector2f barycentric_coord = Eigen::Vector2f::Zero(); + + // Mapping from Embree instance geometry ID to (mesh_index, instance_index). + const std::vector* instance_indices = nullptr; +}; + +// Embree point query callback for closest-point queries on an instanced scene. +// +// Adapted from: https://github.com/RenderKit/embree/tree/master/tutorials/closest_point +// SPDX-License-Identifier: Apache-2.0 +// +// This function has been modified by Adobe. +// +// All modifications are Copyright 2020 Adobe. +bool embree_closest_point_callback(RTCPointQueryFunctionArguments* args) +{ + using Map4f = Eigen::Map; + + la_debug_assert(args->userPtr); + auto* data = reinterpret_cast(args->userPtr); + + const unsigned int prim_id = args->primID; + + RTCPointQueryContext* context = args->context; + const unsigned int stack_size = args->context->instStackSize; + const unsigned int stack_ptr = stack_size - 1; + + // In an instanced scene, stack_size > 0 and instID gives us the instance geometry ID in the + // world scene, from which we can recover the mesh and instance indices. + la_debug_assert(stack_size > 0); + unsigned int inst_geom_id = context->instID[stack_ptr]; + + // TODO: Can we avoid this lookup? + auto& indices = (*data->instance_indices)[inst_geom_id]; + uint32_t mesh_idx = indices.mesh_index; + + Eigen::Affine3f inst2world(Map4f(context->inst2world[stack_ptr])); + + // Query position in world space + Eigen::Vector3f q(args->query->x, args->query->y, args->query->z); + + // Get triangle vertices from the stored mesh in local (instance) space + const auto& mesh = data->scene->get_mesh(mesh_idx); + la_debug_assert(mesh.is_triangle_mesh()); + la_debug_assert(prim_id < mesh.get_num_facets()); + + // TODO: Is there any overhead to calling get_corner_to_vertex().get_all() here? Should we cache + // this? + auto facets = mesh.get_corner_to_vertex().get_all(); + uint32_t i0 = facets[prim_id * 3 + 0]; + uint32_t i1 = facets[prim_id * 3 + 1]; + uint32_t i2 = facets[prim_id * 3 + 2]; + + auto positions = mesh.get_vertex_to_position().get_all(); + Eigen::Vector3f v0(positions[i0 * 3], positions[i0 * 3 + 1], positions[i0 * 3 + 2]); + Eigen::Vector3f v1(positions[i1 * 3], positions[i1 * 3 + 1], positions[i1 * 3 + 2]); + Eigen::Vector3f v2(positions[i2 * 3], positions[i2 * 3 + 1], positions[i2 * 3 + 2]); + + // Bring query and primitive data in the same space if necessary. + if (stack_size > 0 && args->similarityScale > 0) { + // Instance transform is a similarity transform, therefore we + // can compute distance information in instance space. Therefore, + // transform query position into local instance space. + Eigen::Affine3f world2inst(Map4f(context->world2inst[stack_ptr])); + q = world2inst * q; + } else if (stack_size > 0) { + // Instance transform is not a similarity transform. We have to transform the + // primitive data into world space and perform distance computations in + // world space to ensure correctness. + v0 = inst2world * v0; + v1 = inst2world * v1; + v2 = inst2world * v2; + } + + // Determine distance to closest point on triangle, and transform in + // world space if necessary. + Eigen::Vector3f p; + float l1, l2, l3, d; + if (data->snap_to_vertex) { + // If snapping to vertex, compute distances to vertices and pick the closest one. + float d0 = (q - v0).squaredNorm(); + float d1 = (q - v1).squaredNorm(); + float d2 = (q - v2).squaredNorm(); + if (d0 < d1 && d0 < d2) { + p = v0; + l1 = 1.0f; + l2 = 0.0f; + l3 = 0.0f; + d = std::sqrt(d0); + } else if (d1 < d2) { + p = v1; + l1 = 0.0f; + l2 = 1.0f; + l3 = 0.0f; + d = std::sqrt(d1); + } else { + p = v2; + l1 = 0.0f; + l2 = 0.0f; + l3 = 1.0f; + d = std::sqrt(d2); + } + } else { + float d2 = point_triangle_squared_distance(q, v0, v1, v2, p, l1, l2, l3); + d = std::sqrt(d2); + } + if (args->similarityScale > 0) { + d = d / args->similarityScale; + } + + // Store result and update the query radius if we found a closer point. + if (d < args->query->radius) { + args->query->radius = d; + data->distance = d; + data->closest_point = (args->similarityScale > 0 ? (inst2world * p).eval() : p); + data->mesh_index = mesh_idx; + data->instance_index = indices.instance_index; + data->facet_index = prim_id; + // Store (u, v) barycentric coordinates: l2 corresponds to u and l3 corresponds to v + data->barycentric_coord[0] = l2; + data->barycentric_coord[1] = l3; + return true; + } + + return false; +} + +// ============================================================================ +// Impl +// ============================================================================ + +struct RayCasterImpl +{ + RTCSceneFlags m_scene_flags = to_embree_scene_flags(SceneFlags::None); + RTCBuildQuality m_build_quality = to_embree_build_quality(BuildQuality::Medium); + + RTCDevice m_device; + RTCScene m_world_scene; + + // Whether the world scene has changed and needs to be committed. + bool m_need_commit = true; + + bool m_has_intersection_filter = false; + bool m_has_occlusion_filter = false; + + // Per-mesh data ---------------------------------------------------------- + struct MeshData + { + unsigned mesh_geometry_id; + RTCScene mesh_scene; + std::function intersection_filter; + std::function occlusion_filter; + }; + + // Extra data per source mesh + std::vector m_meshes; + + // Per-instance data ------------------------------------------------------ + struct InstanceData + { + unsigned instance_geometry_id; + bool visible = true; + }; + + // Extra data per instance + std::vector> m_instances; + + // Mapping from Embree instance geometry ID to mesh/instance indices + std::vector m_instance_indices; + + // Scene data (buffers shared with the BVH) + SimpleScene32f m_scene; + + // ----------------------------------------------------------------------- + RayCasterImpl() + { +#if LAGRANGE_TARGET_PLATFORM(x86_64) + // Embree strongly recommends having FTZ and DAZ enabled. + _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); + _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON); +#endif + + m_device = rtcNewDevice(nullptr); + check_errors_runtime(m_device); + m_world_scene = rtcNewScene(m_device); + check_errors_runtime(m_device); + rtcSetSceneFlags(m_world_scene, m_scene_flags); + check_errors_runtime(m_device); + rtcSetSceneBuildQuality(m_world_scene, m_build_quality); + check_errors_runtime(m_device); + } + + ~RayCasterImpl() + { + for (auto& m : m_meshes) { + rtcReleaseScene(m.mesh_scene); + } + rtcReleaseScene(m_world_scene); + rtcReleaseDevice(m_device); + } + + // TODO: Support other geometry types: + // - RTC_GEOMETRY_TYPE_QUAD + // - RTC_GEOMETRY_TYPE_USER for general hybrid/polygonal meshes? + // - Subdiv meshes + MeshData create_embree_mesh(SurfaceMesh32f& mesh, RTCScene mesh_scene = nullptr) + { + la_runtime_assert(mesh.is_triangle_mesh()); + MeshData data; + + // Create a new sub-scene for this mesh (needed for instancing). + if (mesh_scene) { + data.mesh_scene = mesh_scene; + } else { + data.mesh_scene = rtcNewScene(m_device); + rtcSetSceneFlags(data.mesh_scene, m_scene_flags); + rtcSetSceneBuildQuality(data.mesh_scene, m_build_quality); + check_errors_runtime(m_device); + } + + // Create a new geometry object. + RTCGeometry geom = rtcNewGeometry(m_device, RTC_GEOMETRY_TYPE_TRIANGLE); + rtcSetGeometryBuildQuality(geom, m_build_quality); + check_errors_runtime(m_device); + + // Set shared vertex buffer, padded to allow 16-byte SSE memory loads. + // https://github.com/embree/embree/issues/124 + // + // TODO: We should do this by default in the SurfaceMesh class, to avoid copying buffers + mesh.ref_vertex_to_position().reserve_entries(mesh.get_num_vertices() * 3 + 1); + rtcSetSharedGeometryBuffer( + geom, + RTC_BUFFER_TYPE_VERTEX, + 0, + RTC_FORMAT_FLOAT3, + mesh.get_vertex_to_position().get_all_with_padding().data(), + 0, + sizeof(float) * 3, + mesh.get_num_vertices()); + check_errors_runtime(m_device); + + // Set shared triangle buffer, padded to allow 16-byte SSE memory loads. + // https://github.com/embree/embree/issues/124 + // + // TODO: We should do this by default in the SurfaceMesh class, to avoid copying buffers + mesh.ref_corner_to_vertex(AttributeRefPolicy::Force) + .reserve_entries(mesh.get_num_facets() * 3 + 1); + rtcSetSharedGeometryBuffer( + geom, + RTC_BUFFER_TYPE_INDEX, + 0, + RTC_FORMAT_UINT3, + mesh.get_corner_to_vertex().get_all_with_padding().data(), + 0, + sizeof(uint32_t) * 3, + mesh.get_num_facets()); + check_errors_runtime(m_device); + + rtcCommitGeometry(geom); + check_errors_runtime(m_device); + data.mesh_geometry_id = rtcAttachGeometry(data.mesh_scene, geom); + check_errors_runtime(m_device); + rtcReleaseGeometry(geom); + check_errors_runtime(m_device); + + rtcCommitScene(data.mesh_scene); + check_errors_runtime(m_device); + + return data; + } + + uint32_t add_mesh(SurfaceMesh32f mesh) + { + uint32_t mesh_index = m_scene.add_mesh(std::move(mesh)); + la_debug_assert(mesh_index == m_meshes.size()); + m_meshes.push_back(create_embree_mesh(m_scene.ref_mesh(mesh_index))); + m_instances.emplace_back(); + m_need_commit = true; + return mesh_index; + } + + InstanceData create_embree_instance(const RTCScene& mesh_scene, const MeshInstance& instance) + { + InstanceData data; + + RTCGeometry geom_inst = rtcNewGeometry(m_device, RTC_GEOMETRY_TYPE_INSTANCE); + rtcSetGeometryInstancedScene(geom_inst, mesh_scene); + rtcSetGeometryTimeStepCount(geom_inst, 1); + + rtcSetGeometryTransform( + geom_inst, + 0, + RTC_FORMAT_FLOAT4X4_COLUMN_MAJOR, + instance.transform.matrix().data()); + + rtcCommitGeometry(geom_inst); + data.instance_geometry_id = rtcAttachGeometry(m_world_scene, geom_inst); + rtcReleaseGeometry(geom_inst); + check_errors_runtime(m_device); + + m_need_commit = true; + + return data; + } + + uint32_t add_instance(const MeshInstance& instance) + { + const auto& mesh_scene = m_meshes[instance.mesh_index].mesh_scene; + uint32_t instance_index = m_scene.add_instance(instance); + la_debug_assert(instance_index == m_instances[instance.mesh_index].size()); + m_instances[instance.mesh_index].emplace_back(create_embree_instance(mesh_scene, instance)); + auto& ist = m_instances[instance.mesh_index].back(); + m_instance_indices.resize(ist.instance_geometry_id + 1); + m_instance_indices[ist.instance_geometry_id] = {instance.mesh_index, instance_index}; + return instance_index; + } + + void add_scene(SimpleScene32f scene) + { + la_debug_assert(m_meshes.size() == m_instances.size()); + const uint32_t index_offset = static_cast(m_meshes.size()); + + m_meshes.reserve(m_meshes.size() + scene.get_num_meshes()); + m_instances.reserve(m_instances.size() + scene.get_num_meshes()); + + for (uint32_t local_idx = 0; local_idx < scene.get_num_meshes(); ++local_idx) { + add_mesh(std::move(scene.ref_mesh(local_idx))); + } + + scene.foreach_instances([&](const auto& local_instance) { + auto global_instance = local_instance; + global_instance.mesh_index += index_offset; + add_instance(global_instance); + }); + + m_need_commit = true; + } + + void commit_updates_if_needed() + { + if (m_need_commit) { + rtcCommitScene(m_world_scene); + check_errors_runtime(m_device); + m_need_commit = false; + } + } + + void check_no_pending_updates() const + { + if (m_need_commit) { + throw Error("Scene changes not committed. Call commit_updates() first."); + } + } + + // Decode Embree instance geometry ID into user-facing mesh/instance indices. + void decode_instance(unsigned rtc_inst_id, uint32_t& mesh_idx, uint32_t& inst_idx) const + { + mesh_idx = m_instance_indices[rtc_inst_id].mesh_index; + inst_idx = m_instance_indices[rtc_inst_id].instance_index; + } +}; + +// ============================================================================ +// Embree filter callbacks +// ============================================================================ + +/// Whether the filter is for intersection or occlusion queries. +enum class FilterKind { Intersection, Occlusion }; + +/// Context struct that extends RTCRayQueryContext (Embree 4) / RTCIntersectContext (Embree 3) with a +/// pointer back to the RayCasterImpl so that the filter callback can look up user filter functions. +struct FilterContext +{ +#ifdef LAGRANGE_WITH_EMBREE_3 + RTCIntersectContext embree_ctx; +#else + RTCRayQueryContext embree_ctx; +#endif + const RayCasterImpl* impl = nullptr; + FilterKind kind = FilterKind::Intersection; +}; + +/// Embree filter callback invoked for each potential hit. Looks up the user-defined filter function +/// on the mesh that was hit and rejects the hit (sets valid[i] = 0) if the filter returns false. +void embree_filter_callback(const RTCFilterFunctionNArguments* args) +{ + // Recover our extended context from the context pointer. +#ifdef LAGRANGE_WITH_EMBREE_3 + auto* fctx = reinterpret_cast(args->context); +#else + auto* fctx = reinterpret_cast(args->context); +#endif + + const RayCasterImpl* impl = fctx->impl; + if (!impl) return; + + const unsigned int N = args->N; + + for (unsigned int i = 0; i < N; ++i) { + // Skip lanes that are already invalid. + if (args->valid[i] == 0) continue; + + // Extract instance geometry ID from the hit. + unsigned int inst_id = RTCHitN_instID(args->hit, N, i, 0); + if (inst_id == RTC_INVALID_GEOMETRY_ID) continue; + + uint32_t mesh_index = 0; + uint32_t instance_index = 0; + impl->decode_instance(inst_id, mesh_index, instance_index); + + unsigned int facet_index = RTCHitN_primID(args->hit, N, i); + + // Look up the user filter for this mesh. + const auto& filter = (fctx->kind == FilterKind::Intersection) + ? impl->m_meshes[mesh_index].intersection_filter + : impl->m_meshes[mesh_index].occlusion_filter; + + if (filter && !filter(instance_index, facet_index)) { + // Reject this hit. + args->valid[i] = 0; + } + } +} + +/// Returns true if any mesh in the scene has an intersection filter set. +bool has_any_intersection_filter(const RayCasterImpl& impl) +{ + for (const auto& m : impl.m_meshes) { + if (m.intersection_filter) return true; + } + return false; +} + +/// Returns true if any mesh in the scene has an occlusion filter set. +bool has_any_occlusion_filter(const RayCasterImpl& impl) +{ + for (const auto& m : impl.m_meshes) { + if (m.occlusion_filter) return true; + } + return false; +} + +/// Initialize a FilterContext for intersection queries. +void init_intersection_filter_context(FilterContext& fctx, const RayCasterImpl& impl) +{ +#ifdef LAGRANGE_WITH_EMBREE_3 + rtcInitIntersectContext(&fctx.embree_ctx); + fctx.embree_ctx.filter = embree_filter_callback; +#else + rtcInitRayQueryContext(&fctx.embree_ctx); +#endif + fctx.impl = &impl; + fctx.kind = FilterKind::Intersection; +} + +/// Initialize a FilterContext for occlusion queries. +void init_occlusion_filter_context(FilterContext& fctx, const RayCasterImpl& impl) +{ +#ifdef LAGRANGE_WITH_EMBREE_3 + rtcInitIntersectContext(&fctx.embree_ctx); + fctx.embree_ctx.filter = embree_filter_callback; +#else + rtcInitRayQueryContext(&fctx.embree_ctx); +#endif + fctx.impl = &impl; + fctx.kind = FilterKind::Occlusion; +} + +} // namespace + +struct RayCaster::Impl : public RayCasterImpl +{ +}; + +// ============================================================================ +// Construction / destruction +// ============================================================================ + +RayCaster::RayCaster(BitField scene_flags, BuildQuality build_quality) + : m_impl(make_value_ptr()) +{ + m_impl->m_scene_flags = to_embree_scene_flags(scene_flags); + m_impl->m_build_quality = to_embree_build_quality(build_quality); +} + +RayCaster::~RayCaster() = default; + +RayCaster::RayCaster(RayCaster&& other) noexcept = default; + +RayCaster& RayCaster::operator=(RayCaster&& other) noexcept = default; + +// ============================================================================ +// Scene population +// ============================================================================ + +template +uint32_t RayCaster::add_mesh( + SurfaceMesh mesh, + const std::optional>& transform) +{ + uint32_t mesh_index = m_impl->add_mesh(SurfaceMesh32f::stripped_move(std::move(mesh))); + + if (transform.has_value()) { + MeshInstance instance; + instance.mesh_index = mesh_index; + instance.transform = transform.value().template cast(); + m_impl->add_instance(instance); + } + return mesh_index; +} + +uint32_t RayCaster::add_instance(uint32_t mesh_index, const Eigen::Affine3f& transform) +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_meshes.size(), + "mesh_index out of range."); + + MeshInstance instance; + instance.mesh_index = mesh_index; + instance.transform = transform; + return m_impl->add_instance(instance); +} + +template +void RayCaster::add_scene(scene::SimpleScene simple_scene) +{ + for (Index i = 0; i < simple_scene.get_num_meshes(); ++i) { + auto& mesh = simple_scene.ref_mesh(i); + mesh = SurfaceMesh::stripped_move(std::move(mesh)); + } + m_impl->add_scene(lagrange::scene::cast(std::move(simple_scene))); +} + +void RayCaster::commit_updates() +{ + m_impl->commit_updates_if_needed(); +} + +// ============================================================================ +// Scene modification +// ============================================================================ + +template +void RayCaster::update_mesh(uint32_t mesh_index, const SurfaceMesh& mesh) +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_meshes.size(), + "mesh_index out of range."); + + // Detach the previous mesh geometry from the mesh scene + rtcDetachGeometry( + m_impl->m_meshes[mesh_index].mesh_scene, + m_impl->m_meshes[mesh_index].mesh_geometry_id); + + // Replace the mesh in the scene (cast to float/uint32_t if needed). + m_impl->m_scene.ref_mesh(mesh_index) = lagrange::cast(mesh); + + // Recreate the Embree geometry for this mesh, preserving filters. + auto mesh_data = std::move(m_impl->m_meshes[mesh_index]); + m_impl->m_meshes[mesh_index] = + m_impl->create_embree_mesh(m_impl->m_scene.ref_mesh(mesh_index), mesh_data.mesh_scene); + m_impl->m_meshes[mesh_index].intersection_filter = std::move(mesh_data.intersection_filter); + m_impl->m_meshes[mesh_index].occlusion_filter = std::move(mesh_data.occlusion_filter); + + m_impl->m_need_commit = true; +} + +template +void RayCaster::update_vertices(uint32_t mesh_index, const SurfaceMesh& mesh) +{ + update_vertices(mesh_index, mesh.get_vertex_to_position().get_all()); +} + +template +void RayCaster::update_vertices(uint32_t mesh_index, span vertices) +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_meshes.size(), + "mesh_index out of range."); + + auto& stored_mesh = m_impl->m_scene.ref_mesh(mesh_index); + const uint32_t num_vertices = stored_mesh.get_num_vertices(); + la_runtime_assert( + vertices.size() == static_cast(num_vertices) * 3, + "Vertex buffer size mismatch (expected num_vertices * 3)."); + + // Update vertex positions in the stored mesh. + auto dst = stored_mesh.ref_vertex_to_position().ref_all(); + std::transform(vertices.begin(), vertices.end(), dst.begin(), [](auto&& x) { + return static_cast(x); + }); + + // Mark Embree geometry as modified. + RTCGeometry geom = rtcGetGeometry( + m_impl->m_meshes[mesh_index].mesh_scene, + m_impl->m_meshes[mesh_index].mesh_geometry_id); + rtcUpdateGeometryBuffer(geom, RTC_BUFFER_TYPE_VERTEX, 0); + rtcCommitGeometry(geom); + rtcCommitScene(m_impl->m_meshes[mesh_index].mesh_scene); + check_errors_runtime(m_impl->m_device); + + m_impl->m_need_commit = true; +} + +Eigen::Affine3f RayCaster::get_transform(uint32_t mesh_index, uint32_t instance_index) const +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_instances.size(), + "mesh_index out of range."); + la_runtime_assert( + static_cast(instance_index) < m_impl->m_instances[mesh_index].size(), + "instance_index out of range."); + return m_impl->m_scene.get_instance(mesh_index, instance_index).transform; +} + +void RayCaster::update_transform( + uint32_t mesh_index, + uint32_t instance_index, + const Eigen::Affine3f& transform) +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_instances.size(), + "mesh_index out of range."); + la_runtime_assert( + static_cast(instance_index) < m_impl->m_instances[mesh_index].size(), + "instance_index out of range."); + + m_impl->m_scene.ref_instance(mesh_index, instance_index).transform = transform; + + // Update the Embree instance geometry. + unsigned geom_id = m_impl->m_instances[mesh_index][instance_index].instance_geometry_id; + RTCGeometry geom_inst = rtcGetGeometry(m_impl->m_world_scene, geom_id); + rtcSetGeometryTransform( + geom_inst, + 0, + RTC_FORMAT_FLOAT4X4_COLUMN_MAJOR, + transform.matrix().data()); + rtcCommitGeometry(geom_inst); + check_errors_runtime(m_impl->m_device); + + m_impl->m_need_commit = true; +} + +bool RayCaster::get_visibility(uint32_t mesh_index, uint32_t instance_index) +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_instances.size(), + "mesh_index out of range."); + la_runtime_assert( + static_cast(instance_index) < m_impl->m_instances[mesh_index].size(), + "instance_index out of range."); + return m_impl->m_instances[mesh_index][instance_index].visible; +} + +void RayCaster::update_visibility(uint32_t mesh_index, uint32_t instance_index, bool visible) +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_instances.size(), + "mesh_index out of range."); + la_runtime_assert( + static_cast(instance_index) < m_impl->m_instances[mesh_index].size(), + "instance_index out of range."); + + auto& inst = m_impl->m_instances[mesh_index][instance_index]; + if (inst.visible != visible) { + inst.visible = visible; + + unsigned geom_id = inst.instance_geometry_id; + if (visible) { + rtcEnableGeometry(rtcGetGeometry(m_impl->m_world_scene, geom_id)); + } else { + rtcDisableGeometry(rtcGetGeometry(m_impl->m_world_scene, geom_id)); + } + check_errors_runtime(m_impl->m_device); + + m_impl->m_need_commit = true; + } +} + +// ============================================================================ +// Filtering +// ============================================================================ + +auto RayCaster::get_intersection_filter(uint32_t mesh_index) const + -> std::function +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_meshes.size(), + "mesh_index out of range."); + return m_impl->m_meshes[mesh_index].intersection_filter; +} + +void RayCaster::set_intersection_filter( + uint32_t mesh_index, + std::function&& filter) +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_meshes.size(), + "mesh_index out of range."); + la_runtime_assert( + !filter || is_embree_flag_set(m_impl->m_scene_flags, SceneFlags::Filter), + "Scene must be created with SceneFlags::Filter to use intersection filters."); + m_impl->m_meshes[mesh_index].intersection_filter = std::move(filter); + m_impl->m_has_intersection_filter = has_any_intersection_filter(*m_impl); +} + +auto RayCaster::get_occlusion_filter(uint32_t mesh_index) const + -> std::function +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_meshes.size(), + "mesh_index out of range."); + return m_impl->m_meshes[mesh_index].occlusion_filter; +} + +void RayCaster::set_occlusion_filter( + uint32_t mesh_index, + std::function&& filter) +{ + la_runtime_assert( + static_cast(mesh_index) < m_impl->m_meshes.size(), + "mesh_index out of range."); + la_runtime_assert( + !filter || is_embree_flag_set(m_impl->m_scene_flags, SceneFlags::Filter), + "Scene must be created with SceneFlags::Filter to use occlusion filters."); + m_impl->m_meshes[mesh_index].occlusion_filter = std::move(filter); + m_impl->m_has_occlusion_filter = has_any_occlusion_filter(*m_impl); +} + +// ============================================================================ +// Closest point queries +// ============================================================================ + +namespace { + +std::optional closest_point_impl( + const RayCasterImpl& impl, + const Eigen::Vector3f& query_point, + const bool snap_to_vertex) +{ + impl.check_no_pending_updates(); + + RTCPointQuery query; + query.x = query_point.x(); + query.y = query_point.y(); + query.z = query_point.z(); + query.radius = std::numeric_limits::max(); + query.time = 0.f; + + ClosestPointUserData data; + data.scene = &impl.m_scene; + data.snap_to_vertex = snap_to_vertex; + data.instance_indices = &impl.m_instance_indices; + + RTCPointQueryContext context; + rtcInitPointQueryContext(&context); + rtcPointQuery( + impl.m_world_scene, + &query, + &context, + &embree_closest_point_callback, + reinterpret_cast(&data)); + check_errors_debug(impl.m_device); + + if (data.mesh_index == invalid()) { + return std::nullopt; + } + + ClosestPointHit hit; + hit.distance = data.distance; + hit.mesh_index = data.mesh_index; + hit.instance_index = data.instance_index; + hit.facet_index = data.facet_index; + hit.barycentric_coord = data.barycentric_coord; + hit.position = data.closest_point; + return hit; +} + +template +ClosestPointHitN closest_pointN( + const RayCasterImpl& impl, + const PointNf& query_points, + const std::variant, size_t>& active, + const bool snap_to_vertex) +{ + impl.check_no_pending_updates(); + + constexpr size_t kAlign = N <= 4 ? 16 : (N <= 8 ? 32 : 64); + alignas(kAlign) std::array active_mask; + resolve_active_mask(active, active_mask); + + // Set up per-lane user data and SoA query packet. + std::array lane_data; + std::array user_ptrs; + RTCPointQueryN query; + for (size_t i = 0; i < N; ++i) { + query.x[i] = query_points(i, 0); + query.y[i] = query_points(i, 1); + query.z[i] = query_points(i, 2); + query.time[i] = 0.f; + query.radius[i] = std::numeric_limits::max(); + lane_data[i].scene = &impl.m_scene; + lane_data[i].snap_to_vertex = snap_to_vertex; + lane_data[i].instance_indices = &impl.m_instance_indices; + user_ptrs[i] = &lane_data[i]; + } + + RTCPointQueryContext context; + rtcInitPointQueryContext(&context); + auto rtc_point_query_fn = [] { + if constexpr (N == 4) { + return rtcPointQuery4; + } else if constexpr (N == 8) { + return rtcPointQuery8; + } else if constexpr (N == 16) { + return rtcPointQuery16; + } else { + static_assert(N == 4 || N == 8 || N == 16, "Unsupported packet size."); + } + }(); + rtc_point_query_fn( + active_mask.data(), + impl.m_world_scene, + &query, + &context, + &embree_closest_point_callback, + user_ptrs.data()); + check_errors_debug(impl.m_device); + + ClosestPointHitN result; + for (size_t i = 0; i < N; ++i) { + if (lane_data[i].mesh_index != invalid()) { + result.valid_mask |= (1u << i); + result.distances[i] = lane_data[i].distance; + result.mesh_indices[i] = lane_data[i].mesh_index; + result.instance_indices[i] = lane_data[i].instance_index; + result.facet_indices[i] = lane_data[i].facet_index; + result.barycentric_coords.col(i) = lane_data[i].barycentric_coord; + result.positions.col(i) = lane_data[i].closest_point; + } + } + + return result; +} + +} // namespace + +std::optional RayCaster::closest_point(const Pointf& query_point) const +{ + return closest_point_impl(*m_impl, query_point, false); +} + +RayCaster::ClosestPointHit4 RayCaster::closest_point4( + const Point4f& query_points, + const std::variant& active) const +{ + return closest_pointN<4>(*m_impl, query_points, active, false); +} + +RayCaster::ClosestPointHit8 RayCaster::closest_point8( + const Point8f& query_points, + const std::variant& active) const +{ + return closest_pointN<8>(*m_impl, query_points, active, false); +} + +RayCaster::ClosestPointHit16 RayCaster::closest_point16( + const Point16f& query_points, + const std::variant& active) const +{ + return closest_pointN<16>(*m_impl, query_points, active, false); +} + +std::optional RayCaster::closest_vertex(const Pointf& query_point) const +{ + return closest_point_impl(*m_impl, query_point, true); +} + +RayCaster::ClosestPointHit4 RayCaster::closest_vertex4( + const Point4f& query_points, + const std::variant& active) const +{ + return closest_pointN<4>(*m_impl, query_points, active, true); +} + +RayCaster::ClosestPointHit8 RayCaster::closest_vertex8( + const Point8f& query_points, + const std::variant& active) const +{ + return closest_pointN<8>(*m_impl, query_points, active, true); +} + +RayCaster::ClosestPointHit16 RayCaster::closest_vertex16( + const Point16f& query_points, + const std::variant& active) const +{ + return closest_pointN<16>(*m_impl, query_points, active, true); +} + +// ============================================================================ +// Single-ray queries +// ============================================================================ + +std::optional +RayCaster::cast(const Pointf& origin, const Directionf& direction, float tmin, float tmax) const +{ + m_impl->check_no_pending_updates(); + + RTCRayHit rayhit; + rayhit.ray.org_x = origin.x(); + rayhit.ray.org_y = origin.y(); + rayhit.ray.org_z = origin.z(); + rayhit.ray.dir_x = direction.x(); + rayhit.ray.dir_y = direction.y(); + rayhit.ray.dir_z = direction.z(); + rayhit.ray.tnear = tmin; + rayhit.ray.tfar = to_embree_tfar(tmax); + rayhit.ray.mask = 0xFFFFFFFF; + rayhit.ray.id = 0; + rayhit.ray.flags = 0; + rayhit.ray.time = 0.f; + rayhit.hit.geomID = RTC_INVALID_GEOMETRY_ID; + rayhit.hit.primID = RTC_INVALID_GEOMETRY_ID; + rayhit.hit.instID[0] = RTC_INVALID_GEOMETRY_ID; + +#ifdef LAGRANGE_WITH_EMBREE_3 + if (m_impl->m_has_intersection_filter) { + FilterContext fctx; + init_intersection_filter_context(fctx, *m_impl); + rtcIntersect1(m_impl->m_world_scene, &fctx.embree_ctx, &rayhit); + } else { + RTCIntersectContext ctx; + rtcInitIntersectContext(&ctx); + rtcIntersect1(m_impl->m_world_scene, &ctx, &rayhit); + } +#else + if (m_impl->m_has_intersection_filter) { + FilterContext fctx; + init_intersection_filter_context(fctx, *m_impl); + RTCIntersectArguments args; + rtcInitIntersectArguments(&args); + args.context = &fctx.embree_ctx; + args.filter = embree_filter_callback; + args.flags = RTC_RAY_QUERY_FLAG_INVOKE_ARGUMENT_FILTER; + rtcIntersect1(m_impl->m_world_scene, &rayhit, &args); + } else { + rtcIntersect1(m_impl->m_world_scene, &rayhit); + } +#endif + + if (rayhit.hit.geomID == RTC_INVALID_GEOMETRY_ID) { + return std::nullopt; + } + + RayHit hit; + unsigned rtc_inst_id = rayhit.hit.instID[0]; + la_debug_assert(rtc_inst_id != RTC_INVALID_GEOMETRY_ID); + m_impl->decode_instance(rtc_inst_id, hit.mesh_index, hit.instance_index); + hit.facet_index = rayhit.hit.primID; + hit.ray_depth = rayhit.ray.tfar; + hit.barycentric_coord[0] = rayhit.hit.u; + hit.barycentric_coord[1] = rayhit.hit.v; + hit.position = origin + direction * hit.ray_depth; + hit.normal[0] = rayhit.hit.Ng_x; + hit.normal[1] = rayhit.hit.Ng_y; + hit.normal[2] = rayhit.hit.Ng_z; + return hit; +} + +bool RayCaster::occluded(const Pointf& origin, const Directionf& direction, float tmin, float tmax) + const +{ + m_impl->check_no_pending_updates(); + + RTCRay ray; + ray.org_x = origin.x(); + ray.org_y = origin.y(); + ray.org_z = origin.z(); + ray.dir_x = direction.x(); + ray.dir_y = direction.y(); + ray.dir_z = direction.z(); + ray.tnear = tmin; + ray.tfar = to_embree_tfar(tmax); + ray.mask = 0xFFFFFFFF; + ray.id = 0; + ray.flags = 0; + ray.time = 0.f; + +#ifdef LAGRANGE_WITH_EMBREE_3 + if (m_impl->m_has_occlusion_filter) { + FilterContext fctx; + init_occlusion_filter_context(fctx, *m_impl); + rtcOccluded1(m_impl->m_world_scene, &fctx.embree_ctx, &ray); + } else { + RTCIntersectContext ctx; + rtcInitIntersectContext(&ctx); + rtcOccluded1(m_impl->m_world_scene, &ctx, &ray); + } +#else + if (m_impl->m_has_occlusion_filter) { + FilterContext fctx; + init_occlusion_filter_context(fctx, *m_impl); + RTCOccludedArguments args; + rtcInitOccludedArguments(&args); + args.context = &fctx.embree_ctx; + args.filter = embree_filter_callback; + args.flags = RTC_RAY_QUERY_FLAG_INVOKE_ARGUMENT_FILTER; + rtcOccluded1(m_impl->m_world_scene, &ray, &args); + } else { + rtcOccluded1(m_impl->m_world_scene, &ray); + } +#endif + + // If hit, Embree sets tfar to -inf. + return !std::isfinite(ray.tfar); +} + +// ============================================================================ +// Ray packet queries - templated +// ============================================================================ + +namespace { + +template +RayHitN castN( + const RayCasterImpl& impl, + const PointNf& origins, + const DirectionNf& directions, + const std::variant, size_t>& active, + const FloatN& tmin, + const FloatN& tmax) +{ + impl.check_no_pending_updates(); + + constexpr size_t kAlign = N <= 4 ? 16 : (N <= 8 ? 32 : 64); + alignas(kAlign) std::array embree_mask; + resolve_active_mask(active, embree_mask); + + RTCRayHitN packet; + for (size_t i = 0; i < N; ++i) { + // Only copy data for active rays to avoid using potentially uninitialized data. + // Inactive rays get safe default values. + if (embree_mask[i]) { + packet.ray.org_x[i] = origins(i, 0); + packet.ray.org_y[i] = origins(i, 1); + packet.ray.org_z[i] = origins(i, 2); + packet.ray.dir_x[i] = directions(i, 0); + packet.ray.dir_y[i] = directions(i, 1); + packet.ray.dir_z[i] = directions(i, 2); + packet.ray.tnear[i] = tmin[i]; + packet.ray.tfar[i] = to_embree_tfar(tmax[i]); + } else { + // Initialize inactive rays with valid default values to avoid NaN/garbage data + packet.ray.org_x[i] = 0.f; + packet.ray.org_y[i] = 0.f; + packet.ray.org_z[i] = 0.f; + packet.ray.dir_x[i] = 0.f; + packet.ray.dir_y[i] = 0.f; + packet.ray.dir_z[i] = 1.f; // Valid non-zero direction + packet.ray.tnear[i] = 0.f; + packet.ray.tfar[i] = std::numeric_limits::infinity(); + } + packet.ray.mask[i] = 0xFFFFFFFF; + packet.ray.id[i] = static_cast(i); + packet.ray.flags[i] = 0; + packet.ray.time[i] = 0.f; + packet.hit.geomID[i] = RTC_INVALID_GEOMETRY_ID; + packet.hit.primID[i] = RTC_INVALID_GEOMETRY_ID; + packet.hit.instID[0][i] = RTC_INVALID_GEOMETRY_ID; + } + +#ifdef LAGRANGE_WITH_EMBREE_3 + if (impl.m_has_intersection_filter) { + FilterContext fctx; + init_intersection_filter_context(fctx, impl); + if constexpr (N <= 4) { + rtcIntersect4(embree_mask.data(), impl.m_world_scene, &fctx.embree_ctx, &packet); + } else if constexpr (N <= 8) { + rtcIntersect8(embree_mask.data(), impl.m_world_scene, &fctx.embree_ctx, &packet); + } else { + rtcIntersect16(embree_mask.data(), impl.m_world_scene, &fctx.embree_ctx, &packet); + } + } else { + RTCIntersectContext ctx; + rtcInitIntersectContext(&ctx); + if constexpr (N <= 4) { + rtcIntersect4(embree_mask.data(), impl.m_world_scene, &ctx, &packet); + } else if constexpr (N <= 8) { + rtcIntersect8(embree_mask.data(), impl.m_world_scene, &ctx, &packet); + } else { + rtcIntersect16(embree_mask.data(), impl.m_world_scene, &ctx, &packet); + } + } +#else + if (impl.m_has_intersection_filter) { + FilterContext fctx; + init_intersection_filter_context(fctx, impl); + RTCIntersectArguments args; + rtcInitIntersectArguments(&args); + args.context = &fctx.embree_ctx; + args.filter = embree_filter_callback; + args.flags = RTC_RAY_QUERY_FLAG_INVOKE_ARGUMENT_FILTER; + if constexpr (N <= 4) { + rtcIntersect4(embree_mask.data(), impl.m_world_scene, &packet, &args); + } else if constexpr (N <= 8) { + rtcIntersect8(embree_mask.data(), impl.m_world_scene, &packet, &args); + } else { + rtcIntersect16(embree_mask.data(), impl.m_world_scene, &packet, &args); + } + } else { + if constexpr (N <= 4) { + rtcIntersect4(embree_mask.data(), impl.m_world_scene, &packet); + } else if constexpr (N <= 8) { + rtcIntersect8(embree_mask.data(), impl.m_world_scene, &packet); + } else { + rtcIntersect16(embree_mask.data(), impl.m_world_scene, &packet); + } + } +#endif + + RayHitN result; + for (size_t i = 0; i < N; ++i) { + if (packet.hit.geomID[i] != RTC_INVALID_GEOMETRY_ID) { + result.valid_mask |= (1u << i); + unsigned rtc_inst_id = packet.hit.instID[0][i]; + impl.decode_instance(rtc_inst_id, result.mesh_indices[i], result.instance_indices[i]); + result.facet_indices[i] = packet.hit.primID[i]; + result.ray_depths[i] = packet.ray.tfar[i]; + result.barycentric_coords(0, i) = packet.hit.u[i]; + result.barycentric_coords(1, i) = packet.hit.v[i]; + result.positions.col(i) = + origins.row(i).transpose() + directions.row(i).transpose() * result.ray_depths[i]; + result.normals(0, i) = packet.hit.Ng_x[i]; + result.normals(1, i) = packet.hit.Ng_y[i]; + result.normals(2, i) = packet.hit.Ng_z[i]; + } + } + + return result; +} + +template +uint32_t occludedN( + const RayCasterImpl& impl, + const PointNf& origins, + const DirectionNf& directions, + const std::variant, size_t>& active, + const FloatN& tmin, + const FloatN& tmax) +{ + impl.check_no_pending_updates(); + + constexpr size_t kAlign = N <= 4 ? 16 : (N <= 8 ? 32 : 64); + alignas(kAlign) std::array embree_mask; + resolve_active_mask(active, embree_mask); + + RTCRayN packet; + for (size_t i = 0; i < N; ++i) { + // Only copy data for active rays to avoid using potentially uninitialized data. + // Inactive rays get safe default values. + if (embree_mask[i]) { + packet.org_x[i] = origins(i, 0); + packet.org_y[i] = origins(i, 1); + packet.org_z[i] = origins(i, 2); + packet.dir_x[i] = directions(i, 0); + packet.dir_y[i] = directions(i, 1); + packet.dir_z[i] = directions(i, 2); + packet.tnear[i] = tmin[i]; + packet.tfar[i] = to_embree_tfar(tmax[i]); + } else { + // Initialize inactive rays with valid default values to avoid NaN/garbage data + packet.org_x[i] = 0.f; + packet.org_y[i] = 0.f; + packet.org_z[i] = 0.f; + packet.dir_x[i] = 0.f; + packet.dir_y[i] = 0.f; + packet.dir_z[i] = 1.f; // Valid non-zero direction + packet.tnear[i] = 0.f; + packet.tfar[i] = std::numeric_limits::infinity(); + } + packet.mask[i] = 0xFFFFFFFF; + packet.id[i] = static_cast(i); + packet.flags[i] = 0; + packet.time[i] = 0.f; + } + +#ifdef LAGRANGE_WITH_EMBREE_3 + if (impl.m_has_occlusion_filter) { + FilterContext fctx; + init_occlusion_filter_context(fctx, impl); + if constexpr (N <= 4) { + rtcOccluded4(embree_mask.data(), impl.m_world_scene, &fctx.embree_ctx, &packet); + } else if constexpr (N <= 8) { + rtcOccluded8(embree_mask.data(), impl.m_world_scene, &fctx.embree_ctx, &packet); + } else { + rtcOccluded16(embree_mask.data(), impl.m_world_scene, &fctx.embree_ctx, &packet); + } + } else { + RTCIntersectContext ctx; + rtcInitIntersectContext(&ctx); + if constexpr (N <= 4) { + rtcOccluded4(embree_mask.data(), impl.m_world_scene, &ctx, &packet); + } else if constexpr (N <= 8) { + rtcOccluded8(embree_mask.data(), impl.m_world_scene, &ctx, &packet); + } else { + rtcOccluded16(embree_mask.data(), impl.m_world_scene, &ctx, &packet); + } + } +#else + if (impl.m_has_occlusion_filter) { + FilterContext fctx; + init_occlusion_filter_context(fctx, impl); + RTCOccludedArguments args; + rtcInitOccludedArguments(&args); + args.context = &fctx.embree_ctx; + args.filter = embree_filter_callback; + args.flags = RTC_RAY_QUERY_FLAG_INVOKE_ARGUMENT_FILTER; + if constexpr (N <= 4) { + rtcOccluded4(embree_mask.data(), impl.m_world_scene, &packet, &args); + } else if constexpr (N <= 8) { + rtcOccluded8(embree_mask.data(), impl.m_world_scene, &packet, &args); + } else { + rtcOccluded16(embree_mask.data(), impl.m_world_scene, &packet, &args); + } + } else { + if constexpr (N <= 4) { + rtcOccluded4(embree_mask.data(), impl.m_world_scene, &packet); + } else if constexpr (N <= 8) { + rtcOccluded8(embree_mask.data(), impl.m_world_scene, &packet); + } else { + rtcOccluded16(embree_mask.data(), impl.m_world_scene, &packet); + } + } +#endif + + uint32_t hit_mask = 0; + for (size_t i = 0; i < N; ++i) { + if (embree_mask[i] && !std::isfinite(packet.tfar[i])) { + hit_mask |= (1u << i); + } + } + + return hit_mask; +} + +} // namespace + +// ============================================================================ +// Ray packet queries - 4-wide +// ============================================================================ + +RayCaster::RayHit4 RayCaster::cast4( + const Point4f& origins, + const Direction4f& directions, + const std::variant& active, + const Float4& tmin, + const Float4& tmax) const +{ + return castN<4>(*m_impl, origins, directions, active, tmin, tmax); +} + +uint32_t RayCaster::occluded4( + const Point4f& origins, + const Direction4f& directions, + const std::variant& active, + const Float4& tmin, + const Float4& tmax) const +{ + return occludedN<4>(*m_impl, origins, directions, active, tmin, tmax); +} + +// ============================================================================ +// Ray packet queries - 8-wide +// ============================================================================ + +RayCaster::RayHit8 RayCaster::cast8( + const Point8f& origins, + const Direction8f& directions, + const std::variant& active, + const Float8& tmin, + const Float8& tmax) const +{ + return castN<8>(*m_impl, origins, directions, active, tmin, tmax); +} + +uint32_t RayCaster::occluded8( + const Point8f& origins, + const Direction8f& directions, + const std::variant& active, + const Float8& tmin, + const Float8& tmax) const +{ + return occludedN<8>(*m_impl, origins, directions, active, tmin, tmax); +} + +// ============================================================================ +// Ray packet queries - 16-wide +// ============================================================================ + +RayCaster::RayHit16 RayCaster::cast16( + const Point16f& origins, + const Direction16f& directions, + const std::variant& active, + const Float16& tmin, + const Float16& tmax) const +{ + return castN<16>(*m_impl, origins, directions, active, tmin, tmax); +} + +uint32_t RayCaster::occluded16( + const Point16f& origins, + const Direction16f& directions, + const std::variant& active, + const Float16& tmin, + const Float16& tmax) const +{ + return occludedN<16>(*m_impl, origins, directions, active, tmin, tmax); +} + +// ============================================================================ +// Explicit template instantiation +// ============================================================================ + +#define LA_X_raycaster_methods(_, Scalar, Index) \ + template LA_RAYCASTING_API uint32_t RayCaster::add_mesh( \ + SurfaceMesh, \ + const std::optional>&); \ + template LA_RAYCASTING_API void RayCaster::add_scene( \ + scene::SimpleScene); \ + template LA_RAYCASTING_API void RayCaster::update_mesh( \ + uint32_t, \ + const SurfaceMesh&); \ + template LA_RAYCASTING_API void RayCaster::update_vertices( \ + uint32_t, \ + const SurfaceMesh&); +LA_SURFACE_MESH_X(raycaster_methods, 0) + +#define LA_X_raycaster_update_vertices(_, Scalar) \ + template LA_RAYCASTING_API void RayCaster::update_vertices( \ + uint32_t, \ + span); +LA_SURFACE_MESH_SCALAR_X(raycaster_update_vertices, 0) + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/src/EmbreeHelper.cpp b/modules/raycasting/src/legacy/EmbreeHelper.cpp similarity index 87% rename from modules/raycasting/src/EmbreeHelper.cpp rename to modules/raycasting/src/legacy/EmbreeHelper.cpp index 25b983f5..9ad36768 100644 --- a/modules/raycasting/src/EmbreeHelper.cpp +++ b/modules/raycasting/src/legacy/EmbreeHelper.cpp @@ -9,7 +9,12 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -#include +#ifdef LAGRANGE_WITH_EMBREE_3 + #include +#else + #include +#endif + #include #include @@ -18,8 +23,9 @@ #include #include +RTC_NAMESPACE_USE -void lagrange::raycasting::EmbreeHelper::ensure_no_errors(const RTCDevice& device) +void lagrange::raycasting::legacy::EmbreeHelper::ensure_no_errors(const RTCDevice& device) { std::stringstream err_msg; auto err = rtcGetDeviceError(device); diff --git a/modules/raycasting/src/prepare_attribute_ids.h b/modules/raycasting/src/prepare_attribute_ids.h new file mode 100644 index 00000000..de415e7a --- /dev/null +++ b/modules/raycasting/src/prepare_attribute_ids.h @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +namespace lagrange::raycasting { + +template +std::vector prepare_attribute_ids( + const SurfaceMesh& source, + const ProjectCommonOptions& options) +{ + // Build the full list of attribute ids to project. + std::vector attribute_ids = options.attribute_ids; + if (options.project_vertices) { + attribute_ids.push_back(source.attr_id_vertex_to_position()); + } + // Remove duplicates, if any + std::sort(attribute_ids.begin(), attribute_ids.end()); + attribute_ids.erase( + std::unique(attribute_ids.begin(), attribute_ids.end()), + attribute_ids.end()); + return attribute_ids; +} + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/src/prepare_ray_caster.h b/modules/raycasting/src/prepare_ray_caster.h new file mode 100644 index 00000000..6c0cf12d --- /dev/null +++ b/modules/raycasting/src/prepare_ray_caster.h @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +namespace lagrange::raycasting { + +template +std::unique_ptr prepare_ray_caster( + const SurfaceMesh& source, + const RayCaster* ray_caster) +{ + std::unique_ptr engine; + if (!ray_caster) { + engine = std::make_unique(SceneFlags::Robust, BuildQuality::High); + SurfaceMesh source_copy = source; + engine->add_mesh(std::move(source_copy)); + engine->commit_updates(); + ray_caster = engine.get(); + } + return engine; +} + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/src/project.cpp b/modules/raycasting/src/project.cpp new file mode 100644 index 00000000..380c9dfb --- /dev/null +++ b/modules/raycasting/src/project.cpp @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +#include +#include +#include +#include +#include + +#include + +namespace lagrange::raycasting { + +template +void project( + const SurfaceMesh& source, + SurfaceMesh& target, + const ProjectOptions& options, + const RayCaster* ray_caster) +{ + la_runtime_assert(source.is_triangle_mesh()); + + switch (options.project_mode) { + case ProjectMode::ClosestVertex: { + project_closest_vertex(source, target, options, ray_caster); + break; + } + case ProjectMode::ClosestPoint: { + project_closest_point(source, target, options, ray_caster); + break; + } + case ProjectMode::RayCasting: { + project_directional(source, target, options, ray_caster); + break; + } + default: throw std::runtime_error("Unknown ProjectMode"); + } +} + +#define LA_X_project(_, Scalar, Index) \ + template LA_RAYCASTING_API void project( \ + const SurfaceMesh&, \ + SurfaceMesh&, \ + const ProjectOptions&, \ + const RayCaster*); +LA_SURFACE_MESH_X(project, 0) + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/src/project_closest_point.cpp b/modules/raycasting/src/project_closest_point.cpp new file mode 100644 index 00000000..82f5845e --- /dev/null +++ b/modules/raycasting/src/project_closest_point.cpp @@ -0,0 +1,168 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +#include "prepare_attribute_ids.h" +#include "prepare_ray_caster.h" + +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +// clang-format on + +namespace lagrange::raycasting { + +template +void project_closest_point( + const SurfaceMesh& source, + SurfaceMesh& target, + const ProjectCommonOptions& options, + const RayCaster* ray_caster) +{ + la_runtime_assert(source.is_triangle_mesh()); + + auto attribute_ids = prepare_attribute_ids(source, options); + + const auto& skip_vertex = options.skip_vertex; + + // Build a temporary ray caster if one is not provided. + auto engine = prepare_ray_caster(source, ray_caster); + if (engine) { + ray_caster = engine.get(); + } + + // Gather source attribute metadata. + struct AttrInfo + { + AttributeId source_id; + AttributeId target_id; + size_t num_channels; + }; + std::vector attrs; + attrs.reserve(attribute_ids.size()); + for (auto src_id : attribute_ids) { + const auto& src_attr = source.get_attribute_base(src_id); + la_runtime_assert( + src_attr.get_element_type() == AttributeElement::Vertex, + "Only vertex attributes are supported"); + + auto name = source.get_attribute_name(src_id); + size_t num_channels = src_attr.get_num_channels(); + + AttributeId dst_id = lagrange::internal::find_or_create_attribute( + target, + name, + AttributeElement::Vertex, + src_attr.get_usage(), + num_channels, + lagrange::internal::ResetToDefault::No); + + attrs.push_back({src_id, dst_id, num_channels}); + } + + // Get the source facet indices for barycentric interpolation. + auto source_facets = facet_view(source); + + // Get target vertex positions. + auto target_vertices = vertex_view(target); + const Index num_target_vertices = target.get_num_vertices(); + + // Process target vertices in packets of 16 for SIMD efficiency. + const Index num_packets = (num_target_vertices + 15) / 16; + + tbb::parallel_for(Index(0), num_packets, [&](Index packet_index) { + const Index base = packet_index * 16; + const size_t batchsize = + static_cast(std::min(num_target_vertices - base, Index(16))); + + RayCaster::Mask16 mask = RayCaster::Mask16::Constant(true); + RayCaster::Point16f queries; + + size_t num_skipped = 0; + for (size_t b = 0; b < batchsize; ++b) { + const Index vi = base + static_cast(b); + if (skip_vertex && skip_vertex(vi)) { + mask(static_cast(b)) = false; + ++num_skipped; + continue; + } + queries.row(static_cast(b)) = + target_vertices.row(vi).template cast(); + } + + if (num_skipped == batchsize) return; + + for (size_t b = batchsize; b < 16; ++b) { + mask(static_cast(b)) = false; + } + + auto result = ray_caster->closest_point16(queries, mask); + + for (size_t b = 0; b < batchsize; ++b) { + if (!mask(static_cast(b))) continue; + const Index vi = base + static_cast(b); + + la_runtime_assert(result.is_valid(b), "closest_point query returned no hit"); + la_runtime_assert( + result.facet_indices(static_cast(b)) < + static_cast(source.get_num_facets())); + + // Reconstruct 3-component barycentric coordinates from the (u, v) pair. + float u = result.barycentric_coords(0, static_cast(b)); + float v = result.barycentric_coords(1, static_cast(b)); + Scalar b0 = static_cast(1.0f - u - v); + Scalar b1 = static_cast(u); + Scalar b2 = static_cast(v); + + auto face = source_facets.row(result.facet_indices(static_cast(b))); + + // Interpolate each attribute. + for (const auto& info : attrs) { + const auto& src_attr = source.template get_attribute(info.source_id); + auto& dst_attr = target.template ref_attribute(info.target_id); + + auto src_span = src_attr.get_all(); + auto dst_span = dst_attr.ref_all(); + + size_t nc = info.num_channels; + size_t dst_offset = static_cast(vi) * nc; + size_t s0 = static_cast(face[0]) * nc; + size_t s1 = static_cast(face[1]) * nc; + size_t s2 = static_cast(face[2]) * nc; + + for (size_t c = 0; c < nc; ++c) { + dst_span[dst_offset + c] = + src_span[s0 + c] * b0 + src_span[s1 + c] * b1 + src_span[s2 + c] * b2; + } + } + } + }); +} + +#define LA_X_project_closest_point(_, Scalar, Index) \ + template LA_RAYCASTING_API void project_closest_point( \ + const SurfaceMesh&, \ + SurfaceMesh&, \ + const ProjectCommonOptions&, \ + const RayCaster*); +LA_SURFACE_MESH_X(project_closest_point, 0) + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/src/project_closest_vertex.cpp b/modules/raycasting/src/project_closest_vertex.cpp new file mode 100644 index 00000000..6c617cbe --- /dev/null +++ b/modules/raycasting/src/project_closest_vertex.cpp @@ -0,0 +1,173 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +#include "prepare_attribute_ids.h" +#include "prepare_ray_caster.h" + +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +// clang-format on + +namespace lagrange::raycasting { + +template +void project_closest_vertex( + const SurfaceMesh& source, + SurfaceMesh& target, + const ProjectCommonOptions& options, + const RayCaster* ray_caster) +{ + la_runtime_assert(source.is_triangle_mesh()); + + auto attribute_ids = prepare_attribute_ids(source, options); + + const auto& skip_vertex = options.skip_vertex; + + // Build a temporary ray caster if one is not provided. + auto engine = prepare_ray_caster(source, ray_caster); + if (engine) { + ray_caster = engine.get(); + } + + // Gather source attribute metadata. + struct AttrInfo + { + AttributeId source_id; + AttributeId target_id; + size_t num_channels; + }; + std::vector attrs; + attrs.reserve(attribute_ids.size()); + for (auto src_id : attribute_ids) { + const auto& src_attr = source.get_attribute_base(src_id); + la_runtime_assert( + src_attr.get_element_type() == AttributeElement::Vertex, + "Only vertex attributes are supported"); + + auto name = source.get_attribute_name(src_id); + size_t num_channels = src_attr.get_num_channels(); + + AttributeId dst_id = lagrange::internal::find_or_create_attribute( + target, + name, + AttributeElement::Vertex, + src_attr.get_usage(), + num_channels, + lagrange::internal::ResetToDefault::No); + + attrs.push_back({src_id, dst_id, num_channels}); + } + + // Get the source facet indices for vertex lookup. + auto source_facets = facet_view(source); + + // Get target vertex positions. + auto target_vertices = vertex_view(target); + const Index num_target_vertices = target.get_num_vertices(); + + // Process target vertices in packets of 16 for SIMD efficiency. + const Index num_packets = (num_target_vertices + 15) / 16; + + tbb::parallel_for(Index(0), num_packets, [&](Index packet_index) { + const Index base = packet_index * 16; + const size_t batchsize = + static_cast(std::min(num_target_vertices - base, Index(16))); + + RayCaster::Mask16 mask = RayCaster::Mask16::Constant(true); + RayCaster::Point16f queries = RayCaster::Point16f::Zero(); + + size_t num_skipped = 0; + for (size_t b = 0; b < batchsize; ++b) { + const Index vi = base + static_cast(b); + if (skip_vertex && skip_vertex(vi)) { + mask(static_cast(b)) = false; + ++num_skipped; + continue; + } + queries.row(static_cast(b)) = + target_vertices.row(vi).template cast(); + } + + if (num_skipped == batchsize) return; + + for (size_t b = batchsize; b < 16; ++b) { + mask(static_cast(b)) = false; + } + + auto result = ray_caster->closest_vertex16(queries, mask); + + for (size_t b = 0; b < batchsize; ++b) { + if (!mask(static_cast(b))) continue; + const Index vi = base + static_cast(b); + + la_runtime_assert(result.is_valid(b), "closest_point query returned no hit"); + la_runtime_assert( + result.facet_indices(static_cast(b)) < + static_cast(source.get_num_facets())); + + // Determine which vertex of the hit triangle is closest by picking the one with the + // largest barycentric weight. + float u = result.barycentric_coords(0, static_cast(b)); + float v = result.barycentric_coords(1, static_cast(b)); + float w = 1.0f - u - v; + + auto face = source_facets.row(result.facet_indices(static_cast(b))); + + Index closest_vi; + if (w >= u && w >= v) { + closest_vi = face[0]; + } else if (u >= v) { + closest_vi = face[1]; + } else { + closest_vi = face[2]; + } + + // Copy attribute values from the closest source vertex. + for (const auto& info : attrs) { + const auto& src_attr = source.template get_attribute(info.source_id); + auto& dst_attr = target.template ref_attribute(info.target_id); + + auto src_span = src_attr.get_all(); + auto dst_span = dst_attr.ref_all(); + + size_t nc = info.num_channels; + size_t dst_offset = static_cast(vi) * nc; + size_t src_offset = static_cast(closest_vi) * nc; + + for (size_t c = 0; c < nc; ++c) { + dst_span[dst_offset + c] = src_span[src_offset + c]; + } + } + } + }); +} + +#define LA_X_project_closest_vertex(_, Scalar, Index) \ + template LA_RAYCASTING_API void project_closest_vertex( \ + const SurfaceMesh&, \ + SurfaceMesh&, \ + const ProjectCommonOptions&, \ + const RayCaster*); +LA_SURFACE_MESH_X(project_closest_vertex, 0) + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/src/project_directional.cpp b/modules/raycasting/src/project_directional.cpp new file mode 100644 index 00000000..8e36da40 --- /dev/null +++ b/modules/raycasting/src/project_directional.cpp @@ -0,0 +1,345 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +#include "prepare_attribute_ids.h" +#include "prepare_ray_caster.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include +#include + +namespace lagrange::raycasting { + +template +void project_directional( + const SurfaceMesh& source, + SurfaceMesh& target, + const ProjectDirectionalOptions& options, + const RayCaster* ray_caster) +{ + la_runtime_assert(source.is_triangle_mesh()); + + auto attribute_ids = prepare_attribute_ids(source, options); + + const auto cast_mode = options.cast_mode; + const auto fallback_mode = options.fallback_mode; + const Scalar default_value = static_cast(options.default_value); + const auto& user_callback = options.user_callback; + const auto& skip_vertex = options.skip_vertex; + + // Resolve the direction variant. + // - monostate: find or compute vertex normals on the target mesh. + // - AttributeId: per-vertex direction attribute on the target mesh. + // - Vector3f: uniform direction for all rays. + bool uniform_direction = false; + Eigen::Vector3f uniform_dir = Eigen::Vector3f::UnitZ(); + AttributeId direction_attr_id = invalid(); + + struct DirectionCleanup + { + SurfaceMesh* mesh = nullptr; + AttributeId attr_id = invalid(); + ~DirectionCleanup() + { + if (mesh && attr_id != invalid()) { + mesh->delete_attribute(mesh->get_attribute_name(attr_id)); + } + } + }; + DirectionCleanup direction_cleanup; + + std::visit( + [&](auto&& arg) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + uniform_direction = true; + uniform_dir = arg.normalized(); + } else if constexpr (std::is_same_v) { + direction_attr_id = arg; + auto res = internal::check_attribute( + target, + direction_attr_id, + AttributeElement::Vertex, + AttributeUsage::Normal, + 3, + internal::ShouldBeWritable::No); + if (!res.success) { + throw Error(fmt::format("Invalid direction attribute: {}", res.msg)); + } + } else { + // std::monostate: find existing vertex normal or compute one. + // Search for an existing vertex normal attribute on the target mesh. + AttributeMatcher matcher; + matcher.usages = AttributeUsage::Normal; + matcher.element_types = AttributeElement::Vertex; + matcher.num_channels = 3; + auto found_id = find_matching_attribute(target, matcher); + if (found_id.has_value()) { + direction_attr_id = found_id.value(); + } else { + // Compute vertex normals. + direction_attr_id = compute_vertex_normal(target); + direction_cleanup.mesh = ⌖ + direction_cleanup.attr_id = direction_attr_id; + } + } + }, + options.direction); + + // Build a temporary ray caster if one is not provided. + auto engine = prepare_ray_caster(source, ray_caster); + if (engine) { + ray_caster = engine.get(); + } + + // Gather source attribute metadata. + struct AttrInfo + { + AttributeId source_id; + AttributeId target_id; + size_t num_channels; + }; + std::vector attrs; + attrs.reserve(attribute_ids.size()); + for (auto src_id : attribute_ids) { + auto name = source.get_attribute_name(src_id); + la_runtime_assert( + source.has_attribute(name), + fmt::format("Source mesh missing attribute: {}", name)); + const auto& src_base = source.get_attribute_base(src_id); + la_runtime_assert( + src_base.get_element_type() == AttributeElement::Vertex, + fmt::format("Only vertex attributes are supported: {}", name)); + + size_t num_channels = src_base.get_num_channels(); + + AttributeId dst_id = lagrange::internal::find_or_create_attribute( + target, + name, + AttributeElement::Vertex, + src_base.get_usage(), + num_channels, + lagrange::internal::ResetToDefault::No); + + attrs.push_back({src_id, dst_id, num_channels}); + } + + // Compute bounding box diagonal for the offset in BOTH_WAYS mode. + auto source_verts = vertex_view(source); + Eigen::Vector3f bbox_min = source_verts.template cast().colwise().minCoeff().transpose(); + Eigen::Vector3f bbox_max = source_verts.template cast().colwise().maxCoeff().transpose(); + float diag = (bbox_max - bbox_min).norm(); + + // Get source facet connectivity. + auto source_facets = facet_view(source); + + // Get target vertex positions. + auto target_vertices = vertex_view(target); + const Index num_target_vertices = target.get_num_vertices(); + + // Get per-vertex direction data (if applicable). + lagrange::span dir_data; + if (!uniform_direction) { + const auto& dir_attr = target.template get_attribute(direction_attr_id); + dir_data = dir_attr.get_all(); + } + + // Track which vertices were hit (needed for FallbackMode fallback). + // Using char instead of bool for thread-safe concurrent writes. + std::vector is_hit; + if (fallback_mode != FallbackMode::Constant) { + is_hit.assign(num_target_vertices, false); + } + + // Process target vertices in packets of 16 for SIMD efficiency. + const Index num_packets = (num_target_vertices + 15) / 16; + + tbb::parallel_for(Index(0), num_packets, [&](Index packet_index) { + Index base = packet_index * 16; + size_t batchsize = static_cast(std::min(num_target_vertices - base, Index(16))); + + RayCaster::Mask16 mask = RayCaster::Mask16::Constant(true); + RayCaster::Point16f origins; + RayCaster::Direction16f dirs; + + size_t num_skipped = 0; + for (size_t b = 0; b < batchsize; ++b) { + Index i = base + static_cast(b); + if (skip_vertex && skip_vertex(i)) { + if (!is_hit.empty()) { + is_hit[i] = true; + } + mask(static_cast(b)) = false; + ++num_skipped; + continue; + } + origins.row(static_cast(b)) = + target_vertices.row(i).template cast(); + if (uniform_direction) { + dirs.row(static_cast(b)) = uniform_dir.transpose(); + } else { + size_t offset = static_cast(i) * 3; + Eigen::Vector3f d( + static_cast(dir_data[offset + 0]), + static_cast(dir_data[offset + 1]), + static_cast(dir_data[offset + 2])); + dirs.row(static_cast(b)) = d.normalized().transpose(); + } + } + + if (num_skipped == batchsize) return; + + for (size_t b = batchsize; b < 16; ++b) { + mask(static_cast(b)) = false; + } + + auto result = ray_caster->cast16(origins, dirs, mask); + + if (cast_mode == CastMode::BothWays) { + // Try again in the opposite direction with a small offset. + RayCaster::Point16f origins2 = origins; + RayCaster::Direction16f dirs2 = -dirs; + for (size_t b = 0; b < batchsize; ++b) { + origins2.row(static_cast(b)) += + 1e-6f * diag * dirs.row(static_cast(b)); + } + + auto result2 = ray_caster->cast16(origins2, dirs2, mask); + + // Keep the closer hit. + for (size_t b = 0; b < batchsize; ++b) { + if (!mask(static_cast(b))) continue; + bool hit1 = result.is_valid(b); + bool hit2 = result2.is_valid(b); + if (hit2) { + float len2 = + std::abs(1e-6f * diag - result2.ray_depths(static_cast(b))); + if (!hit1 || len2 < result.ray_depths(static_cast(b))) { + result.valid_mask |= (1u << b); + result.mesh_indices(static_cast(b)) = + result2.mesh_indices(static_cast(b)); + result.facet_indices(static_cast(b)) = + result2.facet_indices(static_cast(b)); + result.barycentric_coords.col(static_cast(b)) = + result2.barycentric_coords.col(static_cast(b)); + result.ray_depths(static_cast(b)) = len2; + } + } + } + } + + // Write results for each ray in the packet. + for (size_t b = 0; b < batchsize; ++b) { + if (!mask(static_cast(b))) continue; + Index i = base + static_cast(b); + bool hit = result.is_valid(b); + + if (hit) { + la_runtime_assert( + result.facet_indices(static_cast(b)) < + static_cast(source.get_num_facets())); + + float u = result.barycentric_coords(0, static_cast(b)); + float v = result.barycentric_coords(1, static_cast(b)); + Scalar b0 = static_cast(1.0f - u - v); + Scalar b1 = static_cast(u); + Scalar b2 = static_cast(v); + + auto face = source_facets.row(result.facet_indices(static_cast(b))); + + for (const auto& info : attrs) { + const auto& src_attr = source.template get_attribute(info.source_id); + auto& dst_attr = target.template ref_attribute(info.target_id); + + auto src_span = src_attr.get_all(); + auto dst_span = dst_attr.ref_all(); + + size_t nc = info.num_channels; + size_t dst_offset = static_cast(i) * nc; + size_t s0 = static_cast(face[0]) * nc; + size_t s1 = static_cast(face[1]) * nc; + size_t s2 = static_cast(face[2]) * nc; + + for (size_t c = 0; c < nc; ++c) { + dst_span[dst_offset + c] = + src_span[s0 + c] * b0 + src_span[s1 + c] * b1 + src_span[s2 + c] * b2; + } + } + } else { + // No hit: fill with default for Constant mode. + for (const auto& info : attrs) { + auto& dst_attr = target.template ref_attribute(info.target_id); + auto dst_span = dst_attr.ref_all(); + size_t nc = info.num_channels; + size_t dst_offset = static_cast(i) * nc; + for (size_t c = 0; c < nc; ++c) { + dst_span[dst_offset + c] = default_value; + } + } + } + + if (!is_hit.empty()) { + is_hit[i] = hit; + } + if (user_callback) { + user_callback(i, hit); + } + } + }); + + // If there is any vertex without a hit, defer to the relevant fallback function. + if (fallback_mode != FallbackMode::Constant) { + bool all_hit = std::all_of(is_hit.begin(), is_hit.end(), [](char x) { return bool(x); }); + if (!all_hit) { + auto fallback_skip = [&](Index i) { return bool(is_hit[i]); }; + ProjectCommonOptions fb_options(options); + fb_options.skip_vertex = fallback_skip; + if (fallback_mode == FallbackMode::ClosestPoint) { + project_closest_point(source, target, fb_options, ray_caster); + } else if (fallback_mode == FallbackMode::ClosestVertex) { + project_closest_vertex(source, target, fb_options, ray_caster); + } else { + la_runtime_assert(false, "Unknown FallbackMode"); + } + } + } +} + +#define LA_X_project_directional(_, Scalar, Index) \ + template LA_RAYCASTING_API void project_directional( \ + const SurfaceMesh&, \ + SurfaceMesh&, \ + const ProjectDirectionalOptions&, \ + const RayCaster*); +LA_SURFACE_MESH_X(project_directional, 0) + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/tests/test_RayCaster.cpp b/modules/raycasting/tests/test_RayCaster.cpp new file mode 100644 index 00000000..040b4bfe --- /dev/null +++ b/modules/raycasting/tests/test_RayCaster.cpp @@ -0,0 +1,672 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +/// Create a SurfaceMesh unit cube from the legacy create_cube(). +lagrange::SurfaceMesh32d create_cube_surface_mesh() +{ + return lagrange::to_surface_mesh_copy(*lagrange::create_cube()); +} + +} // namespace + +TEST_CASE("RayCaster: single ray", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + SECTION("hit from outside") + { + // Ray from (5,0,0) toward (-1,0,0) should hit the cube at x=1 + auto hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->mesh_index == 0); + REQUIRE(hit->ray_depth == Catch::Approx(4.0f).margin(1e-4f)); + REQUIRE(hit->position.x() == Catch::Approx(1.0f).margin(1e-4f)); + } + + SECTION("miss") + { + // Ray from (5,0,0) toward (1,0,0) should miss (going away from cube) + auto hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(1, 0, 0)); + REQUIRE(!hit.has_value()); + } + + SECTION("hit from inside") + { + // Ray from origin toward (1,0,0) should hit the cube at x=1 + auto hit = caster.cast(Eigen::Vector3f(0, 0, 0), Eigen::Vector3f(1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->mesh_index == 0); + REQUIRE(hit->position.x() == Catch::Approx(1.0f).margin(1e-4f)); + } + + SECTION("all directions hit from inside") + { + // From the center, rays in all spherical directions should hit the cube + const int order = 10; + int hit_count = 0; + int total = 0; + for (int i = 0; i <= order; ++i) { + float theta = float(i) / float(order) * 2.f * float(lagrange::internal::pi); + for (int j = 0; j <= order; ++j) { + float phi = float(j) / float(order) * float(lagrange::internal::pi) - + float(lagrange::internal::pi) * 0.5f; + Eigen::Vector3f dir( + std::cos(phi) * std::cos(theta), + std::cos(phi) * std::sin(theta), + std::sin(phi)); + + auto hit = caster.cast(Eigen::Vector3f(0, 0, 0), dir); + if (hit.has_value()) { + hit_count++; + } + total++; + } + } + REQUIRE(hit_count == total); + } +} + +TEST_CASE("RayCaster: occluded", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + SECTION("occluded from outside") + { + bool occ = caster.occluded(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(occ); + } + + SECTION("not occluded") + { + bool occ = caster.occluded(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(1, 0, 0)); + REQUIRE(!occ); + } +} + +TEST_CASE("RayCaster: closest_point", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + SECTION("point on face") + { + // Query from (2,0,0): closest point should be on the +x face at (1,0,0) + auto hit = caster.closest_point(Eigen::Vector3f(2, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->position.x() == Catch::Approx(1.0f).margin(1e-4f)); + REQUIRE(hit->position.y() == Catch::Approx(0.0f).margin(1e-4f)); + REQUIRE(hit->position.z() == Catch::Approx(0.0f).margin(1e-4f)); + REQUIRE(hit->mesh_index == 0); + } + + SECTION("point on edge") + { + // Query from (2,2,0): closest point should be on the edge at (1,1,0) + auto hit = caster.closest_point(Eigen::Vector3f(2, 2, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->position.x() == Catch::Approx(1.0f).margin(1e-4f)); + REQUIRE(hit->position.y() == Catch::Approx(1.0f).margin(1e-4f)); + REQUIRE(hit->position.z() == Catch::Approx(0.0f).margin(1e-4f)); + } + + SECTION("point on vertex") + { + // Query from (2,2,2): closest point should be at (1,1,1) + auto hit = caster.closest_point(Eigen::Vector3f(2, 2, 2)); + REQUIRE(hit.has_value()); + REQUIRE(hit->position.x() == Catch::Approx(1.0f).margin(1e-4f)); + REQUIRE(hit->position.y() == Catch::Approx(1.0f).margin(1e-4f)); + REQUIRE(hit->position.z() == Catch::Approx(1.0f).margin(1e-4f)); + } +} + +TEST_CASE("RayCaster: cast4 packet", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + SECTION("all 4 rays hit") + { + rc::RayCaster::Point4f origins = rc::RayCaster::Point4f::Zero(); + rc::RayCaster::Direction4f dirs = rc::RayCaster::Direction4f::Zero(); + for (int i = 0; i < 4; ++i) { + origins(i, 0) = 5.0f; + dirs(i, 0) = -1.0f; + } + + auto result = caster.cast4(origins, dirs, size_t(4)); + for (int i = 0; i < 4; ++i) { + REQUIRE(result.is_valid(i)); + REQUIRE(result.ray_depths(i) == Catch::Approx(4.0f).margin(1e-3f)); + } + } + + SECTION("partial mask") + { + rc::RayCaster::Point4f origins = rc::RayCaster::Point4f::Zero(); + rc::RayCaster::Direction4f dirs = rc::RayCaster::Direction4f::Zero(); + for (int i = 0; i < 4; ++i) { + origins(i, 0) = 5.0f; + dirs(i, 0) = -1.0f; + } + + // Only activate first 2 rays + auto result = caster.cast4(origins, dirs, size_t(2)); + for (int i = 0; i < 2; ++i) { + REQUIRE(result.is_valid(i)); + } + } + + SECTION("mixed hit/miss") + { + rc::RayCaster::Point4f origins = rc::RayCaster::Point4f::Zero(); + rc::RayCaster::Direction4f dirs = rc::RayCaster::Direction4f::Zero(); + + for (int i = 0; i < 4; ++i) { + origins(i, 0) = 5.0f; + if (i % 2 == 0) { + dirs(i, 0) = -1.0f; // toward cube + } else { + dirs(i, 0) = 1.0f; // away from cube + } + } + + auto result = caster.cast4(origins, dirs, size_t(4)); + for (int i = 0; i < 4; ++i) { + if (i % 2 == 0) { + REQUIRE(result.is_valid(i)); + } else { + REQUIRE(!result.is_valid(i)); + } + } + } +} + +TEST_CASE("RayCaster: cast8 packet", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + SECTION("all 8 rays hit") + { + rc::RayCaster::Point8f origins = rc::RayCaster::Point8f::Zero(); + rc::RayCaster::Direction8f dirs = rc::RayCaster::Direction8f::Zero(); + for (int i = 0; i < 8; ++i) { + origins(i, 0) = 5.0f; + dirs(i, 0) = -1.0f; + } + + auto result = caster.cast8(origins, dirs, size_t(8)); + for (int i = 0; i < 8; ++i) { + REQUIRE(result.is_valid(i)); + REQUIRE(result.ray_depths(i) == Catch::Approx(4.0f).margin(1e-3f)); + } + } + + SECTION("partial mask") + { + rc::RayCaster::Point8f origins = rc::RayCaster::Point8f::Zero(); + rc::RayCaster::Direction8f dirs = rc::RayCaster::Direction8f::Zero(); + for (int i = 0; i < 8; ++i) { + origins(i, 0) = 5.0f; + dirs(i, 0) = -1.0f; + } + + // Only activate first 3 rays + auto result = caster.cast8(origins, dirs, size_t(3)); + for (int i = 0; i < 3; ++i) { + REQUIRE(result.is_valid(i)); + } + } + + SECTION("mixed hit/miss") + { + rc::RayCaster::Point8f origins = rc::RayCaster::Point8f::Zero(); + rc::RayCaster::Direction8f dirs = rc::RayCaster::Direction8f::Zero(); + + for (int i = 0; i < 8; ++i) { + origins(i, 0) = 5.0f; + if (i % 2 == 0) { + dirs(i, 0) = -1.0f; // toward cube + } else { + dirs(i, 0) = 1.0f; // away from cube + } + } + + auto result = caster.cast8(origins, dirs, size_t(8)); + for (int i = 0; i < 8; ++i) { + if (i % 2 == 0) { + REQUIRE(result.is_valid(i)); + } else { + REQUIRE(!result.is_valid(i)); + } + } + } +} + +TEST_CASE("RayCaster: cast16 packet", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + SECTION("all 16 rays hit") + { + rc::RayCaster::Point16f origins = rc::RayCaster::Point16f::Zero(); + rc::RayCaster::Direction16f dirs = rc::RayCaster::Direction16f::Zero(); + for (int i = 0; i < 16; ++i) { + origins(i, 0) = 5.0f; + origins(i, 1) = 0.0f; + origins(i, 2) = 0.0f; + dirs(i, 0) = -1.0f; + } + + auto result = caster.cast16(origins, dirs, size_t(16)); + for (int i = 0; i < 16; ++i) { + REQUIRE(result.is_valid(i)); + REQUIRE(result.ray_depths(i) == Catch::Approx(4.0f).margin(1e-3f)); + } + } + + SECTION("partial mask") + { + rc::RayCaster::Point16f origins = rc::RayCaster::Point16f::Zero(); + rc::RayCaster::Direction16f dirs = rc::RayCaster::Direction16f::Zero(); + for (int i = 0; i < 16; ++i) { + origins(i, 0) = 5.0f; + dirs(i, 0) = -1.0f; + } + + // Only activate first 4 rays + auto result = caster.cast16(origins, dirs, size_t(4)); + for (int i = 0; i < 4; ++i) { + REQUIRE(result.is_valid(i)); + } + } + + SECTION("mixed hit/miss") + { + rc::RayCaster::Point16f origins = rc::RayCaster::Point16f::Zero(); + rc::RayCaster::Direction16f dirs = rc::RayCaster::Direction16f::Zero(); + + // Even rays hit, odd rays miss + for (int i = 0; i < 16; ++i) { + origins(i, 0) = 5.0f; + if (i % 2 == 0) { + dirs(i, 0) = -1.0f; // toward cube + } else { + dirs(i, 0) = 1.0f; // away from cube + } + } + + auto result = caster.cast16(origins, dirs, size_t(16)); + for (int i = 0; i < 16; ++i) { + if (i % 2 == 0) { + REQUIRE(result.is_valid(i)); + } else { + REQUIRE(!result.is_valid(i)); + } + } + } +} + +TEST_CASE("RayCaster: closest_point16 packet", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + rc::RayCaster::Point16f queries = rc::RayCaster::Point16f::Zero(); + for (int i = 0; i < 16; ++i) { + queries(i, 0) = 2.0f; // All query points at (2,0,0) + } + + auto result = caster.closest_point16(queries, size_t(16)); + for (int i = 0; i < 16; ++i) { + REQUIRE(result.is_valid(i)); + REQUIRE(result.positions(0, i) == Catch::Approx(1.0f).margin(1e-4f)); + REQUIRE(result.positions(1, i) == Catch::Approx(0.0f).margin(1e-4f)); + REQUIRE(result.positions(2, i) == Catch::Approx(0.0f).margin(1e-4f)); + } +} + +TEST_CASE("RayCaster: multiple meshes", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube1 = create_cube_surface_mesh(); + auto cube2 = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + + // Place cube1 at identity (centered at origin, extent [-1,1]^3) + auto mesh_id1 = caster.add_mesh(std::move(cube1)); + + // Place cube2 translated to (10,0,0) + Eigen::Transform t2 = + Eigen::Transform::Identity(); + t2.translate(Eigen::Vector3d(10, 0, 0)); + auto mesh_id2 = caster.add_mesh(std::move(cube2), std::make_optional(t2)); + + caster.commit_updates(); + + SECTION("hit first cube") + { + auto hit = caster.cast(Eigen::Vector3f(-5, 0, 0), Eigen::Vector3f(1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->mesh_index == mesh_id1); + } + + SECTION("hit second cube") + { + auto hit = caster.cast(Eigen::Vector3f(15, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->mesh_index == mesh_id2); + REQUIRE(hit->position.x() == Catch::Approx(11.0f).margin(1e-3f)); + } +} + +TEST_CASE("RayCaster: multiple instances", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + + // Add a single mesh with identity transform + auto mesh_id = caster.add_mesh(std::move(cube)); + + // Add a second instance translated to (10,0,0) + Eigen::Affine3f t2 = Eigen::Affine3f::Identity(); + t2.translate(Eigen::Vector3f(10, 0, 0)); + auto instance_id = caster.add_instance(mesh_id, t2); + + caster.commit_updates(); + + SECTION("hit original instance") + { + auto hit = caster.cast(Eigen::Vector3f(-5, 0, 0), Eigen::Vector3f(1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->mesh_index == mesh_id); + REQUIRE(hit->instance_index == 0); + } + + SECTION("hit second instance") + { + auto hit = caster.cast(Eigen::Vector3f(15, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->mesh_index == mesh_id); + REQUIRE(hit->instance_index == instance_id); + } + + SECTION("get transform") + { + auto tr = caster.get_transform(mesh_id, instance_id); + REQUIRE(tr.translation().x() == Catch::Approx(10.0f).margin(1e-6f)); + } +} + +TEST_CASE("RayCaster: visibility", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + auto mesh_id = caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + SECTION("visible by default") + { + REQUIRE(caster.get_visibility(mesh_id, 0)); + auto hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + } + + SECTION("hidden instance") + { + caster.update_visibility(mesh_id, 0, false); + caster.commit_updates(); + + REQUIRE(!caster.get_visibility(mesh_id, 0)); + auto hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(!hit.has_value()); + } + + SECTION("re-show instance") + { + caster.update_visibility(mesh_id, 0, false); + caster.commit_updates(); + REQUIRE(!caster.get_visibility(mesh_id, 0)); + + caster.update_visibility(mesh_id, 0, true); + caster.commit_updates(); + REQUIRE(caster.get_visibility(mesh_id, 0)); + + auto hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + } +} + +TEST_CASE("RayCaster: update_vertices", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + auto mesh_id = caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + // Initially should hit + auto hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->position.x() == Catch::Approx(1.0f).margin(1e-4f)); + + // Update mesh: scale cube by 2x + auto scaled_cube = create_cube_surface_mesh(); + { + auto V = lagrange::vertex_ref(scaled_cube); + V *= 2.0; + } + caster.update_vertices(mesh_id, scaled_cube); + caster.commit_updates(); + + // Now should hit at x=2 + hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->position.x() == Catch::Approx(2.0f).margin(1e-3f)); +} + +TEST_CASE("RayCaster: intersection filter", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + // Filters require SceneFlags::Filter to be set. + rc::RayCaster caster( + lagrange::BitField(rc::SceneFlags::Robust) | lagrange::BitField(rc::SceneFlags::Filter), + rc::BuildQuality::High); + auto mesh_id = caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + // Without filter, should hit + auto hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + + // Set a filter that rejects all hits + caster.set_intersection_filter( + mesh_id, + [](uint32_t /*instance_index*/, uint32_t /*facet_index*/) { return false; }); + + hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(!hit.has_value()); + + // Remove filter + caster.set_intersection_filter(mesh_id, {}); + + hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); +} + +TEST_CASE("RayCaster: occlusion filter", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + // Filters require SceneFlags::Filter to be set. + rc::RayCaster caster( + lagrange::BitField(rc::SceneFlags::Robust) | lagrange::BitField(rc::SceneFlags::Filter), + rc::BuildQuality::High); + auto mesh_id = caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + // Without filter, should be occluded + REQUIRE(caster.occluded(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0))); + + // Set filter that rejects all hits + caster.set_occlusion_filter(mesh_id, [](uint32_t /*instance_index*/, uint32_t /*facet_index*/) { + return false; + }); + + REQUIRE(!caster.occluded(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0))); + + // Remove filter + caster.set_occlusion_filter(mesh_id, {}); + + REQUIRE(caster.occluded(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0))); +} + +TEST_CASE("RayCaster: update_transform", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + auto mesh_id = caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + // Initially should hit at x=1 + auto hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->position.x() == Catch::Approx(1.0f).margin(1e-4f)); + + // Move cube to (10,0,0) + Eigen::Affine3f t = Eigen::Affine3f::Identity(); + t.translate(Eigen::Vector3f(10, 0, 0)); + caster.update_transform(mesh_id, 0, t); + caster.commit_updates(); + + // Old position should miss + hit = caster.cast(Eigen::Vector3f(5, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(!hit.has_value()); + + // New position should hit at x=9 (10-1) + hit = caster.cast(Eigen::Vector3f(15, 0, 0), Eigen::Vector3f(-1, 0, 0)); + REQUIRE(hit.has_value()); + REQUIRE(hit->position.x() == Catch::Approx(11.0f).margin(1e-3f)); +} + +TEST_CASE("RayCaster: rotated directions", "[raycasting][RayCaster]") +{ + namespace rc = lagrange::raycasting; + + auto cube = create_cube_surface_mesh(); + + rc::RayCaster caster(rc::SceneFlags::Robust, rc::BuildQuality::High); + caster.add_mesh(std::move(cube)); + caster.commit_updates(); + + const int order = 20; + int hit_count = 0; + int total = 0; + const Eigen::Vector3f axis = Eigen::Vector3f(1, 1, 1).normalized(); + + for (int k = 0; k <= order; ++k) { + float angle = float(k) / float(order) * 2.f * float(lagrange::internal::pi); + Eigen::Affine3f t(Eigen::AngleAxisf(angle, axis)); + caster.update_transform(0, 0, t); + caster.commit_updates(); + + for (int i = 0; i <= order; ++i) { + float theta = float(i) / float(order) * 2.f * float(lagrange::internal::pi); + for (int j = 0; j <= order; ++j) { + float phi = float(j) / float(order) * float(lagrange::internal::pi) - + float(lagrange::internal::pi) * 0.5f; + Eigen::Vector3f dir( + std::cos(phi) * std::cos(theta), + std::cos(phi) * std::sin(theta), + std::sin(phi)); + + auto hit = caster.cast(Eigen::Vector3f::Zero(), dir); + if (hit.has_value()) { + hit_count++; + } + total++; + } + } + } + + REQUIRE(hit_count == total); +} diff --git a/modules/raycasting/tests/test_benchmark_closest_vertex.cpp b/modules/raycasting/tests/test_benchmark_closest_vertex.cpp new file mode 100644 index 00000000..eb022ac7 --- /dev/null +++ b/modules/raycasting/tests/test_benchmark_closest_vertex.cpp @@ -0,0 +1,175 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +// clang-format off +#include +#include +#include +// clang-format on + +#include + +namespace { + +using Scalar = double; +using Index = uint32_t; +using MeshType = lagrange::SurfaceMesh; + +/// Create a source triangle mesh with a vertex attribute "color". Uses the hemisphere test asset +/// which has a moderate number of vertices and triangles. +MeshType create_source_mesh() +{ + auto mesh = lagrange::testing::load_surface_mesh("open/core/dragon.obj"); + + auto V = lagrange::vertex_view(mesh); + const Index nv = mesh.get_num_vertices(); + std::vector values(static_cast(nv) * 3); + for (Index v = 0; v < nv; ++v) { + // Use normalized position as a synthetic color attribute. + Eigen::RowVector3 p = V.row(v).normalized(); + values[3 * v + 0] = p.x(); + values[3 * v + 1] = p.y(); + values[3 * v + 2] = p.z(); + } + mesh.template create_attribute( + "color", + lagrange::AttributeElement::Vertex, + 3, + lagrange::AttributeUsage::Color, + {values.data(), values.size()}); + + return mesh; +} + +/// Create a target mesh by perturbing vertex positions of the source. +MeshType create_target_mesh(const MeshType& source, double perturbation = 0.01) +{ + MeshType target = source; + auto V = lagrange::vertex_ref(target); + + std::mt19937 gen(42); + std::uniform_real_distribution dist(-perturbation, perturbation); + for (Index i = 0; i < target.get_num_vertices(); ++i) { + for (int c = 0; c < 3; ++c) { + V(i, c) += static_cast(dist(gen)); + } + } + + // Remove the "color" attribute from the target so it can be projected. + if (target.has_attribute("color")) { + target.delete_attribute("color"); + } + + return target; +} + +/// Nanoflann-based closest-vertex attribute transfer (the legacy approach). +/// +/// Builds a kd-tree over the source mesh vertices, then for each target vertex finds the closest +/// source vertex and copies the attribute value. +void nanoflann_closest_vertex( + const MeshType& source, + MeshType& target, + lagrange::AttributeId src_attr_id) +{ + using VertexMatrix = Eigen::Matrix; + using BVH = lagrange::bvh::BVHNanoflann; + + // Extract source vertices into an Eigen matrix for Nanoflann. + auto src_V = lagrange::vertex_view(source); + VertexMatrix src_vertices = src_V; + + auto engine = std::make_unique(); + engine->build(src_vertices); + + const auto& src_attr = source.template get_attribute(src_attr_id); + size_t num_channels = src_attr.get_num_channels(); + auto src_span = src_attr.get_all(); + + // Create target attribute. + lagrange::AttributeId dst_attr_id = lagrange::invalid(); + if (target.has_attribute("color")) { + dst_attr_id = target.get_attribute_id("color"); + } else { + dst_attr_id = target.template create_attribute( + "color", + lagrange::AttributeElement::Vertex, + num_channels, + lagrange::AttributeUsage::Color); + } + auto& dst_attr = target.template ref_attribute(dst_attr_id); + auto dst_span = dst_attr.ref_all(); + + auto tgt_V = lagrange::vertex_view(target); + + tbb::parallel_for(Index(0), target.get_num_vertices(), [&](Index i) { + Eigen::RowVector p = tgt_V.row(i); + auto res = engine->query_closest_point(p); + + size_t src_offset = static_cast(res.closest_vertex_idx) * num_channels; + size_t dst_offset = static_cast(i) * num_channels; + for (size_t c = 0; c < num_channels; ++c) { + dst_span[dst_offset + c] = src_span[src_offset + c]; + } + }); +} + +} // namespace + +TEST_CASE("Closest Vertex Benchmark", "[raycasting][!benchmark]") +{ + auto source = create_source_mesh(); + auto src_attr_id = source.get_attribute_id("color"); + const Index nv = source.get_num_vertices(); + + INFO("Source mesh: " << nv << " vertices, " << source.get_num_facets() << " facets"); + + // Pre-build the Embree ray caster (construction time is excluded from the benchmark). + lagrange::raycasting::RayCaster caster( + lagrange::raycasting::SceneFlags::Robust, + lagrange::raycasting::BuildQuality::High); + { + MeshType source_copy = source; + caster.add_mesh(std::move(source_copy)); + caster.commit_updates(); + } + + BENCHMARK("Embree closest_vertex") + { + auto target = create_target_mesh(source); + lagrange::raycasting::ProjectCommonOptions opts; + opts.attribute_ids = {src_attr_id}; + opts.project_vertices = false; + lagrange::raycasting::project_closest_vertex(source, target, opts, &caster); + return target.get_num_vertices(); + }; + + BENCHMARK("Nanoflann closest_vertex") + { + auto target = create_target_mesh(source); + nanoflann_closest_vertex(source, target, src_attr_id); + return target.get_num_vertices(); + }; +} diff --git a/modules/raycasting/tests/test_benchmark_raycasting.cpp b/modules/raycasting/tests/test_benchmark_raycasting.cpp new file mode 100644 index 00000000..36c92835 --- /dev/null +++ b/modules/raycasting/tests/test_benchmark_raycasting.cpp @@ -0,0 +1,274 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include +#include +#include + +#include +#include + +namespace { + +using Scalar = double; +using Index = uint32_t; + +/// +/// Ambient occlusion via the legacy EmbreeRayCaster (single-ray). +/// +template +size_t legacy_ao_single(RayCasterPtr& caster, LegacyMesh& mesh, const Eigen::MatrixXd& directions) +{ + using RC = typename RayCasterPtr::element_type; + using RCScalar = typename RC::Scalar; + using Point = typename RC::Point; + using Direction = typename RC::Direction; + + std::atomic_size_t hit_counter(0); + const int nv = static_cast(mesh.get_num_vertices()); + tbb::parallel_for(0, nv, [&](int v) { + Point origin = mesh.get_vertices().row(v).transpose().template cast(); + for (int d = 0; d < directions.rows(); ++d) { + Direction dir = directions.row(d).transpose().template cast(); + size_t mesh_index, instance_index, facet_index; + RCScalar ray_depth; + Point bc, normal; + bool hit = caster->cast( + origin, + dir, + mesh_index, + instance_index, + facet_index, + ray_depth, + bc, + normal); + if (hit) ++hit_counter; + } + }); + return hit_counter; +} + +/// +/// Ambient occlusion via the legacy EmbreeRayCaster (4-packed). +/// +template +size_t legacy_ao_pack4(RayCasterPtr& caster, LegacyMesh& mesh, const Eigen::MatrixXd& directions) +{ + using RC = typename RayCasterPtr::element_type; + using RCScalar = typename RC::Scalar; + using Point4 = typename RC::Point4; + using Direction4 = typename RC::Direction4; + using Index4 = typename RC::Index4; + using Scalar4 = typename RC::Scalar4; + using Mask4 = typename RC::Mask4; + + auto popcount4 = [](uint32_t x) -> size_t { + size_t c = 0; + for (int i = 0; i < 4; ++i) + if (x & (1u << i)) ++c; + return c; + }; + + std::atomic_size_t hit_counter(0); + const int nv = static_cast(mesh.get_num_vertices()); + const int nd = static_cast(directions.rows()); + + tbb::parallel_for(0, nv, [&](int v) { + Point4 origins; + origins.row(0) = mesh.get_vertices().row(v).template cast(); + for (int i = 1; i < 4; ++i) origins.row(i) = origins.row(0); + Mask4 mask = Mask4::Constant(-1); + + for (int d = 0; d < nd; d += 4) { + const int batch = std::min(nd - d, 4); + Direction4 dirs; + for (int i = 0; i < batch; ++i) + dirs.row(i) = directions.row(d + i).template cast(); + + auto active = mask; + for (int i = batch; i < 4; ++i) active(i) = 0; + + Index4 mesh_indices, instance_indices, facet_indices; + Scalar4 ray_depths; + Point4 bc, normal; + uint32_t hits = caster->cast4( + static_cast(batch), + origins, + dirs, + active, + mesh_indices, + instance_indices, + facet_indices, + ray_depths, + bc, + normal); + if (hits) hit_counter += popcount4(hits); + } + }); + return hit_counter; +} + +/// +/// Ambient occlusion via the new RayCaster (single-ray). +/// +size_t new_ao_single( + const lagrange::raycasting::RayCaster& caster, + const lagrange::SurfaceMesh& mesh, + const Eigen::MatrixXd& directions) +{ + std::atomic_size_t hit_counter(0); + const auto V = lagrange::vertex_view(mesh); + const int nv = static_cast(mesh.get_num_vertices()); + tbb::parallel_for(0, nv, [&](int v) { + Eigen::Vector3f origin = V.row(v).transpose().template cast(); + for (int d = 0; d < directions.rows(); ++d) { + Eigen::Vector3f dir = directions.row(d).transpose().template cast(); + auto hit = caster.cast(origin, dir); + if (hit) ++hit_counter; + } + }); + return hit_counter; +} + +/// +/// Ambient occlusion via the new RayCaster (4-packed). +/// +template +size_t new_ao_pack( + const lagrange::raycasting::RayCaster& caster, + const lagrange::SurfaceMesh& mesh, + const Eigen::MatrixXd& directions) +{ + using PointNf = Eigen::Matrix; + using DirectionNf = Eigen::Matrix; + + auto popcountN = [](uint32_t x) -> size_t { + size_t c = 0; + for (size_t i = 0; i < N; ++i) + if (x & (1u << i)) ++c; + return c; + }; + + std::atomic_size_t hit_counter(0); + const auto V = lagrange::vertex_view(mesh); + const size_t nv = static_cast(mesh.get_num_vertices()); + const size_t nd = static_cast(directions.rows()); + + tbb::parallel_for(size_t(0), nv, [&](size_t v) { + Eigen::Vector3f origin = V.row(v).transpose().template cast(); + + for (size_t d = 0; d < nd; d += N) { + const size_t batch = std::min(nd - d, N); + PointNf origins; + DirectionNf dirs; + for (size_t i = 0; i < N; ++i) origins.row(i) = origin.transpose(); + for (size_t i = 0; i < batch; ++i) + dirs.row(i) = directions.row(d + i).template cast(); + for (size_t i = batch; i < N; ++i) dirs.row(i).setZero(); + + if constexpr (N == 4) { + auto result = caster.cast4(origins, dirs, static_cast(batch)); + if (result.valid_mask) hit_counter += popcountN(result.valid_mask); + } else if constexpr (N == 16) { + auto result = caster.cast16(origins, dirs, static_cast(batch)); + if (result.valid_mask) hit_counter += popcountN(result.valid_mask); + } + } + }); + return hit_counter; +} + +} // namespace + +TEST_CASE("Raycasting Benchmark", "[raycasting][!benchmark]") +{ + const int num_samples = 32; + Eigen::MatrixXd directions = igl::random_dir_stratified(num_samples); + + // Load the dragon mesh for both APIs. + auto legacy_mesh = lagrange::to_shared_ptr( + lagrange::testing::load_mesh("open/core/dragon.obj")); + auto surface_mesh = lagrange::testing::load_surface_mesh("open/core/dragon.obj"); + + INFO( + "Dragon mesh: " << surface_mesh.get_num_vertices() << " vertices, " + << surface_mesh.get_num_facets() << " facets"); + + using namespace lagrange::raycasting; + + // ------------------------------------------------------------------------- + // Build the legacy EmbreeRayCaster (robust, high quality) + // ------------------------------------------------------------------------- + auto legacy_caster = create_ray_caster(EMBREE_ROBUST, BUILD_QUALITY_HIGH); + legacy_caster->add_mesh(legacy_mesh, Eigen::Matrix4f::Identity()); + // Trigger scene build with a dummy cast. + legacy_caster->cast(Eigen::Vector3f(0, 0, 0), Eigen::Vector3f(0, 0, 1)); + + // ------------------------------------------------------------------------- + // Build the new RayCaster (robust, high quality) + // ------------------------------------------------------------------------- + RayCaster new_caster(SceneFlags::Robust, BuildQuality::High); + { + auto mesh_copy = surface_mesh; // keep original for vertex access + new_caster.add_mesh(std::move(mesh_copy)); + new_caster.commit_updates(); + } + + // ------------------------------------------------------------------------- + // Single-ray benchmarks + // ------------------------------------------------------------------------- + BENCHMARK("Legacy EmbreeRayCaster (single ray)") + { + return legacy_ao_single(legacy_caster, *legacy_mesh, directions); + }; + + BENCHMARK("New RayCaster (single ray)") + { + return new_ao_single(new_caster, surface_mesh, directions); + }; + + // ------------------------------------------------------------------------- + // 4-packed ray benchmarks + // ------------------------------------------------------------------------- + BENCHMARK("Legacy EmbreeRayCaster (4-packed)") + { + return legacy_ao_pack4(legacy_caster, *legacy_mesh, directions); + }; + + BENCHMARK("New RayCaster (4-packed)") + { + return new_ao_pack<4>(new_caster, surface_mesh, directions); + }; + + // ------------------------------------------------------------------------- + // 16-packed ray benchmarks + // ------------------------------------------------------------------------- + BENCHMARK("New RayCaster (16-packed)") + { + return new_ao_pack<16>(new_caster, surface_mesh, directions); + }; +} diff --git a/modules/raycasting/tests/test_project.cpp b/modules/raycasting/tests/test_project.cpp new file mode 100644 index 00000000..6fa0ea0f --- /dev/null +++ b/modules/raycasting/tests/test_project.cpp @@ -0,0 +1,409 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace { + +using Scalar = double; +using Index = uint32_t; +using MeshType = lagrange::SurfaceMesh; + +/// Load the hemisphere mesh as a SurfaceMesh and add a 2-channel vertex attribute "pos" containing +/// the xy coordinates of each vertex. +MeshType load_hemisphere_with_pos() +{ + auto mesh = lagrange::testing::load_surface_mesh("open/core/hemisphere.obj"); + + // Create a 2-channel vertex attribute "pos" = first 2 columns of vertex positions. + auto V = lagrange::vertex_view(mesh); + std::vector values(static_cast(mesh.get_num_vertices()) * 2); + for (Index v = 0; v < mesh.get_num_vertices(); ++v) { + values[2 * v + 0] = V(v, 0); + values[2 * v + 1] = V(v, 1); + } + mesh.template create_attribute( + "pos", + lagrange::AttributeElement::Vertex, + 2, + lagrange::AttributeUsage::Vector, + {values.data(), values.size()}); + + return mesh; +} + +/// Perturb vertex positions of a SurfaceMesh. If z_only is true, only z coordinates are perturbed. +MeshType perturb_mesh(const MeshType& mesh, double s, bool z_only = false) +{ + MeshType result = mesh; // shallow copy, will deep-copy on write + + auto V = lagrange::vertex_ref(result); + + // Compute minimum edge length for scaling. + Scalar min_len = std::numeric_limits::max(); + for (Index f = 0; f < result.get_num_facets(); ++f) { + auto fv = result.get_facet_vertices(f); + for (Index lv = 0; lv < fv.size(); ++lv) { + auto p1 = V.row(fv[lv]); + auto p2 = V.row(fv[(lv + 1) % fv.size()]); + min_len = std::min(min_len, Scalar((p1 - p2).norm())); + } + } + + std::mt19937 gen; + std::uniform_real_distribution dist(0, s * min_len); + for (Index i = 0; i < result.get_num_vertices(); ++i) { + if (z_only) { + V(i, 2) += static_cast(dist(gen)); + } else { + for (int c = 0; c < 3; ++c) { + V(i, c) += static_cast(dist(gen)); + } + } + } + + // Remove the "pos" attribute if it exists (we only want vertex positions perturbed). + if (result.has_attribute("pos")) { + result.delete_attribute("pos"); + } + + return result; +} + +/// Create a copy of the mesh geometry (same vertices and facets) without any custom attributes. +MeshType copy_geometry(const MeshType& mesh) +{ + MeshType result; + auto V = lagrange::vertex_view(mesh); + result.add_vertices(mesh.get_num_vertices()); + auto V_out = lagrange::vertex_ref(result); + V_out = V; + + for (Index f = 0; f < mesh.get_num_facets(); ++f) { + auto fv = mesh.get_facet_vertices(f); + std::vector verts(fv.begin(), fv.end()); + result.add_polygon(verts); + } + + return result; +} + +} // namespace + +TEST_CASE("SurfaceMesh: project_directional", "[raycasting]") +{ + auto source = load_hemisphere_with_pos(); + auto pos_id = source.get_attribute_id("pos"); + + SECTION("perturbed") + { + auto target = perturb_mesh(source, 0.1); + + std::vector ishit(target.get_num_vertices(), true); + + lagrange::raycasting::ProjectDirectionalOptions options; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + options.direction = Eigen::Vector3f(0, 0, 1); + options.cast_mode = lagrange::raycasting::CastMode::BothWays; + options.fallback_mode = lagrange::raycasting::FallbackMode::Constant; + options.default_value = 0.0; + options.user_callback = [&](uint64_t v, bool hit) { ishit[v] = hit; }; + + lagrange::raycasting::project_directional(source, target, options); + + REQUIRE(target.has_attribute("pos")); + auto V = lagrange::vertex_view(target); + auto P = lagrange::attribute_matrix_view(target, "pos"); + REQUIRE(P.cols() == 2); + for (Index v = 0; v < target.get_num_vertices(); ++v) { + if (ishit[v]) { + REQUIRE((P.row(v) - V.row(v).head<2>()).norm() < 1e-7); + } + } + } + + SECTION("perturbed in z") + { + auto target = perturb_mesh(source, 0.1, true); + + std::atomic_bool all_hit(true); + + lagrange::raycasting::ProjectDirectionalOptions options; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + options.direction = Eigen::Vector3f(0, 0, 1); + options.cast_mode = lagrange::raycasting::CastMode::BothWays; + options.fallback_mode = lagrange::raycasting::FallbackMode::Constant; + options.user_callback = [&](uint64_t /*v*/, bool hit) { + if (!hit) all_hit = false; + }; + + lagrange::raycasting::project_directional(source, target, options); + + REQUIRE(all_hit); + REQUIRE(target.has_attribute("pos")); + auto V = lagrange::vertex_view(target); + auto P = lagrange::attribute_matrix_view(target, "pos"); + REQUIRE(P.cols() == 2); + for (Index v = 0; v < target.get_num_vertices(); ++v) { + REQUIRE((P.row(v) - V.row(v).head<2>()).norm() < 1e-7); + } + } + + SECTION("exact copy") + { + auto target = copy_geometry(source); + + std::atomic_bool all_hit(true); + + lagrange::raycasting::ProjectDirectionalOptions options; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + options.direction = Eigen::Vector3f(0, 0, 1); + options.cast_mode = lagrange::raycasting::CastMode::BothWays; + options.fallback_mode = lagrange::raycasting::FallbackMode::Constant; + options.user_callback = [&](uint64_t /*v*/, bool hit) { + if (!hit) all_hit = false; + }; + + lagrange::raycasting::project_directional(source, target, options); + + REQUIRE(all_hit); + REQUIRE(target.has_attribute("pos")); + auto V = lagrange::vertex_view(target); + auto P = lagrange::attribute_matrix_view(target, "pos"); + REQUIRE(P.cols() == 2); + for (Index v = 0; v < target.get_num_vertices(); ++v) { + CAPTURE(v, P.row(v), V.row(v)); + REQUIRE((P.row(v) - V.row(v).head<2>()).norm() < 1e-7); + } + } +} + +//////////////////////////////////////////////////////////////////////////////// + +TEST_CASE("SurfaceMesh: project_directional closest vertex fallback", "[raycasting]") +{ + auto source = load_hemisphere_with_pos(); + auto pos_id = source.get_attribute_id("pos"); + + // Perturb the mesh so that some vertices are not hit by the directional cast. + // Using a direction perpendicular to the z-axis ensures partial misses for a hemisphere. + auto target = perturb_mesh(source, 0.1); + + lagrange::raycasting::ProjectDirectionalOptions options; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + options.direction = Eigen::Vector3f(0, 0, 1); + options.cast_mode = lagrange::raycasting::CastMode::BothWays; + options.fallback_mode = lagrange::raycasting::FallbackMode::ClosestVertex; + + lagrange::raycasting::project_directional(source, target, options); + + REQUIRE(target.has_attribute("pos")); + auto P = lagrange::attribute_matrix_view(target, "pos"); + REQUIRE(P.cols() == 2); + + // Every vertex should now have a non-zero attribute value from the fallback. + // Verify that the attribute was actually written (not left as zero). + auto source_pos = lagrange::attribute_matrix_view(source, "pos"); + Scalar src_min = source_pos.minCoeff(); + Scalar src_max = source_pos.maxCoeff(); + for (Index v = 0; v < target.get_num_vertices(); ++v) { + // Each projected channel should lie within the source attribute range. + for (int c = 0; c < 2; ++c) { + REQUIRE(P(v, c) >= src_min - 1e-10); + REQUIRE(P(v, c) <= src_max + 1e-10); + } + } +} + +//////////////////////////////////////////////////////////////////////////////// + +TEST_CASE("SurfaceMesh: project_closest_point", "[raycasting]") +{ + auto source = load_hemisphere_with_pos(); + auto pos_id = source.get_attribute_id("pos"); + + SECTION("perturbed") + { + auto target = perturb_mesh(source, 0.1); + + lagrange::raycasting::ProjectCommonOptions options; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + lagrange::raycasting::project_closest_point(source, target, options); + + REQUIRE(target.has_attribute("pos")); + auto P = lagrange::attribute_matrix_view(target, "pos"); + REQUIRE(P.cols() == 2); + } + + SECTION("exact copy") + { + auto target = copy_geometry(source); + + lagrange::raycasting::ProjectCommonOptions options; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + lagrange::raycasting::project_closest_point(source, target, options); + + REQUIRE(target.has_attribute("pos")); + auto V = lagrange::vertex_view(target); + auto P = lagrange::attribute_matrix_view(target, "pos"); + REQUIRE(P.cols() == 2); + for (Index v = 0; v < target.get_num_vertices(); ++v) { + CAPTURE(v, P.row(v), V.row(v)); + REQUIRE((P.row(v) - V.row(v).head<2>()).norm() < 1e-15); + } + } +} + +//////////////////////////////////////////////////////////////////////////////// + +TEST_CASE("SurfaceMesh: project reproducibility", "[raycasting]") +{ + auto source = load_hemisphere_with_pos(); + auto pos_id = source.get_attribute_id("pos"); + + using ProjectMode = lagrange::raycasting::ProjectMode; + + for (auto proj : {ProjectMode::ClosestPoint, ProjectMode::RayCasting}) { + auto target1 = perturb_mesh(source, 0.1); + auto target2 = perturb_mesh(source, 0.1); + + // Same seed => same perturbation + auto V1 = lagrange::vertex_view(target1); + auto V2 = lagrange::vertex_view(target2); + REQUIRE(V1 == V2); + + lagrange::raycasting::ProjectOptions options; + options.project_mode = proj; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + options.direction = Eigen::Vector3f(0, 0, 1); + + lagrange::raycasting::project(source, target1, options); + lagrange::raycasting::project(source, target2, options); + + auto P1 = lagrange::attribute_matrix_view(target1, "pos"); + auto P2 = lagrange::attribute_matrix_view(target2, "pos"); + REQUIRE(P1 == P2); + } +} + +//////////////////////////////////////////////////////////////////////////////// + +TEST_CASE("SurfaceMesh: project with external RayCaster", "[raycasting]") +{ + auto source = load_hemisphere_with_pos(); + auto pos_id = source.get_attribute_id("pos"); + + // Build a RayCaster externally + lagrange::raycasting::RayCaster rc( + lagrange::raycasting::SceneFlags::Robust, + lagrange::raycasting::BuildQuality::High); + { + MeshType source_copy = source; + rc.add_mesh(std::move(source_copy)); + } + rc.commit_updates(); + + auto target = copy_geometry(source); + + // Use the external RayCaster + lagrange::raycasting::ProjectDirectionalOptions options; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + options.direction = Eigen::Vector3f(0, 0, 1); + options.cast_mode = lagrange::raycasting::CastMode::BothWays; + + lagrange::raycasting::project_directional(source, target, options, &rc); + + REQUIRE(target.has_attribute("pos")); + auto V = lagrange::vertex_view(target); + auto P = lagrange::attribute_matrix_view(target, "pos"); + for (Index v = 0; v < target.get_num_vertices(); ++v) { + REQUIRE((P.row(v) - V.row(v).head<2>()).norm() < 1e-7); + } +} + +//////////////////////////////////////////////////////////////////////////////// + +TEST_CASE("SurfaceMesh: project_directional with vertex normals", "[raycasting]") +{ + auto source = load_hemisphere_with_pos(); + auto pos_id = source.get_attribute_id("pos"); + + SECTION("monostate direction (auto-compute normals)") + { + auto target = copy_geometry(source); + + lagrange::raycasting::ProjectDirectionalOptions options; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + // direction defaults to monostate: auto-compute vertex normals on target + options.cast_mode = lagrange::raycasting::CastMode::BothWays; + options.fallback_mode = lagrange::raycasting::FallbackMode::Constant; + + lagrange::raycasting::project_directional(source, target, options); + + REQUIRE(target.has_attribute("pos")); + auto P = lagrange::attribute_matrix_view(target, "pos"); + REQUIRE(P.cols() == 2); + // Since target is a copy of source, all vertices should get a hit via vertex normals. + auto V = lagrange::vertex_view(target); + for (Index v = 0; v < target.get_num_vertices(); ++v) { + REQUIRE((P.row(v) - V.row(v).head<2>()).norm() < 1e-5); + } + } + + SECTION("per-vertex direction attribute") + { + auto target = copy_geometry(source); + + // Compute vertex normals on the target mesh and pass as direction. + auto normal_id = lagrange::compute_vertex_normal(target); + + lagrange::raycasting::ProjectDirectionalOptions options; + options.attribute_ids = {pos_id}; + options.project_vertices = false; + options.direction = normal_id; + options.cast_mode = lagrange::raycasting::CastMode::BothWays; + options.fallback_mode = lagrange::raycasting::FallbackMode::Constant; + + lagrange::raycasting::project_directional(source, target, options); + + REQUIRE(target.has_attribute("pos")); + auto P = lagrange::attribute_matrix_view(target, "pos"); + REQUIRE(P.cols() == 2); + auto V = lagrange::vertex_view(target); + for (Index v = 0; v < target.get_num_vertices(); ++v) { + REQUIRE((P.row(v) - V.row(v).head<2>()).norm() < 1e-5); + } + } +} diff --git a/modules/raycasting/tests/test_project_attributes.cpp b/modules/raycasting/tests/test_project_attributes.cpp index a071fed8..e99c2df8 100644 --- a/modules/raycasting/tests/test_project_attributes.cpp +++ b/modules/raycasting/tests/test_project_attributes.cpp @@ -129,6 +129,7 @@ TEST_CASE("project_attributes_directional", "[raycasting]") // Not a bool since we write to this guy in parallel std::vector ishit(target->get_num_vertices(), true); + LA_IGNORE_DEPRECATION_WARNING_BEGIN lagrange::raycasting::project_attributes_directional( *source, *target, @@ -138,6 +139,7 @@ TEST_CASE("project_attributes_directional", "[raycasting]") lagrange::raycasting::WrapMode::CONSTANT, 0, [&](int v, bool hit) { ishit[v] = hit; }); + LA_IGNORE_DEPRECATION_WARNING_END target->has_vertex_attribute("pos"); const auto& V = target->get_vertices(); const auto& P = target->get_vertex_attribute("pos"); @@ -154,6 +156,7 @@ TEST_CASE("project_attributes_directional", "[raycasting]") auto target = perturb_mesh(*source, 0.1, true); std::atomic_bool all_hit(true); + LA_IGNORE_DEPRECATION_WARNING_BEGIN lagrange::raycasting::project_attributes_directional( *source, *target, @@ -165,6 +168,7 @@ TEST_CASE("project_attributes_directional", "[raycasting]") [&](int /*v*/, bool hit) { if (!hit) all_hit = false; }); + LA_IGNORE_DEPRECATION_WARNING_END REQUIRE(all_hit); target->has_vertex_attribute("pos"); const auto& V = target->get_vertices(); @@ -180,6 +184,7 @@ TEST_CASE("project_attributes_directional", "[raycasting]") auto target = lagrange::create_mesh(source->get_vertices(), source->get_facets()); std::atomic_bool all_hit(true); + LA_IGNORE_DEPRECATION_WARNING_BEGIN lagrange::raycasting::project_attributes_directional( *source, *target, @@ -191,6 +196,7 @@ TEST_CASE("project_attributes_directional", "[raycasting]") [&](int /*v*/, bool hit) { if (!hit) all_hit = false; }); + LA_IGNORE_DEPRECATION_WARNING_END REQUIRE(all_hit); target->has_vertex_attribute("pos"); const auto& V = target->get_vertices(); @@ -286,8 +292,10 @@ TEST_CASE("project_attributes: reproducibility", "[raycasting]") using ProjectMode = lagrange::raycasting::ProjectMode; + LA_IGNORE_DEPRECATION_WARNING_BEGIN for (auto proj : {ProjectMode::CLOSEST_VERTEX, ProjectMode::CLOSEST_POINT, ProjectMode::RAY_CASTING}) { + LA_IGNORE_DEPRECATION_WARNING_END auto target1 = perturb_mesh(*source, 0.1); auto target2 = perturb_mesh(*source, 0.1); REQUIRE(source->get_vertices() != target2->get_vertices()); diff --git a/modules/remeshing_im/CMakeLists.txt b/modules/remeshing_im/CMakeLists.txt new file mode 100644 index 00000000..fffea1ab --- /dev/null +++ b/modules/remeshing_im/CMakeLists.txt @@ -0,0 +1,39 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# + +# 1. define module +lagrange_add_module() +if(LAGRANGE_TOPLEVEL_PROJECT) + set_target_properties(lagrange_remeshing_im PROPERTIES COMPILE_WARNING_AS_ERROR ON) +endif() + +# 2. dependencies +include(instant-meshes-core) +target_link_libraries(lagrange_remeshing_im + PUBLIC + lagrange::core + PRIVATE + instant-meshes-core::instant-meshes-core +) + +# 3. unit tests and examples +if(LAGRANGE_UNIT_TESTS) + add_subdirectory(tests) +endif() + +#if(LAGRANGE_EXAMPLES) +# add_subdirectory(examples) +#endif() +# +if(LAGRANGE_MODULE_PYTHON) + add_subdirectory(python) +endif() diff --git a/modules/remeshing_im/include/lagrange/remeshing_im/api.h b/modules/remeshing_im/include/lagrange/remeshing_im/api.h new file mode 100644 index 00000000..af108c9b --- /dev/null +++ b/modules/remeshing_im/include/lagrange/remeshing_im/api.h @@ -0,0 +1,23 @@ +#pragma once + +#ifdef LA_REMESHING_IM_STATIC_DEFINE + #define LA_REMESHING_IM_API +#else + #ifndef LA_REMESHING_IM_API + #ifdef lagrange_remeshing_im_EXPORTS + // We are building this library + #if defined(_WIN32) || defined(_WIN64) + #define LA_REMESHING_IM_API __declspec(dllexport) + #else + #define LA_REMESHING_IM_API __attribute__((visibility("default"))) + #endif + #else + // We are using this library + #if defined(_WIN32) || defined(_WIN64) + #define LA_REMESHING_IM_API __declspec(dllimport) + #else + #define LA_REMESHING_IM_API __attribute__((visibility("default"))) + #endif + #endif + #endif +#endif diff --git a/modules/remeshing_im/include/lagrange/remeshing_im/remesh.h b/modules/remeshing_im/include/lagrange/remeshing_im/remesh.h new file mode 100644 index 00000000..81ebba65 --- /dev/null +++ b/modules/remeshing_im/include/lagrange/remeshing_im/remesh.h @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include + +namespace lagrange::remeshing_im { + +/// +/// Positional symmetry type. +/// +enum class PosyType : uint8_t { Triangle = 3, Quad = 4 }; + +/// +/// Rotational symmetry type. +/// +enum class RosyType : uint8_t { Line = 2, Cross = 4, Hex = 6 }; + +/// +/// Options for the remeshing process. +/// +struct RemeshingOptions +{ + /// Target number of facets in the remeshed output. + size_t target_num_facets = 1000; + + /// If true, the remeshing process will be deterministic. + bool deterministic = false; + + /// Number of nearest neighbors to use when processing point clouds. + size_t knn_points = 1000; + + /// Positional symmetry type. Options: Triangle (3), Quad (4) + PosyType posy = PosyType::Quad; + + /// Rotational symmetry type. Options: Line (2), Cross (4), Hex (6) + RosyType rosy = RosyType::Cross; + + /// Crease angle in degrees. Edges with dihedral angle above this value will be treated as + /// creases. + float crease_angle = 0.f; + + /// Whether to align the remeshed output to the input mesh boundaries. + bool align_to_boundaries = false; + + /// Use extrinsic metrics when aligning cross field. + bool extrinsic = true; + + /// Number of smoothing iterations. + size_t num_smooth_iter = 0; +}; + +/// +/// Remeshes the input surface mesh using the Instant Meshes algorithm. +/// +/// \tparam Scalar Scalar type of the mesh geometry. +/// \tparam Index Index type of the mesh connectivity. +/// +/// \param mesh Input surface mesh to be remeshed. This mesh may be modified in-place during +/// the remeshing process (for example, to compute auxiliary attributes or perform +/// preprocessing steps). +/// \param options Options controlling the remeshing process. +/// +/// \return A new surface mesh containing the remeshed output. +/// +template +SurfaceMesh remesh( + SurfaceMesh& mesh, + const RemeshingOptions& options = {}); + +} // namespace lagrange::remeshing_im diff --git a/modules/remeshing_im/python/CMakeLists.txt b/modules/remeshing_im/python/CMakeLists.txt new file mode 100644 index 00000000..884be284 --- /dev/null +++ b/modules/remeshing_im/python/CMakeLists.txt @@ -0,0 +1,12 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +lagrange_add_python_binding() diff --git a/modules/remeshing_im/python/include/lagrange/python/remeshing_im.h b/modules/remeshing_im/python/include/lagrange/python/remeshing_im.h new file mode 100644 index 00000000..0c69a791 --- /dev/null +++ b/modules/remeshing_im/python/include/lagrange/python/remeshing_im.h @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include + +namespace lagrange::python { +void populate_remeshing_im_module(nanobind::module_& m); +} diff --git a/modules/remeshing_im/python/scripts/remesh_im.py b/modules/remeshing_im/python/scripts/remesh_im.py new file mode 100755 index 00000000..503015eb --- /dev/null +++ b/modules/remeshing_im/python/scripts/remesh_im.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python + +"""Remesh a triangle mesh into quad mesh using Lagrange Instant Meshes.""" + +import argparse +import lagrange + + +def parse_args(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--quad", action="store_true", help="Generate quad mesh") + parser.add_argument( + "--num-facets", + "-n", + type=int, + default=5000, + help="Number of facets to generate", + ) + parser.add_argument("input_mesh", help="The input mesh file") + parser.add_argument("output_mesh", help="The output mesh file") + return parser.parse_args() + + +def main(): + args = parse_args() + mesh = lagrange.io.load_mesh(args.input_mesh, stitch_vertices=True) + if mesh.is_hybrid: + lagrange.triangulate_polygonal_facets(mesh) + if args.quad: + mesh = lagrange.remeshing_im.remesh(mesh, target_num_facets=args.num_facets) + assert mesh.is_quad_mesh + else: + mesh = lagrange.remeshing_im.remesh(mesh, target_num_facets=args.num_facets, rosy=6, posy=3) + assert mesh.is_triangle_mesh + lagrange.io.save_mesh(args.output_mesh, mesh) + + +if __name__ == "__main__": + main() diff --git a/modules/remeshing_im/python/src/remeshing_im.cpp b/modules/remeshing_im/python/src/remeshing_im.cpp new file mode 100644 index 00000000..9dd4548d --- /dev/null +++ b/modules/remeshing_im/python/src/remeshing_im.cpp @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include +#include +#include + +namespace nb = nanobind; +using namespace nb::literals; + +namespace lagrange::python { + +void populate_remeshing_im_module(nb::module_& m) +{ + using Scalar = double; + using Index = uint32_t; + using MeshType = SurfaceMesh; + + const remeshing_im::RemeshingOptions defaults; + + m.def( + "remesh", + [](MeshType& mesh, + size_t target_num_facets, + bool deterministic, + size_t knn_points, + uint8_t posy, + uint8_t rosy, + float crease_angle, + bool align_to_boundaries, + bool extrinsic, + size_t num_smooth_iter) { + remeshing_im::RemeshingOptions options; + options.target_num_facets = target_num_facets; + options.deterministic = deterministic; + options.knn_points = knn_points; + options.posy = static_cast(posy); + options.rosy = static_cast(rosy); + options.crease_angle = crease_angle; + options.align_to_boundaries = align_to_boundaries; + options.extrinsic = extrinsic; + options.num_smooth_iter = num_smooth_iter; + return remeshing_im::remesh(mesh, options); + }, + "mesh"_a, + "target_num_facets"_a = defaults.target_num_facets, + "deterministic"_a = defaults.deterministic, + "knn_points"_a = defaults.knn_points, + "posy"_a = static_cast(defaults.posy), + "rosy"_a = static_cast(defaults.rosy), + "crease_angle"_a = defaults.crease_angle, + "align_to_boundaries"_a = defaults.align_to_boundaries, + "extrinsic"_a = defaults.extrinsic, + "num_smooth_iter"_a = defaults.num_smooth_iter, + R"(Remesh a surface mesh using Instant Meshes. + +:param mesh: Input mesh to remesh (triangle mesh or point cloud). +:param target_num_facets: Target number of facets in the output mesh. +:param deterministic: Whether to make the process deterministic. +:param knn_points: Number of nearest neighbors when remeshing point clouds. +:param posy: Positional symmetry type (3 for triangle, 4 for quad). +:param rosy: Rotational symmetry type (2 for line field, 4 for cross field, 6 for hex field). +:param crease_angle: Crease angle in degrees for detecting sharp edges. +:param align_to_boundaries: Whether to align output to input boundaries. +:param extrinsic: Whether to use extrinsic metrics for cross-field alignment. +:param num_smooth_iter: Number of smoothing iterations. + +:return: The remeshed surface mesh.)"); +} + +} // namespace lagrange::python diff --git a/modules/remeshing_im/remeshing_im.md b/modules/remeshing_im/remeshing_im.md new file mode 100644 index 00000000..f1d396d1 --- /dev/null +++ b/modules/remeshing_im/remeshing_im.md @@ -0,0 +1,15 @@ +Remeshing-Instant-Meshes Module +================================ + +@namespace lagrange::remeshing_im + +@defgroup module-remeshing_im Remeshing-Instant-Meshes Module +@brief This module provides a headless API to the Instant Meshes library. + +It exposes `lagrange::remeshing_im::remesh()`, which wraps the field-aligned +remeshing algorithm described in: + +> Wenzel Jakob, Marco Tarini, Daniele Panozzo, Olga Sorkine-Hornung. +> **Instant Field-Aligned Meshes.** +> *ACM Transactions on Graphics (Proc. SIGGRAPH Asia)*, 34(6), 2015. +> DOI: [10.1145/2816795.2818078](https://doi.org/10.1145/2816795.2818078) diff --git a/modules/remeshing_im/src/remesh.cpp b/modules/remeshing_im/src/remesh.cpp new file mode 100644 index 00000000..c60cdb01 --- /dev/null +++ b/modules/remeshing_im/src/remesh.cpp @@ -0,0 +1,247 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// clang-format on + +#include +#include +#include + +namespace lagrange::remeshing_im { + +template +SurfaceMesh remesh(SurfaceMesh& mesh, const RemeshingOptions& options) +{ + using namespace ::instant_meshes; + + la_runtime_assert(mesh.is_triangle_mesh(), "Input mesh must be triangular."); + const auto rosy = static_cast(options.rosy); + const auto posy = static_cast(options.posy); + la_runtime_assert( + rosy == 2 || rosy == 4 || rosy == 6, + "Only 2-RoSy, 4-RoSy and 6-rosy fields are supported."); + la_runtime_assert(posy == 3 || posy == 4, "Only 3-PoSy and 4-PoSy fields are supported."); + + MatrixXf V = vertex_view(mesh).template cast().transpose(); + MatrixXu F = facet_view(mesh).template cast().transpose(); + bool pointcloud = F.size() == 0; + std::set crease_in, crease_out; + + auto normal_attr_id = compute_facet_normal(mesh); + MatrixXf N = + attribute_matrix_view(mesh, normal_attr_id).template cast().transpose(); + + MeshStats stats = compute_mesh_stats(F, V, options.deterministic); + AdjacencyMatrix adj = nullptr; + + std::shared_ptr bvh; + VectorXf A; + if (pointcloud) { + bvh = std::make_shared(&F, &V, &N, stats.mAABB); + bvh->build(); + adj = generate_adjacency_matrix_pointcloud( + V, + N, + bvh.get(), + stats, + static_cast(options.knn_points), + options.deterministic); + A.resize(V.cols()); + A.setConstant(1.0f); + } + + size_t face_count = options.target_num_facets; + float face_area = stats.mSurfaceArea / face_count; + size_t vertex_count = posy == 4 ? face_count : (face_count / 2); + float scale = + posy == 4 ? std::sqrt(face_area) : (2 * std::sqrt(face_area * std::sqrt(1.f / 3.f))); + + logger().info( + "Remeshing target: {} vertices, {} facets, {} edge length", + vertex_count, + face_count, + scale); + + MultiResolutionHierarchy mRes; + auto _ = make_scope_guard([&]() { mRes.free(); }); + + if (!pointcloud) { + /* Subdivide the mesh if necessary */ + VectorXu V2E, E2E; + VectorXb boundary, nonManifold; + if (stats.mMaximumEdgeLength * 2 > scale || + stats.mMaximumEdgeLength > stats.mAverageEdgeLength * 2) { + logger().warn( + "Input mesh is too coarse for the desired output edge length " + "(max input mesh edge length=" + "{}), subdividing ..", + stats.mMaximumEdgeLength); + build_dedge(F, V, V2E, E2E, boundary, nonManifold); + subdivide( + F, + V, + V2E, + E2E, + boundary, + nonManifold, + std::min(scale / 2, (float)stats.mAverageEdgeLength * 2), + options.deterministic); + } + + /* Compute a directed edge data structure */ + build_dedge(F, V, V2E, E2E, boundary, nonManifold); + + /* Compute adjacency matrix */ + adj = generate_adjacency_matrix_uniform(F, V2E, E2E, nonManifold); + + /* Compute vertex/crease normals */ + if (options.crease_angle >= 0) + generate_crease_normals( + F, + V, + V2E, + E2E, + boundary, + nonManifold, + options.crease_angle, + N, + crease_in); + else + generate_smooth_normals(F, V, V2E, E2E, nonManifold, N); + + /* Compute dual vertex areas */ + compute_dual_vertex_areas(F, V, V2E, E2E, nonManifold, A); + + mRes.setE2E(std::move(E2E)); + } + + /* Build multi-resolution hierarrchy */ + mRes.setAdj(std::move(adj)); + mRes.setF(std::move(F)); + mRes.setV(std::move(V)); + mRes.setA(std::move(A)); + mRes.setN(std::move(N)); + mRes.setScale(scale); + mRes.build(options.deterministic); + mRes.resetSolution(); + + if (options.align_to_boundaries && !pointcloud) { + mRes.clearConstraints(); + uint32_t num_corners = static_cast(3 * mRes.F().cols()); + for (uint32_t i = 0; i < num_corners; ++i) { + if (mRes.E2E()[i] == INVALID) { + uint32_t i0 = mRes.F()(i % 3, i / 3); + uint32_t i1 = mRes.F()((i + 1) % 3, i / 3); + LA_IGNORE_ARRAY_BOUNDS_BEGIN + Vector3f p0 = mRes.V().col(i0), p1 = mRes.V().col(i1); + Vector3f edge = p1 - p0; + if (edge.squaredNorm() > 0) { + edge.normalize(); + mRes.CO().col(i0) = p0; + mRes.CO().col(i1) = p1; + mRes.CQ().col(i0) = mRes.CQ().col(i1) = edge; + mRes.CQw()[i0] = mRes.CQw()[i1] = mRes.COw()[i0] = mRes.COw()[i1] = 1.0f; + } + LA_IGNORE_ARRAY_BOUNDS_END + } + } + mRes.propagateConstraints(rosy, posy); + } + + if (bvh) { + bvh->setData(&mRes.F(), &mRes.V(), &mRes.N()); + } else if (options.num_smooth_iter > 0) { + bvh = std::make_shared(&mRes.F(), &mRes.V(), &mRes.N(), stats.mAABB); + bvh->build(); + } + + Optimizer optimizer(mRes, false); + optimizer.setRoSy(rosy); + optimizer.setPoSy(posy); + optimizer.setExtrinsic(options.extrinsic); + + optimizer.optimizeOrientations(-1); + optimizer.notify(); + optimizer.wait(); + + std::map sing; + compute_orientation_singularities(mRes, sing, options.extrinsic, rosy); + + optimizer.optimizePositions(-1); + optimizer.notify(); + optimizer.wait(); + + optimizer.shutdown(); + + MatrixXf O_extr, N_extr, Nf_extr; + std::vector> adj_extr; + extract_graph( + mRes, + options.extrinsic, + rosy, + posy, + adj_extr, + O_extr, + N_extr, + crease_in, + crease_out, + options.deterministic); + + MatrixXu F_extr; + extract_faces( + adj_extr, + O_extr, + N_extr, + Nf_extr, + F_extr, + posy, + mRes.scale(), + crease_out, + true, + posy == 4, + bvh.get(), + static_cast(options.num_smooth_iter)); + + return eigen_to_surface_mesh(O_extr.transpose(), F_extr.transpose()); +} + +#define LA_X_remesh(_, Scalar, Index) \ + template LA_REMESHING_IM_API SurfaceMesh remesh( \ + SurfaceMesh&, \ + const RemeshingOptions&); +LA_SURFACE_MESH_X(remesh, 0) + +} // namespace lagrange::remeshing_im diff --git a/modules/remeshing_im/tests/CMakeLists.txt b/modules/remeshing_im/tests/CMakeLists.txt new file mode 100644 index 00000000..2181b4d0 --- /dev/null +++ b/modules/remeshing_im/tests/CMakeLists.txt @@ -0,0 +1,12 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +lagrange_add_test() diff --git a/modules/remeshing_im/tests/test_remesh.cpp b/modules/remeshing_im/tests/test_remesh.cpp new file mode 100644 index 00000000..6524ad56 --- /dev/null +++ b/modules/remeshing_im/tests/test_remesh.cpp @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +TEST_CASE("remeshing_im::remesh", "[remeshing_im]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({0.5, 0.5, 1}); + mesh.add_triangle(0, 2, 1); + mesh.add_triangle(0, 3, 2); + mesh.add_triangle(0, 1, 4); + mesh.add_triangle(1, 2, 4); + mesh.add_triangle(2, 3, 4); + mesh.add_triangle(3, 0, 4); + const Scalar total_area = compute_mesh_area(mesh); + + remeshing_im::RemeshingOptions options; + options.crease_angle = 45.f; + options.deterministic = true; + + SECTION("default options") + { + options.target_num_facets = 100; + auto out_mesh = remeshing_im::remesh(mesh, options); + + REQUIRE(out_mesh.is_quad_mesh()); + REQUIRE(is_vertex_manifold(out_mesh)); + REQUIRE(is_edge_manifold(out_mesh)); + REQUIRE(is_oriented(out_mesh)); + + REQUIRE_THAT(compute_mesh_area(out_mesh), Catch::Matchers::WithinRel(total_area, 1e-1)); + } + + SECTION("Triangle mesh") + { + options.target_num_facets = 500; + options.posy = remeshing_im::PosyType::Triangle; + options.rosy = remeshing_im::RosyType::Hex; + options.crease_angle = 45.f; + auto out_mesh = remeshing_im::remesh(mesh, options); + io::save_mesh("out_mesh.obj", out_mesh); + + REQUIRE(out_mesh.is_triangle_mesh()); + REQUIRE(is_vertex_manifold(out_mesh)); + REQUIRE(is_edge_manifold(out_mesh)); + REQUIRE(is_oriented(out_mesh)); + + REQUIRE_THAT(compute_mesh_area(out_mesh), Catch::Matchers::WithinRel(total_area, 1e-1)); + } + + SECTION("empty mesh") + { + SurfaceMesh empty_mesh; + auto out_mesh = remeshing_im::remesh(empty_mesh); + REQUIRE(out_mesh.is_quad_mesh()); + REQUIRE(out_mesh.get_num_vertices() == 0); + REQUIRE(out_mesh.get_num_facets() == 0); + REQUIRE(is_vertex_manifold(out_mesh)); + REQUIRE(is_edge_manifold(out_mesh)); + REQUIRE(is_oriented(out_mesh)); + } +} diff --git a/modules/scene/include/lagrange/scene/SimpleScene.h b/modules/scene/include/lagrange/scene/SimpleScene.h index 1b14db71..e22aa622 100644 --- a/modules/scene/include/lagrange/scene/SimpleScene.h +++ b/modules/scene/include/lagrange/scene/SimpleScene.h @@ -37,7 +37,7 @@ struct LA_SCENE_API MeshInstance /// Affine transformation matrix. using AffineTransform = Eigen::Transform(Dimension), Eigen::Affine>; - /// Index of the referenced mesh in the scene. + /// Index of the source mesh in the scene. Index mesh_index = invalid(); /// Instance transformation. @@ -121,8 +121,9 @@ class LA_SCENE_API SimpleScene /// /// Get a const reference to a mesh instance in the scene. /// - /// @param[in] mesh_index Index of the parent mesh in the scene. - /// @param[in] instance_index Local instance index respective to the parent mesh. + /// @param[in] mesh_index Index of the source mesh in the scene. + /// @param[in] instance_index Local instance index relative to other instances of the same + /// source mesh. /// /// @return Reference to the specified mesh instance. /// @@ -134,8 +135,9 @@ class LA_SCENE_API SimpleScene /// /// Get a reference to a mesh instance in the scene. /// - /// @param[in] mesh_index Index of the parent mesh in the scene. - /// @param[in] instance_index Local instance index respective to the parent mesh. + /// @param[in] mesh_index Index of the source mesh in the scene. + /// @param[in] instance_index Local instance index relative to other instances of the same + /// source mesh. /// /// @return Reference to the specified mesh instance. /// @@ -197,7 +199,7 @@ class LA_SCENE_API SimpleScene /// List of meshes in the scene. std::vector m_meshes; - /// List of mesh instances in the scene. Stored as a list of instance per parent mesh. + /// List of mesh instances in the scene. Stored as a list of instances per source mesh. std::vector> m_instances; }; diff --git a/modules/scene/include/lagrange/scene/internal/scene_string_utils.h b/modules/scene/include/lagrange/scene/internal/scene_string_utils.h index 9f0b267e..3d3bcd4a 100644 --- a/modules/scene/include/lagrange/scene/internal/scene_string_utils.h +++ b/modules/scene/include/lagrange/scene/internal/scene_string_utils.h @@ -13,6 +13,7 @@ #include #include +#include #include @@ -26,7 +27,7 @@ namespace lagrange::scene::internal { /// /// @return A string representation of the mesh instance. /// -std::string to_string(const SceneMeshInstance& mesh_instance, size_t indent = 0); +LA_SCENE_API std::string to_string(const SceneMeshInstance& mesh_instance, size_t indent = 0); /// /// Convert a node to a string representation. @@ -36,7 +37,7 @@ std::string to_string(const SceneMeshInstance& mesh_instance, size_t indent = 0) /// /// @return A string representation of the node. /// -std::string to_string(const Node& node, size_t indent = 0); +LA_SCENE_API std::string to_string(const Node& node, size_t indent = 0); /// /// Convert an image buffer to a string representation. @@ -46,7 +47,7 @@ std::string to_string(const Node& node, size_t indent = 0); /// /// @return A string representation of the image buffer. /// -std::string to_string(const ImageBufferExperimental& image, size_t indent = 0); +LA_SCENE_API std::string to_string(const ImageBufferExperimental& image, size_t indent = 0); /// /// Convert an image to a string representation. @@ -56,7 +57,7 @@ std::string to_string(const ImageBufferExperimental& image, size_t indent = 0); /// /// @return A string representation of the image. /// -std::string to_string(const ImageExperimental& image, size_t indent = 0); +LA_SCENE_API std::string to_string(const ImageExperimental& image, size_t indent = 0); /// /// Convert a texture info object to a string representation. @@ -66,7 +67,7 @@ std::string to_string(const ImageExperimental& image, size_t indent = 0); /// /// @return A string representation of the texture info. /// -std::string to_string(const TextureInfo& texture_info, size_t indent = 0); +LA_SCENE_API std::string to_string(const TextureInfo& texture_info, size_t indent = 0); /// /// Convert a material to a string representation. @@ -76,7 +77,7 @@ std::string to_string(const TextureInfo& texture_info, size_t indent = 0); /// /// @return A string representation of the material. /// -std::string to_string(const MaterialExperimental& material, size_t indent = 0); +LA_SCENE_API std::string to_string(const MaterialExperimental& material, size_t indent = 0); /// /// Convert a Texture to a string representation. @@ -86,7 +87,7 @@ std::string to_string(const MaterialExperimental& material, size_t indent = 0); /// /// @return A string representation of the texture. /// -std::string to_string(const Texture& texture, size_t indent = 0); +LA_SCENE_API std::string to_string(const Texture& texture, size_t indent = 0); /// /// Convert a light to a string representation. @@ -96,7 +97,7 @@ std::string to_string(const Texture& texture, size_t indent = 0); /// /// @return A string representation of the light. /// -std::string to_string(const Light& light, size_t indent = 0); +LA_SCENE_API std::string to_string(const Light& light, size_t indent = 0); /// @@ -107,7 +108,7 @@ std::string to_string(const Light& light, size_t indent = 0); /// /// @return A string representation of the camera. /// -std::string to_string(const Camera& camera, size_t indent = 0); +LA_SCENE_API std::string to_string(const Camera& camera, size_t indent = 0); /// @@ -118,7 +119,7 @@ std::string to_string(const Camera& camera, size_t indent = 0); /// /// @return A string representation of the animation. /// -std::string to_string(const Animation& animation, size_t indent = 0); +LA_SCENE_API std::string to_string(const Animation& animation, size_t indent = 0); /// /// Convert a skeleton to a string representation. @@ -128,7 +129,7 @@ std::string to_string(const Animation& animation, size_t indent = 0); /// /// @return A string representation of the skeleton. /// -std::string to_string(const Skeleton& skeleton, size_t indent = 0); +LA_SCENE_API std::string to_string(const Skeleton& skeleton, size_t indent = 0); /// /// Convert a scene to a string representation. @@ -152,6 +153,6 @@ std::string to_string(const Scene& scene, size_t indent = 0); /// /// @return A string representation of the scene extensions. /// -std::string to_string(const Extensions& extensions, size_t indent = 0); +LA_SCENE_API std::string to_string(const Extensions& extensions, size_t indent = 0); } // namespace lagrange::scene::internal diff --git a/modules/scene/include/lagrange/scene/scene_convert.h b/modules/scene/include/lagrange/scene/scene_convert.h index f10f60f7..85934f7f 100644 --- a/modules/scene/include/lagrange/scene/scene_convert.h +++ b/modules/scene/include/lagrange/scene/scene_convert.h @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -80,4 +81,72 @@ std::vector> scene_to_meshes( const Scene& scene, const TransformOptions& transform_options = {}); +/// +/// Converts a Scene into a SimpleScene. +/// +/// The Scene's node hierarchy is flattened: each mesh instance in the scene becomes a +/// MeshInstance in the SimpleScene with the accumulated world transform. Meshes are copied +/// by index. Materials and other scene metadata (images, textures, cameras, lights) are not +/// preserved in the SimpleScene. +/// +/// @param[in] scene Input scene to convert. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return SimpleScene containing all mesh instances from the scene. +/// +template +SimpleScene scene_to_simple_scene(const Scene& scene); + +/// +/// Converts a SimpleScene into a Scene. +/// +/// Each mesh instance in the SimpleScene becomes a node in the Scene with the instance +/// transform. All nodes are direct children of a single root node. Meshes are copied +/// by index. +/// +/// @param[in] simple_scene Input simple scene to convert. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return Scene containing the meshes and instances from the SimpleScene. +/// +template +Scene simple_scene_to_scene(const SimpleScene& simple_scene); + +/// +/// Result structure for scene to meshes and materials conversion. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +struct MeshesAndMaterialsResult +{ + /// List of meshes with transforms applied. + std::vector> meshes; + + /// List of material IDs for each mesh. + std::vector> material_ids; +}; + +/// +/// Converts a scene into a list of meshes with all the transforms applied and a list of material IDs. +/// +/// @param[in] scene Scene to convert. +/// @param[in] transform_options Options to use when applying mesh transformations. +/// +/// @tparam Scalar Input scene scalar type. +/// @tparam Index Input scene index type. +/// +/// @return List of meshes with transforms applied and a list of material IDs. +/// +template +MeshesAndMaterialsResult scene_to_meshes_and_materials( + const Scene& scene, + const TransformOptions& transform_options = {}); + + } // namespace lagrange::scene diff --git a/modules/scene/python/src/bind_scene.h b/modules/scene/python/src/bind_scene.h index 5589a542..34c2efa3 100644 --- a/modules/scene/python/src/bind_scene.h +++ b/modules/scene/python/src/bind_scene.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -663,12 +664,12 @@ void bind_scene(nb::module_& m) [](const SceneType& scene, size_t node_idx) { auto t = utils::compute_global_node_transform(scene, node_idx); return nb::ndarray>( - t.data(), - {4, 4}, - nb::handle(), // owner - {1, 4}); + t.data(), + {4, 4}, + nb::handle(), // owner + {1, 4}) + .cast(); }, - nb::rv_policy::copy, "scene"_a, "node_idx"_a, R"(Compute the global transform associated with a node. @@ -722,6 +723,28 @@ void bind_scene(nb::module_& m) :return: List of transformed meshes.)"); + m.def( + "scene_to_meshes_and_materials", + [](const SceneType& scene, bool normalize_normals, bool normalize_tangents_bitangents) + -> std::pair, std::vector>> { + TransformOptions transform_options; + transform_options.normalize_normals = normalize_normals; + transform_options.normalize_tangents_bitangents = normalize_tangents_bitangents; + auto [meshes, material_ids] = + scene::scene_to_meshes_and_materials(scene, transform_options); + return {std::move(meshes), std::move(material_ids)}; + }, + "scene"_a, + "normalize_normals"_a = TransformOptions{}.normalize_normals, + "normalize_tangents_bitangents"_a = TransformOptions{}.normalize_tangents_bitangents, + R"(Converts a scene into a list of meshes with all the transforms applied and a list of material IDs. + +:param scene: Scene to convert. +:param normalize_normals: If enabled, normals are normalized after transformation. +:param normalize_tangents_bitangents: If enabled, tangents and bitangents are normalized after transformation. + +:return: List of meshes with transforms applied and a list of material IDs.)"); + m.def( "mesh_to_scene", [](const SceneType::MeshType& mesh) { return scene::mesh_to_scene(mesh); }, @@ -743,6 +766,39 @@ void bind_scene(nb::module_& m) :param meshes: Input meshes to convert. :return: Scene containing the input meshes.)"); + + using SimpleScene3D = scene::SimpleScene; + + m.def( + "scene_to_simple_scene", + [](const SceneType& scene) { return scene::scene_to_simple_scene(scene); }, + "scene"_a, + R"(Converts a Scene into a SimpleScene. + +The Scene's node hierarchy is flattened: each mesh instance in the scene becomes a +MeshInstance in the SimpleScene with the accumulated world transform. Meshes are copied +by index. Materials and other scene metadata (images, textures, cameras, lights) are not +preserved in the SimpleScene. + +:param scene: Input scene to convert. + +:return: SimpleScene containing all mesh instances from the scene.)"); + + m.def( + "simple_scene_to_scene", + [](const SimpleScene3D& simple_scene) { + return scene::simple_scene_to_scene(simple_scene); + }, + "simple_scene"_a, + R"(Converts a SimpleScene into a Scene. + +Each mesh instance in the SimpleScene becomes a node in the Scene with the instance +transform. All nodes are direct children of a single root node. Meshes are copied +by index. + +:param simple_scene: Input simple scene to convert. + +:return: Scene containing the meshes and instances from the SimpleScene.)"); } } // namespace lagrange::python diff --git a/modules/scene/python/tests/test_mesh_instance.py b/modules/scene/python/tests/test_mesh_instance.py index 3ab184dd..7e9e8769 100644 --- a/modules/scene/python/tests/test_mesh_instance.py +++ b/modules/scene/python/tests/test_mesh_instance.py @@ -11,7 +11,6 @@ # import lagrange -import pytest import numpy as np diff --git a/modules/scene/python/tests/test_scene.py b/modules/scene/python/tests/test_scene.py index 8e2a386e..1c49c82d 100644 --- a/modules/scene/python/tests/test_scene.py +++ b/modules/scene/python/tests/test_scene.py @@ -14,7 +14,7 @@ import lagrange import numpy as np -from .assets import single_triangle +from .assets import single_triangle # noqa: F401 class TestScene: @@ -182,12 +182,14 @@ def test_scene_construction(self): # Add skeletons to the scene skeleton = lagrange.scene.Skeleton() skeleton_id = scene.add(skeleton) + assert skeleton_id == 0 assert len(scene.skeletons) == 1 # Add animations to the scene animation = lagrange.scene.Animation() animation.name = "test animation" animation_id = scene.add(animation) + assert animation_id == 0 assert len(scene.animations) == 1 def test_scene_extension(self): @@ -201,6 +203,7 @@ def test_scene_extension(self): scene.extensions.data.update({"extension0": {"key": [0, 1, 2]}}) assert scene.extensions.size == 1 x = scene.extensions.data["extension0"] + assert x == {"key": [0, 1, 2]} assert scene.extensions.data["extension0"] == {"key": [0, 1, 2]} scene.extensions.data.update({"extension1": {"key": "foo"}}) assert scene.extensions.size == 2 @@ -226,3 +229,154 @@ def test_scene_convert(self, single_triangle): assert np.all(mesh2.vertices == mesh2_alt.vertices) and np.all( mesh2.facets == mesh2_alt.facets ) + + def test_scene_to_simple_scene_empty(self): + """An empty scene converts to an empty SimpleScene.""" + scene = lagrange.scene.Scene() + root = lagrange.scene.Node() + root.name = "root" + root_id = scene.add(root) + scene.root_nodes.append(root_id) + + simple = lagrange.scene.scene_to_simple_scene(scene) + assert simple.num_meshes == 0 + + def test_scene_to_simple_scene_basic(self, single_triangle): + """A scene with one mesh instance converts to a SimpleScene with correct transform.""" + scene = lagrange.scene.Scene() + mesh_id = scene.add(single_triangle) + + root = lagrange.scene.Node() + root.name = "root" + root_id = scene.add(root) + scene.root_nodes.append(root_id) + + child = lagrange.scene.Node() + child.name = "child" + xf = np.eye(4, dtype=np.float32) + xf[0, 3] = 1.0 + xf[1, 3] = 2.0 + xf[2, 3] = 3.0 + child.transform = xf + child.parent = root_id + + mi = lagrange.scene.SceneMeshInstance() + mi.mesh = mesh_id + child.meshes.append(mi) + + child_id = scene.add(child) + scene.add_child(root_id, child_id) + + simple = lagrange.scene.scene_to_simple_scene(scene) + assert simple.num_meshes == 1 + assert simple.num_instances(0) == 1 + + inst = simple.get_instance(0, 0) + assert inst.mesh_index == 0 + assert np.allclose(inst.transform, xf, atol=1e-6) + + def test_scene_to_simple_scene_hierarchy(self, single_triangle): + """World transform is the product of ancestor transforms.""" + scene = lagrange.scene.Scene() + mesh_id = scene.add(single_triangle) + + root_xf = np.eye(4, dtype=np.float32) + root_xf[0, 3] = 1.0 # translate x by 1 + + root = lagrange.scene.Node() + root.name = "root" + root.transform = root_xf + root_id = scene.add(root) + scene.root_nodes.append(root_id) + + child_xf = np.eye(4, dtype=np.float32) + child_xf[1, 3] = 2.0 # translate y by 2 + + child = lagrange.scene.Node() + child.name = "child" + child.transform = child_xf + child.parent = root_id + mi = lagrange.scene.SceneMeshInstance() + mi.mesh = mesh_id + child.meshes.append(mi) + child_id = scene.add(child) + scene.add_child(root_id, child_id) + + simple = lagrange.scene.scene_to_simple_scene(scene) + assert simple.num_meshes == 1 + assert simple.num_instances(0) == 1 + + expected_xf = root_xf @ child_xf + inst = simple.get_instance(0, 0) + assert np.allclose(inst.transform, expected_xf, atol=1e-6) + + def test_simple_scene_to_scene_basic(self, single_triangle): + """A SimpleScene converts to a Scene with correct structure.""" + simple = lagrange.scene.SimpleScene3D() + mesh_idx = simple.add_mesh(single_triangle) + + inst = lagrange.scene.MeshInstance3D() + inst.mesh_index = mesh_idx + xf = np.eye(4, dtype=np.float64) + xf[0, 3] = 5.0 + inst.transform = xf + simple.add_instance(inst) + + scene = lagrange.scene.simple_scene_to_scene(simple) + assert len(scene.meshes) == 1 + assert len(scene.root_nodes) == 1 + # root + 1 instance node + assert len(scene.nodes) == 2 + + root_id = scene.root_nodes[0] + assert len(scene.nodes[root_id].children) == 1 + + child_id = scene.nodes[root_id].children[0] + child = scene.nodes[child_id] + assert len(child.meshes) == 1 + assert child.meshes[0].mesh == 0 + # Compare as float32 since Scene node transforms are float. + assert np.allclose(child.transform, xf.astype(np.float32), atol=1e-5) + + def test_scene_to_simple_scene_roundtrip(self, single_triangle): + """Roundtrip Scene -> SimpleScene -> Scene preserves mesh data and world transforms.""" + scene = lagrange.scene.Scene() + mesh_id = scene.add(single_triangle) + + root_xf = np.eye(4, dtype=np.float32) + root_xf[0, 3] = 1.0 + + root = lagrange.scene.Node() + root.name = "root" + root.transform = root_xf + root_id = scene.add(root) + scene.root_nodes.append(root_id) + + child_xf = np.eye(4, dtype=np.float32) + child_xf[1, 3] = 2.0 + + child = lagrange.scene.Node() + child.name = "child" + child.transform = child_xf + child.parent = root_id + mi = lagrange.scene.SceneMeshInstance() + mi.mesh = mesh_id + child.meshes.append(mi) + child_id = scene.add(child) + scene.add_child(root_id, child_id) + + # Roundtrip + simple = lagrange.scene.scene_to_simple_scene(scene) + scene2 = lagrange.scene.simple_scene_to_scene(simple) + + # Mesh data preserved + assert len(scene2.meshes) == len(scene.meshes) + assert np.all(scene2.meshes[0].vertices == scene.meshes[0].vertices) + assert np.all(scene2.meshes[0].facets == scene.meshes[0].facets) + + # World transform preserved: the instance node in scene2 should carry + # the flattened world transform root_xf @ child_xf. + expected_world = root_xf @ child_xf + root2_id = scene2.root_nodes[0] + child2_id = scene2.nodes[root2_id].children[0] + assert np.allclose(scene2.nodes[child2_id].transform, expected_world, atol=1e-5) diff --git a/modules/scene/python/tests/test_simple_scene.py b/modules/scene/python/tests/test_simple_scene.py index 42b27ad4..8370dd51 100644 --- a/modules/scene/python/tests/test_simple_scene.py +++ b/modules/scene/python/tests/test_simple_scene.py @@ -12,7 +12,7 @@ import lagrange import numpy as np -from .assets import single_triangle +from .assets import single_triangle # noqa: F401 class TestSimpleScene: diff --git a/modules/scene/src/internal/scene_string_utils.cpp b/modules/scene/src/internal/scene_string_utils.cpp index e4daaa88..e5ae0129 100644 --- a/modules/scene/src/internal/scene_string_utils.cpp +++ b/modules/scene/src/internal/scene_string_utils.cpp @@ -589,7 +589,7 @@ std::string to_string(const Extensions& extensions, size_t indent) } #define LA_X_to_string(_, Scalar, Index) \ - template std::string to_string(const Scene&, size_t); + template LA_SCENE_API std::string to_string(const Scene&, size_t); LA_SURFACE_MESH_X(to_string, 0) } // namespace lagrange::scene::internal diff --git a/modules/scene/src/scene_convert.cpp b/modules/scene/src/scene_convert.cpp index 4b10fd4b..4ac5d998 100644 --- a/modules/scene/src/scene_convert.cpp +++ b/modules/scene/src/scene_convert.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -41,11 +42,11 @@ Scene meshes_to_scene(std::vector> mes } template -std::vector> scene_to_meshes( +MeshesAndMaterialsResult scene_to_meshes_and_materials( const Scene& scene, const TransformOptions& transform_options) { - std::vector> meshes; + MeshesAndMaterialsResult ret; for (ElementId node_id = 0; node_id < scene.nodes.size(); ++node_id) { const auto& node = scene.nodes[node_id]; @@ -56,15 +57,24 @@ std::vector> scene_to_meshes( utils::compute_global_node_transform(scene, node_id).template cast(); for (const SceneMeshInstance& mesh_instance : node.meshes) { const auto mesh_id = mesh_instance.mesh; - meshes.emplace_back( + ret.meshes.emplace_back( transformed_mesh( scene.meshes.at(mesh_id), world_from_mesh, transform_options)); + ret.material_ids.push_back(mesh_instance.materials); } } - return meshes; + return ret; +} + +template +std::vector> scene_to_meshes( + const Scene& scene, + const TransformOptions& transform_options) +{ + return scene_to_meshes_and_materials(scene, transform_options).meshes; } template @@ -78,16 +88,92 @@ SurfaceMesh scene_to_mesh( preserve_attributes); } -#define LA_X_scene_convert(_, Scalar, Index) \ - template LA_SCENE_API Scene mesh_to_scene(SurfaceMesh mesh); \ - template LA_SCENE_API Scene meshes_to_scene( \ - std::vector> meshes); \ - template LA_SCENE_API SurfaceMesh scene_to_mesh( \ - const Scene& scene, \ - const TransformOptions& transform_options, \ - bool preserve_attributes); \ - template LA_SCENE_API std::vector> scene_to_meshes( \ - const Scene& scene, \ +template +SimpleScene scene_to_simple_scene(const Scene& scene) +{ + SimpleScene simple_scene; + + // Copy meshes. + simple_scene.reserve_meshes(static_cast(scene.meshes.size())); + for (size_t i = 0; i < scene.meshes.size(); ++i) { + simple_scene.add_mesh(scene.meshes[i]); + } + + // Flatten the node hierarchy into mesh instances with world transforms. + for (ElementId node_id = 0; node_id < scene.nodes.size(); ++node_id) { + const auto& node = scene.nodes[node_id]; + if (node.meshes.empty()) continue; + + auto world_transform = utils::compute_global_node_transform(scene, node_id); + + for (const SceneMeshInstance& mi : node.meshes) { + using InstanceType = MeshInstance; + InstanceType instance; + instance.mesh_index = static_cast(mi.mesh); + instance.transform = world_transform.template cast(); + simple_scene.add_instance(std::move(instance)); + } + } + + return simple_scene; +} + +template +Scene simple_scene_to_scene(const SimpleScene& simple_scene) +{ + Scene scene; + + // Copy meshes. + scene.meshes.reserve(simple_scene.get_num_meshes()); + for (Index i = 0; i < simple_scene.get_num_meshes(); ++i) { + scene.meshes.push_back(simple_scene.get_mesh(i)); + } + + // Create a root node. + Node root; + root.name = "root"; + ElementId root_id = scene.add(std::move(root)); + scene.root_nodes.push_back(root_id); + + // Create one child node per mesh instance. + for (Index mesh_idx = 0; mesh_idx < simple_scene.get_num_meshes(); ++mesh_idx) { + for (Index inst_idx = 0; inst_idx < simple_scene.get_num_instances(mesh_idx); ++inst_idx) { + const auto& instance = simple_scene.get_instance(mesh_idx, inst_idx); + la_debug_assert(instance.mesh_index == mesh_idx); + + Node child; + child.transform = instance.transform.template cast(); + child.parent = root_id; + + SceneMeshInstance mi; + mi.mesh = mesh_idx; + child.meshes.push_back(std::move(mi)); + + ElementId child_id = scene.add(std::move(child)); + scene.add_child(root_id, child_id); + } + } + + return scene; +} + +#define LA_X_scene_convert(_, Scalar, Index) \ + template LA_SCENE_API Scene mesh_to_scene(SurfaceMesh mesh); \ + template LA_SCENE_API Scene meshes_to_scene( \ + std::vector> meshes); \ + template LA_SCENE_API SurfaceMesh scene_to_mesh( \ + const Scene& scene, \ + const TransformOptions& transform_options, \ + bool preserve_attributes); \ + template LA_SCENE_API std::vector> scene_to_meshes( \ + const Scene& scene, \ + const TransformOptions& transform_options); \ + template LA_SCENE_API SimpleScene scene_to_simple_scene( \ + const Scene& scene); \ + template LA_SCENE_API Scene simple_scene_to_scene( \ + const SimpleScene& simple_scene); \ + template LA_SCENE_API MeshesAndMaterialsResult scene_to_meshes_and_materials( \ + const Scene& scene, \ const TransformOptions& transform_options); LA_SURFACE_MESH_X(scene_convert, 0) diff --git a/modules/scene/tests/test_scene.cpp b/modules/scene/tests/test_scene.cpp index 94fb0d64..101c247e 100644 --- a/modules/scene/tests/test_scene.cpp +++ b/modules/scene/tests/test_scene.cpp @@ -12,9 +12,13 @@ #include #include +#include #include +#include #include +#include + #include TEST_CASE("scene_extension_value", "[scene]") @@ -114,3 +118,364 @@ TEST_CASE("Scene: convert", "[scene]") REQUIRE(vertex_view(mesh) == vertex_view(mesh2)); REQUIRE(facet_view(mesh) == facet_view(mesh2)); } + +TEST_CASE("Scene: scene_to_simple_scene empty", "[scene]") +{ + using Scalar = double; + using Index = uint32_t; + + lagrange::scene::Scene scene; + lagrange::scene::Node root; + root.name = "root"; + auto root_id = scene.add(std::move(root)); + scene.root_nodes.push_back(root_id); + + auto simple = lagrange::scene::scene_to_simple_scene(scene); + REQUIRE(simple.get_num_meshes() == 0); +} + +TEST_CASE("Scene: scene_to_simple_scene basic", "[scene]") +{ + using Scalar = double; + using Index = uint32_t; + using namespace lagrange::scene; + + // Create a triangle mesh. + lagrange::SurfaceMesh mesh; + mesh.add_vertices(3); + auto V = lagrange::vertex_ref(mesh); + V.row(0) << 0.0, 0.0, 0.0; + V.row(1) << 1.0, 0.0, 0.0; + V.row(2) << 0.0, 1.0, 0.0; + mesh.add_triangle(0, 1, 2); + + // Build a scene with one root and one child that has a translation transform. + Scene scene; + auto mesh_id = scene.add(mesh); + + Node root; + root.name = "root"; + auto root_id = scene.add(std::move(root)); + scene.root_nodes.push_back(root_id); + + Eigen::Affine3f child_transform = Eigen::Affine3f::Identity(); + child_transform.translate(Eigen::Vector3f(1.0f, 2.0f, 3.0f)); + + Node child; + child.name = "child"; + child.transform = child_transform; + child.parent = root_id; + + SceneMeshInstance mi; + mi.mesh = mesh_id; + child.meshes.push_back(std::move(mi)); + + auto child_id = scene.add(std::move(child)); + scene.nodes[root_id].children.push_back(child_id); + + // Convert to SimpleScene. + auto simple = scene_to_simple_scene(scene); + + REQUIRE(simple.get_num_meshes() == 1); + REQUIRE(simple.get_num_instances(0) == 1); + + const auto& instance = simple.get_instance(0, 0); + REQUIRE(instance.mesh_index == 0); + + // Check that the world transform matches the child transform (parent is identity). + auto expected_transform = child_transform.cast(); + REQUIRE(instance.transform.matrix().isApprox(expected_transform.matrix(), 1e-6)); +} + +TEST_CASE("Scene: scene_to_simple_scene hierarchy", "[scene]") +{ + using Scalar = double; + using Index = uint32_t; + using namespace lagrange::scene; + + // Create a simple mesh. + lagrange::SurfaceMesh mesh; + mesh.add_vertices(3); + auto V = lagrange::vertex_ref(mesh); + V.row(0) << 0.0, 0.0, 0.0; + V.row(1) << 1.0, 0.0, 0.0; + V.row(2) << 0.0, 1.0, 0.0; + mesh.add_triangle(0, 1, 2); + + // Build a scene: root -> parent -> child (mesh instance). + // root has translation (1,0,0), parent has translation (0,2,0). + // The child should have world transform = root * parent = translation(1,2,0). + Scene scene; + auto mesh_id = scene.add(mesh); + + Eigen::Affine3f root_xf = Eigen::Affine3f::Identity(); + root_xf.translate(Eigen::Vector3f(1.0f, 0.0f, 0.0f)); + + Node root; + root.name = "root"; + root.transform = root_xf; + auto root_id = scene.add(std::move(root)); + scene.root_nodes.push_back(root_id); + + Eigen::Affine3f parent_xf = Eigen::Affine3f::Identity(); + parent_xf.translate(Eigen::Vector3f(0.0f, 2.0f, 0.0f)); + + Node child; + child.name = "child"; + child.transform = parent_xf; + child.parent = root_id; + + SceneMeshInstance mi; + mi.mesh = mesh_id; + child.meshes.push_back(std::move(mi)); + + auto child_id = scene.add(std::move(child)); + scene.nodes[root_id].children.push_back(child_id); + + auto simple = scene_to_simple_scene(scene); + + REQUIRE(simple.get_num_meshes() == 1); + REQUIRE(simple.get_num_instances(0) == 1); + + // Expected world transform: root_xf * parent_xf = translate(1,2,0). + Eigen::Affine3f expected_f = root_xf * parent_xf; + auto expected = expected_f.cast(); + + const auto& instance = simple.get_instance(0, 0); + REQUIRE(instance.transform.matrix().isApprox(expected.matrix(), 1e-6)); +} + +TEST_CASE("Scene: scene_to_simple_scene multiple meshes and instances", "[scene]") +{ + using Scalar = double; + using Index = uint32_t; + using namespace lagrange::scene; + + // Create two meshes. + lagrange::SurfaceMesh mesh_a; + mesh_a.add_vertices(3); + lagrange::vertex_ref(mesh_a).setRandom(); + mesh_a.add_triangle(0, 1, 2); + + lagrange::SurfaceMesh mesh_b; + mesh_b.add_vertices(4); + lagrange::vertex_ref(mesh_b).setRandom(); + mesh_b.add_triangle(0, 1, 2); + mesh_b.add_triangle(0, 2, 3); + + Scene scene; + auto mesh_a_id = scene.add(mesh_a); + auto mesh_b_id = scene.add(mesh_b); + + Node root; + root.name = "root"; + auto root_id = scene.add(std::move(root)); + scene.root_nodes.push_back(root_id); + + // Node referencing mesh_a (identity transform). + { + Node n; + n.parent = root_id; + SceneMeshInstance mi; + mi.mesh = mesh_a_id; + n.meshes.push_back(mi); + auto nid = scene.add(std::move(n)); + scene.nodes[root_id].children.push_back(nid); + } + + // Node referencing mesh_b (with a scale transform). + { + Eigen::Affine3f xf = Eigen::Affine3f::Identity(); + xf.scale(2.0f); + + Node n; + n.parent = root_id; + n.transform = xf; + SceneMeshInstance mi; + mi.mesh = mesh_b_id; + n.meshes.push_back(mi); + auto nid = scene.add(std::move(n)); + scene.nodes[root_id].children.push_back(nid); + } + + // Another node also referencing mesh_a (with translation). + { + Eigen::Affine3f xf = Eigen::Affine3f::Identity(); + xf.translate(Eigen::Vector3f(5.0f, 0.0f, 0.0f)); + + Node n; + n.parent = root_id; + n.transform = xf; + SceneMeshInstance mi; + mi.mesh = mesh_a_id; + n.meshes.push_back(mi); + auto nid = scene.add(std::move(n)); + scene.nodes[root_id].children.push_back(nid); + } + + auto simple = scene_to_simple_scene(scene); + + REQUIRE(simple.get_num_meshes() == 2); + // mesh_a has 2 instances, mesh_b has 1 instance. + REQUIRE(simple.get_num_instances(0) == 2); + REQUIRE(simple.get_num_instances(1) == 1); + + // Verify mesh vertex counts are preserved. + REQUIRE(simple.get_mesh(0).get_num_vertices() == 3); + REQUIRE(simple.get_mesh(1).get_num_vertices() == 4); +} + +TEST_CASE("Scene: simple_scene_to_scene basic", "[scene]") +{ + using Scalar = double; + using Index = uint32_t; + using namespace lagrange::scene; + + SimpleScene simple; + + lagrange::SurfaceMesh mesh; + mesh.add_vertices(3); + lagrange::vertex_ref(mesh).setRandom(); + mesh.add_triangle(0, 1, 2); + + auto mesh_idx = simple.add_mesh(std::move(mesh)); + + MeshInstance inst; + inst.mesh_index = mesh_idx; + Eigen::Transform xf = + Eigen::Transform::Identity(); + xf.translate(Eigen::Matrix(1.0, 2.0, 3.0)); + inst.transform = xf; + simple.add_instance(std::move(inst)); + + auto scene = simple_scene_to_scene(simple); + + // One mesh, one root, one child node (for the instance). + REQUIRE(scene.meshes.size() == 1); + REQUIRE(scene.root_nodes.size() == 1); + REQUIRE(scene.nodes.size() == 2); // root + 1 instance node + + // Root node should have one child. + auto root_id = scene.root_nodes[0]; + REQUIRE(scene.nodes[root_id].children.size() == 1); + + // The child node should reference mesh 0 and carry the transform. + auto child_id = scene.nodes[root_id].children[0]; + const auto& child = scene.nodes[child_id]; + REQUIRE(child.meshes.size() == 1); + REQUIRE(child.meshes[0].mesh == 0); + REQUIRE(child.transform.matrix().isApprox(xf.template cast().matrix(), 1e-5f)); +} + +TEST_CASE("Scene: simple_scene_to_scene multiple instances", "[scene]") +{ + using Scalar = double; + using Index = uint32_t; + using namespace lagrange::scene; + + SimpleScene simple; + + lagrange::SurfaceMesh mesh; + mesh.add_vertices(3); + lagrange::vertex_ref(mesh).setRandom(); + mesh.add_triangle(0, 1, 2); + auto mesh_idx = simple.add_mesh(std::move(mesh)); + + // Add 3 instances of the same mesh with different transforms. + for (int i = 0; i < 3; ++i) { + MeshInstance inst; + inst.mesh_index = mesh_idx; + Eigen::Transform xf = + Eigen::Transform::Identity(); + xf.translate( + Eigen::Matrix( + static_cast(i), + static_cast(i * 2), + static_cast(i * 3))); + inst.transform = xf; + simple.add_instance(std::move(inst)); + } + + auto scene = simple_scene_to_scene(simple); + + REQUIRE(scene.meshes.size() == 1); + // root + 3 instance nodes + REQUIRE(scene.nodes.size() == 4); + + auto root_id = scene.root_nodes[0]; + REQUIRE(scene.nodes[root_id].children.size() == 3); + + // All children should reference mesh 0. + for (auto child_id : scene.nodes[root_id].children) { + REQUIRE(scene.nodes[child_id].meshes.size() == 1); + REQUIRE(scene.nodes[child_id].meshes[0].mesh == 0); + } +} + +TEST_CASE("Scene: roundtrip scene -> simple -> scene", "[scene]") +{ + using Scalar = double; + using Index = uint32_t; + using namespace lagrange::scene; + + // Build a scene with a hierarchy. + lagrange::SurfaceMesh mesh; + mesh.add_vertices(3); + auto V = lagrange::vertex_ref(mesh); + V.row(0) << 0.0, 0.0, 0.0; + V.row(1) << 1.0, 0.0, 0.0; + V.row(2) << 0.0, 1.0, 0.0; + mesh.add_triangle(0, 1, 2); + + Scene scene; + auto mesh_id = scene.add(mesh); + + Eigen::Affine3f root_xf = Eigen::Affine3f::Identity(); + root_xf.translate(Eigen::Vector3f(1.0f, 0.0f, 0.0f)); + + Node root; + root.name = "root"; + root.transform = root_xf; + auto root_id = scene.add(std::move(root)); + scene.root_nodes.push_back(root_id); + + Eigen::Affine3f child_xf = Eigen::Affine3f::Identity(); + child_xf.translate(Eigen::Vector3f(0.0f, 2.0f, 0.0f)); + + Node child; + child.name = "child"; + child.transform = child_xf; + child.parent = root_id; + + SceneMeshInstance mi; + mi.mesh = mesh_id; + child.meshes.push_back(std::move(mi)); + + auto child_id = scene.add(std::move(child)); + scene.nodes[root_id].children.push_back(child_id); + + // Roundtrip: scene -> simple_scene -> scene2 + auto simple = scene_to_simple_scene(scene); + auto scene2 = simple_scene_to_scene(simple); + + // The roundtrip scene should have the same number of meshes. + REQUIRE(scene2.meshes.size() == scene.meshes.size()); + + // Mesh data should be preserved. + REQUIRE(lagrange::vertex_view(scene2.meshes[0]) == lagrange::vertex_view(scene.meshes[0])); + REQUIRE(lagrange::facet_view(scene2.meshes[0]) == lagrange::facet_view(scene.meshes[0])); + + // The flattened world transform should be preserved. + // Original world transform = root_xf * child_xf. + Eigen::Affine3f expected_world = root_xf * child_xf; + + // In scene2, all instance nodes are children of the root. + // The root is identity, so the child node's local transform IS the world transform. + REQUIRE(scene2.root_nodes.size() == 1); + auto root2_id = scene2.root_nodes[0]; + REQUIRE(scene2.nodes[root2_id].children.size() == 1); + + auto child2_id = scene2.nodes[root2_id].children[0]; + const auto& child2 = scene2.nodes[child2_id]; + REQUIRE(child2.transform.matrix().isApprox(expected_world.matrix(), 1e-5f)); +} diff --git a/modules/subdivision/src/subdivide_mesh.cpp b/modules/subdivision/src/subdivide_mesh.cpp index c580a4be..aac490c4 100644 --- a/modules/subdivision/src/subdivide_mesh.cpp +++ b/modules/subdivision/src/subdivide_mesh.cpp @@ -260,6 +260,11 @@ SurfaceMesh subdivide_mesh( const SurfaceMesh& input_mesh, const SubdivisionOptions& options) { + if (input_mesh.get_num_vertices() == 0 || input_mesh.get_num_facets() == 0) { + logger().debug("[subdivide_mesh] Input mesh has no facet or vertices. Returning a copy."); + return input_mesh; + } + // Prepare list of attribute ids to interpolate auto interpolated_attr = prepare_interpolated_attribute_ids(input_mesh, options.interpolated_attributes); diff --git a/modules/subdivision/tests/test_mesh_subdivision.cpp b/modules/subdivision/tests/test_mesh_subdivision.cpp index 048dd719..e51c7252 100644 --- a/modules/subdivision/tests/test_mesh_subdivision.cpp +++ b/modules/subdivision/tests/test_mesh_subdivision.cpp @@ -537,6 +537,26 @@ TEST_CASE("mesh_subdivision_midpoint", "[mesh][subdivision][sqrt]") REQUIRE(facet_view(subdivided_mesh) == facet_view(expected_mesh)); } +TEST_CASE("mesh_subdivision_empty", "[mesh][subdivision]") +{ + SECTION("no vertices") + { + lagrange::SurfaceMesh32f mesh; + auto result = lagrange::subdivision::subdivide_mesh(mesh); + REQUIRE(result.get_num_vertices() == 0); + REQUIRE(result.get_num_facets() == 0); + } + + SECTION("vertices but no facets") + { + lagrange::SurfaceMesh32f mesh; + mesh.add_vertices(5); + auto result = lagrange::subdivision::subdivide_mesh(mesh); + REQUIRE(result.get_num_vertices() == 5); + REQUIRE(result.get_num_facets() == 0); + } +} + TEST_CASE("compute_sharpness", "[mesh][subdivision][sharpness]") { using Scalar = double; diff --git a/modules/texproc/examples/CMakeLists.txt b/modules/texproc/examples/CMakeLists.txt index d68082a0..2b9b1843 100644 --- a/modules/texproc/examples/CMakeLists.txt +++ b/modules/texproc/examples/CMakeLists.txt @@ -10,7 +10,7 @@ # governing permissions and limitations under the License. # -lagrange_include_modules(io image_io polyscope) +lagrange_include_modules(io image_io scene polyscope) lagrange_add_example(geodesic_dilation geodesic_dilation.cpp) target_link_libraries(geodesic_dilation lagrange::texproc CLI11::CLI11 lagrange::io lagrange::image_io) @@ -27,5 +27,12 @@ target_link_libraries(texture_compositing lagrange::texproc CLI11::CLI11 lagrang lagrange_add_example(texture_rasterization texture_rasterization.cpp) target_link_libraries(texture_rasterization lagrange::texproc CLI11::CLI11 lagrange::io lagrange::image_io) -lagrange_add_example(texture_stitching_gui texture_stitching_gui.cpp) -target_link_libraries(texture_stitching_gui lagrange::texproc CLI11::CLI11 lagrange::io lagrange::image_io lagrange::polyscope) +lagrange_add_example(extract_mesh_with_alpha_mask + extract_mesh_with_alpha_mask.cpp + ../tests/image_helpers.h + ../tests/image_helpers.cpp +) +target_link_libraries(extract_mesh_with_alpha_mask lagrange::texproc lagrange::scene lagrange::io CLI11::CLI11) + +lagrange_add_example(texture_processing_gui texture_processing_gui.cpp) +target_link_libraries(texture_processing_gui lagrange::texproc CLI11::CLI11 lagrange::io lagrange::image_io lagrange::polyscope) diff --git a/modules/texproc/examples/extract_mesh_with_alpha_mask.cpp b/modules/texproc/examples/extract_mesh_with_alpha_mask.cpp new file mode 100644 index 00000000..bb0b8d33 --- /dev/null +++ b/modules/texproc/examples/extract_mesh_with_alpha_mask.cpp @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include "../tests/image_helpers.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace fs = lagrange::fs; +using Scene = lagrange::scene::Scene; +using SurfaceMesh = Scene::MeshType; + +int main(int argc, char** argv) +{ + struct + { + fs::path input_path; + fs::path output_path; + bool split_grids = false; + size_t log_level = 2; + } args; + + CLI::App app{argv[0]}; + app.option_defaults()->always_capture_default(); + app.add_option("input", args.input_path, "Input scene.")->required()->check(CLI::ExistingFile); + app.add_option("output", args.output_path, "Output scene."); + app.add_option("-l,--level", args.log_level, "Log level."); + + CLI11_PARSE(app, argc, argv) + + auto logger = lagrange::logger(); + spdlog::set_level(static_cast(args.log_level)); + + logger.info("loading scene \"{}\"", args.input_path.string()); + auto scene_options = lagrange::io::LoadOptions(); + scene_options.stitch_vertices = true; + auto scene = lagrange::io::load_scene(args.input_path, scene_options); + + using ElementId = lagrange::scene::ElementId; + struct Payload + { + ElementId image_id; + int texcoord_id; + float alpha_threshold; + }; + std::unordered_map material_to_payloads; + { + ElementId material_id = 0; + for (const auto& material : scene.materials) { + if (material.alpha_mode == lagrange::scene::MaterialExperimental::AlphaMode::Opaque) { + material_id += 1; + continue; + } + if (material.alpha_cutoff <= 0.0 || material.alpha_cutoff >= 1.0) { + material_id += 1; + continue; + } + if (material.base_color_texture.index >= scene.textures.size()) { + material_id += 1; + continue; + } + const auto& base_color_texture = scene.textures.at(material.base_color_texture.index); + if (scene.images.at(base_color_texture.image).image.num_channels != 4) { + material_id += 1; + continue; + } + material_to_payloads[material_id] = Payload{ + base_color_texture.image, + material.base_color_texture.texcoord, + material.alpha_cutoff, + }; + material_id += 1; + } + } + logger.info("found {} compatible materials", material_to_payloads.size()); + + lagrange::scene::MaterialExperimental material_; + const auto material_id_ = scene.add(material_); + size_t num_extracted = 0; + for (auto& node : scene.nodes) { + la_runtime_assert(!node.name.empty()); + for (auto& instance : node.meshes) { + if (instance.materials.size() != 1) { + throw std::runtime_error("multi-material instance not supported"); + continue; + } + la_runtime_assert(instance.materials.size() == 1); + const auto iter = material_to_payloads.find(instance.materials.front()); + if (iter == material_to_payloads.end()) continue; + const auto& payload = iter->second; + const auto image = + test::scene_image_to_image_array(scene.images.at(payload.image_id).image); + auto mesh = scene.meshes.at(instance.mesh); + la_runtime_assert(image.extent(2) == 4, "must have alpha channel"); + const auto texcoord_id = + mesh.get_attribute_id(fmt::format("texcoord_{}", payload.texcoord_id)); + if (texcoord_id == lagrange::invalid_attribute_id()) continue; + if (!mesh.is_attribute_indexed(texcoord_id)) continue; + lagrange::texproc::ExtractMeshWithAlphaMaskOptions extract_options; + extract_options.texcoord_id = texcoord_id; + extract_options.alpha_threshold = payload.alpha_threshold; + auto mesh_ = lagrange::texproc::extract_mesh_with_alpha_mask( + mesh, + image.to_mdspan(), + extract_options); + const auto mesh_id_ = scene.add(mesh_); + instance.mesh = mesh_id_; + instance.materials.clear(); + instance.materials.emplace_back(material_id_); + num_extracted += 1; + } + } + logger.info("extracted {} meshes", num_extracted); + + if (!args.output_path.empty()) { + logger.info("saving scene \"{}\"", args.output_path.string()); + lagrange::io::save_scene(args.output_path, scene); + } + + return 0; +} diff --git a/modules/texproc/examples/texture_filtering.cpp b/modules/texproc/examples/texture_filtering.cpp index c6a28919..a6170639 100644 --- a/modules/texproc/examples/texture_filtering.cpp +++ b/modules/texproc/examples/texture_filtering.cpp @@ -55,10 +55,6 @@ int main(int argc, char** argv) "--jitter-epsilon", filtering_options.jitter_epsilon, "Random jitter amount (0 if no jittering)."); - app.add_option( - "--stiffness-regularization", - filtering_options.stiffness_regularization_weight, - "Stiffness matrix regularization weight."); app.add_option( "--clamp", filtering_options.clamp_to_range, diff --git a/modules/texproc/examples/texture_stitching_gui.cpp b/modules/texproc/examples/texture_processing_gui.cpp similarity index 83% rename from modules/texproc/examples/texture_stitching_gui.cpp rename to modules/texproc/examples/texture_processing_gui.cpp index 48ce0433..c88b512d 100644 --- a/modules/texproc/examples/texture_stitching_gui.cpp +++ b/modules/texproc/examples/texture_processing_gui.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -208,17 +209,19 @@ struct UiState { OrientReturn orient_ret; lagrange::texproc::StitchingOptions stitching_options; + lagrange::texproc::FilteringOptions filtering_options; SurfaceMesh mesh; Array3Df input_texture; Array3Df stitched_texture; + Array3Df filtered_texture; polyscope::SurfaceMesh* ps_mesh; polyscope::SurfaceCornerParameterizationQuantity* ps_tex; - bool can_run_texture_stitching() const; + bool has_valid_inputs() const; void main_panel(); }; -bool UiState::can_run_texture_stitching() const +bool UiState::has_valid_inputs() const { bool can_stitch = true; can_stitch &= mesh.get_num_facets() > 0; @@ -255,19 +258,19 @@ void UiState::main_panel() input_texture.extent(1), input_texture.extent(2)); - ImGui::Separator(); + const auto valid_inputs = has_valid_inputs(); + ImGui_FmtText("{} inputs", valid_inputs ? "valid" : "invalid / missing"); - const auto can_stitch = can_run_texture_stitching(); - ImGui_FmtText("texture stitching inputs: {}", can_stitch ? "valid" : "invalid / missing"); + ImGui::Separator(); // trigger stitching - if (ImGui::Button("run texture stitching") && can_stitch) { + if (ImGui::Button("run texture stitching") && valid_inputs) { lagrange::logger().info("Running texture stitching"); auto result_texture = input_texture; lagrange::texproc::texture_stitching(mesh, result_texture.to_mdspan(), stitching_options); stitched_texture = result_texture; register_color_texture(ps_mesh, ps_tex, "stitched", result_texture.to_mdspan()); - lagrange::logger().warn("Done"); + lagrange::logger().info("Done"); } // stitching options @@ -324,6 +327,91 @@ void UiState::main_panel() is_clamped ? std::make_optional(std::pair{0.0, 1.0}) : std::nullopt; } + if (ImGui::Button("Reset")) options = {}; + + ImGui::TreePop(); + } + + + ImGui::Separator(); + + // trigger filtering + if (ImGui::Button("run texture filtering") && valid_inputs) { + lagrange::logger().info("Running texture filtering"); + auto result_texture = input_texture; + lagrange::texproc::texture_filtering(mesh, result_texture.to_mdspan(), filtering_options); + filtered_texture = result_texture; + register_color_texture(ps_mesh, ps_tex, "filtered", filtered_texture.to_mdspan()); + lagrange::logger().info("Done"); + } + + // filtering options + if (ImGui::TreeNode("texture filtering options")) { + auto& options = filtering_options; + + { + const std::array labels = { + "1", + "3", + "6 (default)", + "12", + "24", + }; + const std::array values = { + 1, + 3, + 6, + 12, + 24, + }; + la_runtime_assert(labels.size() == values.size()); + auto index = 0; + for (const auto& value : values) { + if (value == options.quadrature_samples) break; + index += 1; + } + la_runtime_assert(static_cast(index) < values.size()); + ImGui::Combo("quadrature", &index, labels.data(), static_cast(labels.size())); + options.quadrature_samples = values.at(index); + } + + { + float weight = options.gradient_scale; + ImGui::SliderFloat("scale", &weight, 0.0f, 10.0); + options.gradient_scale = weight; + } + + { + options.value_weight = std::max(options.value_weight, 1e0); + la_runtime_assert(options.value_weight > 0); + float weight_log = std::log(options.value_weight) / std::log(10.0f); + ImGui::SliderFloat("log value weight", &weight_log, 0.0f, 5.0f); + options.value_weight = std::pow(10.0f, weight_log); + } + + { + options.gradient_weight = std::max(options.gradient_weight, 1e0); + la_runtime_assert(options.gradient_weight > 0); + float weight_log = std::log(options.gradient_weight) / std::log(10.0f); + ImGui::SliderFloat("log grad weight", &weight_log, 0.0f, 5.0f); + options.gradient_weight = std::pow(10.0f, weight_log); + } + + { + bool has_jitter = options.jitter_epsilon > 0; + ImGui::Checkbox("jitter", &has_jitter); + options.jitter_epsilon = has_jitter ? 1e-4 : 0.0; + } + + { + bool is_clamped = options.clamp_to_range.has_value(); + ImGui::Checkbox("clamp", &is_clamped); + options.clamp_to_range = + is_clamped ? std::make_optional(std::pair{0.0, 1.0}) : std::nullopt; + } + + if (ImGui::Button("Reset")) options = {}; + ImGui::TreePop(); } } diff --git a/modules/texproc/include/lagrange/texproc/extract_mesh_with_alpha_mask.h b/modules/texproc/include/lagrange/texproc/extract_mesh_with_alpha_mask.h new file mode 100644 index 00000000..960015d7 --- /dev/null +++ b/modules/texproc/include/lagrange/texproc/extract_mesh_with_alpha_mask.h @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +namespace lagrange::texproc { + +/// +/// Extract mesh with alpha mask options. +/// +struct ExtractMeshWithAlphaMaskOptions +{ + /// Indexed UV attribute id. + /// Must be a valid attribute of the input mesh. + AttributeId texcoord_id = invalid_attribute_id(); + + /// Opaque mask theshold. + float alpha_threshold = 0.5f; +}; + +/// +/// Convert a unwrapped triangle mesh with non-opaque texture to tesselated mesh. +/// +/// @param[in] mesh Input mesh. +/// @param[in] image RGBA non-opaque texture. +/// @param[in] options Extraction options. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return Tesselated mesh. +/// +template +auto extract_mesh_with_alpha_mask( + const SurfaceMesh& mesh, + const image::experimental::View3D image, + const ExtractMeshWithAlphaMaskOptions& options) -> SurfaceMesh; + +} // namespace lagrange::texproc diff --git a/modules/texproc/python/src/texproc.cpp b/modules/texproc/python/src/texproc.cpp index ab4a8280..3f51bdf0 100644 --- a/modules/texproc/python/src/texproc.cpp +++ b/modules/texproc/python/src/texproc.cpp @@ -19,8 +19,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -40,77 +42,6 @@ namespace tp = texproc; namespace nb = nanobind; using namespace nb::literals; -using TextureShape = nb::shape<-1, -1, -1>; - -template -using TextureTensor = nb::ndarray; - -static_assert(!std::is_same_v, Tensor>); -static_assert(std::is_same_v>); -static_assert(std::is_same_v>); - -tp::View3Df tensor_to_mdspan(const TextureTensor& tensor) -{ - // Numpy indexes tensors as (row, col, channel), but our mdspan uses (x, y, channel) - // coordinates, so we need to transpose the first two dimensions. - const image::experimental::dextents shape{ - tensor.shape(1), - tensor.shape(0), - tensor.shape(2), - }; - const std::array strides{ - static_cast(tensor.stride(1)), - static_cast(tensor.stride(0)), - static_cast(tensor.stride(2)), - }; - const image::experimental::layout_stride::mapping mapping{shape, strides}; - image::experimental::View3D view{ - static_cast(tensor.data()), - mapping, - }; - return view; -} - -void copy_to_mdspan(const TextureTensor& tensor, tp::View3Df image) -{ - unsigned int width = static_cast(tensor.shape(1)); - unsigned int height = static_cast(tensor.shape(0)); - unsigned int num_channels = static_cast(tensor.shape(2)); - - la_runtime_assert( - image.extent(0) == width && image.extent(1) == height && image.extent(2) == num_channels, - "Tensor and mdspan dimensions do not match"); - - for (unsigned int j = 0; j < height; j++) { - for (unsigned int i = 0; i < width; i++) { - for (unsigned int c = 0; c < num_channels; c++) { - image(i, j, c) = tensor(j, i, c); - } - } - } -} - -nb::object mdarray_to_tensor(const tp::Array3Df& array_) -{ - // Numpy indexes tensors as (row, col, channel), but our mdspan uses (x, y, channel) - // coordinates, so we need to transpose the first two dimensions. - auto array = const_cast(array_); - auto tensor = Tensor( - static_cast(array.data()), - { - array.extent(1), - array.extent(0), - array.extent(2), - }, - nb::handle(), - { - static_cast(array.stride(1)), - static_cast(array.stride(0)), - static_cast(array.stride(2)), - }); - return tensor.cast(); -} - void populate_texproc_module(nb::module_& m) { using Scalar = double; @@ -119,7 +50,7 @@ void populate_texproc_module(nb::module_& m) m.def( "texture_filtering", [](const SurfaceMesh& mesh, - const TextureTensor& image_, + const ImageTensor& image_, double value_weight, double gradient_weight, double gradient_scale, @@ -131,7 +62,7 @@ void populate_texproc_module(nb::module_& m) image_.shape(0), image_.shape(1), image_.shape(2)); - copy_to_mdspan(image_, image.to_mdspan()); + copy_tensor_to_image_view(image_, image.to_mdspan()); tp::FilteringOptions options; options.value_weight = value_weight; @@ -144,7 +75,7 @@ void populate_texproc_module(nb::module_& m) tp::texture_filtering(mesh, image.to_mdspan(), options); - return mdarray_to_tensor(image); + return image_array_to_tensor(image); }, "mesh"_a, "image"_a, @@ -173,7 +104,7 @@ void populate_texproc_module(nb::module_& m) m.def( "texture_stitching", [](const SurfaceMesh& mesh, - const TextureTensor& image_, + const ImageTensor& image_, bool exterior_only, unsigned int quadrature_samples, double jitter_epsilon, @@ -183,7 +114,7 @@ void populate_texproc_module(nb::module_& m) image_.shape(0), image_.shape(1), image_.shape(2)); - copy_to_mdspan(image_, image.to_mdspan()); + copy_tensor_to_image_view(image_, image.to_mdspan()); tp::StitchingOptions options; options.exterior_only = exterior_only; @@ -194,7 +125,7 @@ void populate_texproc_module(nb::module_& m) tp::texture_stitching(mesh, image.to_mdspan(), options); - return mdarray_to_tensor(image); + return image_array_to_tensor(image); }, "mesh"_a, "image"_a, @@ -219,7 +150,7 @@ void populate_texproc_module(nb::module_& m) m.def( "geodesic_dilation", [](const SurfaceMesh& mesh, - const TextureTensor& image_, + const ImageTensor& image_, float dilation_radius) { tp::DilationOptions options; options.dilation_radius = dilation_radius; @@ -228,11 +159,11 @@ void populate_texproc_module(nb::module_& m) image_.shape(0), image_.shape(1), image_.shape(2)); - copy_to_mdspan(image_, image.to_mdspan()); + copy_tensor_to_image_view(image_, image.to_mdspan()); tp::geodesic_dilation(mesh, image.to_mdspan(), options); - return mdarray_to_tensor(image); + return image_array_to_tensor(image); }, "mesh"_a, "image"_a, @@ -263,7 +194,7 @@ void populate_texproc_module(nb::module_& m) tp::geodesic_dilation(mesh, image.to_mdspan(), options); - return mdarray_to_tensor(image); + return image_array_to_tensor(image); }, "mesh"_a, "width"_a, @@ -281,8 +212,8 @@ void populate_texproc_module(nb::module_& m) m.def( "texture_compositing", [](const SurfaceMesh& mesh, - const std::vector>& textures, - const std::vector>& weights, + const std::vector>& textures, + const std::vector>& weights, double value_weight, unsigned int quadrature_samples, double jitter_epsilon, @@ -297,8 +228,8 @@ void populate_texproc_module(nb::module_& m) std::vector> weighted_textures; for (const auto kk : range(textures.size())) { - const tp::View3Df texture = tensor_to_mdspan(textures[kk]); - const tp::View3Df weight = tensor_to_mdspan(weights[kk]); + const tp::View3Df texture = tensor_to_image_view(textures[kk]); + const tp::View3Df weight = tensor_to_image_view(weights[kk]); weighted_textures.emplace_back( tp::ConstWeightedTextureView{ texture, @@ -318,7 +249,7 @@ void populate_texproc_module(nb::module_& m) auto image = tp::texture_compositing(mesh, weighted_textures, options); - return mdarray_to_tensor(image); + return image_array_to_tensor(image); }, "mesh"_a, "colors"_a, @@ -351,14 +282,14 @@ void populate_texproc_module(nb::module_& m) m.def( "rasterize_textures_from_renders", [](const scene::Scene& scene, - const std::vector>& renders, + const std::vector>& renders, const std::optional width, const std::optional height, const float low_confidence_ratio, const std::optional base_confidence) { std::vector views; for (const auto& render : renders) { - views.push_back(tensor_to_mdspan(render)); + views.push_back(tensor_to_image_view(render)); } auto textures_and_weights = tp::rasterize_textures_from_renders( @@ -373,8 +304,8 @@ void populate_texproc_module(nb::module_& m) std::vector textures; std::vector weights; for (auto& [texture_, weight_] : textures_and_weights) { - auto texture = mdarray_to_tensor(texture_); - auto weight = mdarray_to_tensor(weight_); + auto texture = image_array_to_tensor(texture_); + auto weight = image_array_to_tensor(weight_); textures.emplace_back(texture); weights.emplace_back(weight); } @@ -397,6 +328,35 @@ void populate_texproc_module(nb::module_& m) :param base_confidence: Confidence value for the base texture if present in the scene. If set to 0, ignore the base texture of the mesh. Defaults to 0.3 otherwise. :return: A pair of lists (textures, weights), one per camera.)"); + + m.def( + "extract_mesh_with_alpha_mask", + [](const SurfaceMesh32d& mesh, + const ImageTensor& image_, + const std::optional texcoord_id, + const float alpha_threshold) -> SurfaceMesh32d { + const auto image = tensor_to_image_view(image_); + + tp::ExtractMeshWithAlphaMaskOptions options; + if (texcoord_id) options.texcoord_id = *texcoord_id; + options.alpha_threshold = alpha_threshold; + + auto mesh_ = tp::extract_mesh_with_alpha_mask(mesh, image, options); + + return mesh_; + }, + "mesh"_a, + "image"_a, + "texcoord_id"_a = nb::none(), + "alpha_threshold"_a = tp::ExtractMeshWithAlphaMaskOptions().alpha_threshold, + R"(Convert a unwrapped triangle mesh with non-opaque texture to tesselated mesh. + +:param mesh: Input mesh. +:param image: RGBA non-opaque texture. +:param texcoord_id: Indexed UV attribute id. +:param alpha_threshold: Opaque mask theshold. + +:returns: Tesselated mesh.)"); } } // namespace lagrange::python diff --git a/modules/texproc/python/tests/assets.py b/modules/texproc/python/tests/assets.py index 83edbbcc..14db739e 100644 --- a/modules/texproc/python/tests/assets.py +++ b/modules/texproc/python/tests/assets.py @@ -11,7 +11,6 @@ # import lagrange -from pathlib import Path import numpy as np import pytest import math diff --git a/modules/texproc/python/tests/test_mesh_with_alpha_mask.py b/modules/texproc/python/tests/test_mesh_with_alpha_mask.py new file mode 100644 index 00000000..f7447c0f --- /dev/null +++ b/modules/texproc/python/tests/test_mesh_with_alpha_mask.py @@ -0,0 +1,88 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# + +import lagrange +import numpy as np +from pathlib import Path +import pytest + + +def load_alpha_data(scene_path: Path): + scene_options = lagrange.io.LoadOptions() + scene_options.stitch_vertices = True + scene = lagrange.io.load_scene(scene_path, scene_options) + + # retrieve single instance + assert len(scene.nodes) == 1 + node = scene.nodes[0] + assert len(node.meshes) == 1 + instance = node.meshes[0] + + # retrieve material + assert len(instance.materials) == 1 + material = scene.materials[instance.materials[0]] + assert material.alpha_mode == lagrange.scene.Material.AlphaMode.Blend + assert material.alpha_cutoff >= 0.0 + assert material.alpha_cutoff <= 1.0 + image = scene.images[material.base_color_texture.index].image.data + assert image.shape[2] == 4 + + # retrieve mesh + mesh = scene.meshes[instance.mesh] + texcoord_id = mesh.get_attribute_id(f"texcoord_{material.base_color_texture.texcoord}") + lagrange.cast_attribute(mesh, texcoord_id, np.float64) + assert mesh.is_attribute_indexed(texcoord_id) + assert mesh.is_triangle_mesh + + # move mesh to world space + node_transform = lagrange.scene.compute_global_node_transform(scene, 0) + + return mesh, texcoord_id, image, material.alpha_cutoff, node_transform + + +all_alpha_datas = ( + [] + if lagrange.variant == "open" + else list( + map( + lambda pp: load_alpha_data(Path("data/corp/texproc") / pp), + [Path("alpha_cube_numbers.glb"), Path("alpha_cube_letters.glb")], + ) + ) +) + + +@pytest.mark.skipif( + lagrange.variant == "open", + reason="Test requires corp data", +) +@pytest.mark.parametrize( + "alpha_data", + all_alpha_datas, +) +class TestMeshWithAlphaMask: + def test_extract_alpha_cube(self, alpha_data): + mesh, texcoord_id, image, alpha_threshold, transform = alpha_data + + # extract tessellated mesh + mesh_ = lagrange.texproc.extract_mesh_with_alpha_mask( + mesh, + image, + texcoord_id, + alpha_threshold, + ) + assert mesh_.num_facets > 0 + + def test_transform(self, alpha_data): + mesh, texcoord_id, image, alpha_threshold, transform = alpha_data + + assert np.abs(transform - np.eye(4)).max() < 1e-7 diff --git a/modules/texproc/python/tests/test_texproc.py b/modules/texproc/python/tests/test_texproc.py index f02c50e5..65111420 100644 --- a/modules/texproc/python/tests/test_texproc.py +++ b/modules/texproc/python/tests/test_texproc.py @@ -11,12 +11,9 @@ # import lagrange -import math -import pytest import numpy as np -import logging -from .assets import quad_scene, quad_mesh, quad_tex, cube_with_uv +from .assets import quad_scene, quad_mesh, quad_tex, cube_with_uv # noqa: F401 class TestTextureProcessing: diff --git a/modules/texproc/shared/shared_utils.h b/modules/texproc/shared/shared_utils.h index a10463c2..dd075d65 100644 --- a/modules/texproc/shared/shared_utils.h +++ b/modules/texproc/shared/shared_utils.h @@ -33,6 +33,7 @@ namespace lagrange::texproc { using Array3Df = image::experimental::Array3D; using View3Df = image::experimental::View3D; +// FIXME this strips non-color channel, other variants of this function don't. Array3Df convert_from(const scene::ImageBufferExperimental& image) { size_t nc = std::min(image.num_channels, size_t(3)); diff --git a/modules/texproc/src/extract_mesh_with_alpha_mask.cpp b/modules/texproc/src/extract_mesh_with_alpha_mask.cpp new file mode 100644 index 00000000..832904aa --- /dev/null +++ b/modules/texproc/src/extract_mesh_with_alpha_mask.cpp @@ -0,0 +1,268 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace lagrange::texproc { + +template +void rasterize_triangle_data( + const Eigen::Matrix& texcoords, + const size_t width, + const size_t height, + Callback callback) +{ + const auto to_coordinate = [&](Eigen::Vector uv) -> Eigen::Vector { + auto ii = static_cast(uv(0) * static_cast(width)); + auto jj = static_cast(uv(1) * static_cast(height)); + ii = std::min(ii, width - 1); + jj = std::min(jj, height - 1); + la_debug_assert(ii < width); + la_debug_assert(jj < height); + return {ii, jj}; + }; + + const auto from_coordinate = + [&](const Eigen::Vector& ij) -> Eigen::Vector { + la_debug_assert(ij(0) < width); + la_debug_assert(ij(1) < height); + const auto uu = (static_cast(ij(0)) + 0.5) / static_cast(width); + const auto vv = (static_cast(ij(1)) + 0.5) / static_cast(height); + return {uu, vv}; + }; + + const auto wedge = [](const Eigen::Vector& xx, const Eigen::Vector& yy) { + return xx(0) * yy(1) - xx(1) * yy(0); + }; + + Eigen::AlignedBox bbox_uv; + for (auto uv : texcoords.rowwise()) { + bbox_uv.extend(uv.transpose()); + } + const auto coordinate_min = to_coordinate(bbox_uv.min()); + const auto coordinate_max = to_coordinate(bbox_uv.max()); + + const Eigen::Vector q0 = texcoords.row(0).transpose(); + const Eigen::Vector q1 = texcoords.row(1).transpose(); + const Eigen::Vector q2 = texcoords.row(2).transpose(); + const Scalar area = wedge(q1 - q0, q2 - q0); + if (std::fabs(area) <= 1e-7) return; + + const auto barycentric_weights = + [&](const Eigen::Vector& ij) -> Eigen::Vector { + const auto uv = from_coordinate(ij); + la_debug_assert(uv(0) >= 0.0); + la_debug_assert(uv(1) >= 0.0); + la_debug_assert(uv(0) < 1.0); + la_debug_assert(uv(1) < 1.0); + Scalar w0 = wedge(q1 - uv, q2 - uv); + Scalar w1 = wedge(q2 - uv, q0 - uv); + Scalar w2 = wedge(q0 - uv, q1 - uv); + const Scalar area_ = w0 + w1 + w2; + la_debug_assert(std::fabs(area_ - area) < 1e-6); + w0 /= area_; + w1 /= area_; + w2 /= area_; + [[maybe_unused]] const auto ww = Eigen::Vector(w0, w1, w2); + la_debug_assert(std::fabs(ww.sum() - 1.0) < 1e-6); + return {w0, w1, w2}; + }; + + for (const auto jj : lagrange::range(coordinate_min(1), coordinate_max(1))) { + la_debug_assert(jj < height - 1); + for (const auto ii : lagrange::range(coordinate_min(0), coordinate_max(0))) { + la_debug_assert(ii < width - 1); + const auto ij_bottom_left = Eigen::Vector(ii, jj); + const auto ij_bottom_right = Eigen::Vector(ii + 1, jj); + const auto ij_top_left = Eigen::Vector(ii, jj + 1); + const auto ij_top_right = Eigen::Vector(ii + 1, jj + 1); + const auto ww_bottom_left = barycentric_weights(ij_bottom_left); + const auto ww_bottom_right = barycentric_weights(ij_bottom_right); + const auto ww_top_left = barycentric_weights(ij_top_left); + const auto ww_top_right = barycentric_weights(ij_top_right); + const auto ijs = std::array, 4>{ + ij_bottom_left, + ij_bottom_right, + ij_top_right, + ij_top_left, + }; + const auto wws = std::array, 4>{ + ww_bottom_left, + ww_bottom_right, + ww_top_right, + ww_top_left, + }; + callback(ijs, wws); + } + } +} + + +template +auto extract_mesh_with_alpha_mask( + const SurfaceMesh& mesh, + const image::experimental::View3D image, + const ExtractMeshWithAlphaMaskOptions& options) -> SurfaceMesh +{ + AttributeId texcoord_id = options.texcoord_id; + if (texcoord_id == invalid_attribute_id()) { + texcoord_id = + find_matching_attribute(mesh, AttributeUsage::UV).value_or(invalid_attribute_id()); + } + + la_runtime_assert(texcoord_id != invalid_attribute_id()); + la_runtime_assert(options.alpha_threshold >= 0.0); + la_runtime_assert(options.alpha_threshold <= 1.0); + + la_runtime_assert(mesh.get_dimension() == 3); + la_runtime_assert(mesh.is_triangle_mesh()); + la_runtime_assert(mesh.is_attribute_indexed(texcoord_id)); + + const auto width = image.extent(0); + const auto height = image.extent(1); + la_runtime_assert(width > 0); + la_runtime_assert(height > 0); + la_runtime_assert(image.extent(2) == 4, "expected rgba image"); + + logger().debug( + "mesh {}v{}e{}f using {}", + mesh.get_num_vertices(), + mesh.get_num_edges(), + mesh.get_num_facets(), + mesh.get_attribute_name(texcoord_id)); + logger().debug("texture {}x{}x{}", image.extent(0), image.extent(1), image.extent(2)); + + const auto& texcoord_attr = mesh.template get_indexed_attribute(texcoord_id); + const auto texcoord_indices = reshaped_view(texcoord_attr.indices(), 3); + const auto texcoord_values = matrix_view(texcoord_attr.values()); + const auto vertices = vertex_view(mesh); + const auto facets = facet_view(mesh); + const auto get_triangle_data = [&](Index ff) { + Eigen::Matrix triangle_texcoords; + Eigen::Matrix triangle_vertices; + for (const Eigen::Index ii : lagrange::range(3)) { + triangle_texcoords.row(ii) = texcoord_values.row(texcoord_indices(ff, ii)); + triangle_vertices.row(ii) = vertices.row(facets(ff, ii)); + } + la_runtime_assert(triangle_texcoords.minCoeff() >= 0.0); + la_runtime_assert(triangle_texcoords.maxCoeff() <= 1.0); + return std::make_pair(triangle_texcoords, triangle_vertices); + }; + + tbb::concurrent_vector> triangles; + tbb::concurrent_vector> quads; + const auto loop = [&](const Index ff) { + const auto triangle_data = get_triangle_data(ff); + const auto& triangle_texcoords = std::get<0>(triangle_data); + const auto& triangle_vertices = std::get<1>(triangle_data); + rasterize_triangle_data( + triangle_texcoords, + width, + height, + [&](const std::array, 4>& ijs, + const std::array, 4>& wws) { + la_debug_assert(ijs.size() == 4); + la_debug_assert(wws.size() == 4); + Eigen::Vector mean_wws = Eigen::Vector::Zero(); + for (const auto& ww : wws) mean_wws += ww; + mean_wws.array() /= static_cast(wws.size()); + const bool center_outside_triangle = mean_wws.minCoeff() < 0.0; + StackVector, 4> pps; + for (const auto kk : lagrange::range(4)) { + const auto& ww = wws[kk]; + if (center_outside_triangle && ww.minCoeff() < 0.0) continue; + const auto& ij = ijs[kk]; + la_debug_assert(ij(0) < width); + la_debug_assert(ij(1) < height); + // Flip v because (0, 0) is assumed to be the bottom-left corner. + const bool is_opaque = + image(ij(0), height - 1 - ij(1), 3) > options.alpha_threshold; + if (!is_opaque) continue; + const Eigen::Vector pp = triangle_vertices.transpose() * ww; + pps.emplace_back(pp); + } + switch (pps.size()) { + case 0: + case 1: + case 2: break; + case 3: { + Eigen::Matrix triangle; + triangle.row(0) = pps[0].transpose(); + triangle.row(1) = pps[1].transpose(); + triangle.row(2) = pps[2].transpose(); + triangles.emplace_back(triangle); + } break; + case 4: { + Eigen::Matrix quad; + quad.row(0) = pps[0].transpose(); + quad.row(1) = pps[1].transpose(); + quad.row(2) = pps[2].transpose(); + quad.row(3) = pps[3].transpose(); + quads.emplace_back(quad); + } break; + default: la_runtime_assert(false); break; + } + }); + }; + tbb::parallel_for(static_cast(0), mesh.get_num_facets(), loop); + logger().debug("num_triangles {}", triangles.size()); + logger().debug("num_quads {}", quads.size()); + + SurfaceMesh mesh_; + for (const auto& triangle : triangles) { + const auto ii = mesh_.get_num_vertices(); + mesh_.add_vertices(3, [&](const Index vv, span pp) { + la_debug_assert(vv < 3); + la_debug_assert(pp.size() == 3); + const auto pp_ = triangle.row(vv); + std::copy(pp_.data(), pp_.data() + pp.size(), pp.begin()); + }); + la_debug_assert(mesh_.get_num_vertices() == ii + 3); + mesh_.add_triangle(ii + 2, ii + 1, ii + 0); + } + for (const auto& quad : quads) { + const auto ii = mesh_.get_num_vertices(); + mesh_.add_vertices(4, [&](const Index vv, span pp) { + la_debug_assert(vv < 4); + la_debug_assert(pp.size() == 3); + const auto pp_ = quad.row(vv); + std::copy(pp_.data(), pp_.data() + pp.size(), pp.begin()); + }); + la_debug_assert(mesh_.get_num_vertices() == ii + 4); + mesh_.add_quad(ii + 3, ii + 2, ii + 1, ii + 0); + } + + return mesh_; +} + +#define LA_X_extract_mesh_with_alpha_mask(_, Scalar, Index) \ + template auto extract_mesh_with_alpha_mask( \ + const SurfaceMesh& mesh, \ + const image::experimental::View3D image, \ + const ExtractMeshWithAlphaMaskOptions& options) -> SurfaceMesh; + +LA_SURFACE_MESH_X(extract_mesh_with_alpha_mask, 0) + +} // namespace lagrange::texproc diff --git a/modules/texproc/src/mesh_utils.h b/modules/texproc/src/mesh_utils.h index 0f74c486..36dadfa6 100644 --- a/modules/texproc/src/mesh_utils.h +++ b/modules/texproc/src/mesh_utils.h @@ -269,24 +269,29 @@ void jitter_texture( // Add combinatorial stiffness regularization template -Eigen::SparseMatrix laplacian_regularization(Eigen::SparseMatrix S, Scalar eps) +Eigen::SparseMatrix laplacian_regularization(Eigen::SparseMatrix S, Scalar weight) { - if (eps > 0) { - tbb::parallel_for(size_t(0), size_t(S.outerSize()), [&](size_t c) { + if (std::abs(weight) > std::numeric_limits().denorm_min()) { + la_runtime_assert(S.rows() == S.cols()); + la_runtime_assert(S.nonZeros() >= S.rows()); + la_runtime_assert(weight > Scalar(0)); + + tbb::parallel_for(size_t(0), size_t(S.outerSize()), [&S, weight](size_t c) { size_t count = 0; for (typename Eigen::SparseMatrix::InnerIterator it(S, c); it; ++it) { if (it.row() != it.col()) { - it.valueRef() -= eps; + it.valueRef() -= weight; ++count; } } for (typename Eigen::SparseMatrix::InnerIterator it(S, c); it; ++it) { if (it.row() == it.col()) { - it.valueRef() += eps * count; + it.valueRef() += weight * count; } } }); } + return S; } diff --git a/modules/texproc/src/texture_filtering.cpp b/modules/texproc/src/texture_filtering.cpp index b4c4e7c6..d3416d6c 100644 --- a/modules/texproc/src/texture_filtering.cpp +++ b/modules/texproc/src/texture_filtering.cpp @@ -78,30 +78,35 @@ void texture_gradient_modulation( x[n] = grid(coords.first, coords.second); } + // Compute the system matrix + const double eps = options.stiffness_regularization_weight; + const Eigen::SparseMatrix S_reg = + mesh_utils::laplacian_regularization(gd.stiffness(), eps); + const Eigen::SparseMatrix M = + gd.mass() * options.value_weight + S_reg * options.gradient_weight; + // Construct the constraints { std::vector> mass_b(gd.numNodes()); - std::vector> stiffness_b(gd.numNodes()); // Get the constraints from the values gd.mass(&x[0], &mass_b[0]); - // Get the constraints from the gradients - gd.stiffness(&x[0], &stiffness_b[0]); - - // Combine the constraints - for (size_t n = 0; n < gd.numNodes(); n++) { - b[n] = mass_b[n] * options.value_weight + - stiffness_b[n] * options.gradient_weight * options.gradient_scale; + // Get the constraints from the gradients using S_reg (not raw stiffness) + // This ensures consistency between LHS and RHS + for (unsigned int c = 0; c < NumChannels; c++) { + Eigen::VectorXd x_c(gd.numNodes()); + for (size_t n = 0; n < gd.numNodes(); n++) { + x_c[n] = x[n][c]; + } + Eigen::VectorXd stiffness_b_c = S_reg * x_c; + for (size_t n = 0; n < gd.numNodes(); n++) { + b[n][c] = mass_b[n][c] * options.value_weight + + stiffness_b_c[n] * options.gradient_weight * options.gradient_scale; + } } } - // Compute the system matrix - const double eps = options.stiffness_regularization_weight; - Eigen::SparseMatrix M = - gd.mass() * options.value_weight + - mesh_utils::laplacian_regularization(gd.stiffness(), eps) * options.gradient_weight; - // Construct/factor the solver Solver solver(M); switch (solver.info()) { diff --git a/modules/texproc/src/texture_stitching.cpp b/modules/texproc/src/texture_stitching.cpp index a20852a1..6955bdc5 100644 --- a/modules/texproc/src/texture_stitching.cpp +++ b/modules/texproc/src/texture_stitching.cpp @@ -112,7 +112,6 @@ void texture_stitching( } std::vector> x(gd.numNodes()); - std::vector> b(gd.numNodes()); // Copy the texture values into the vector for (size_t n = 0; n < gd.numNodes(); n++) { @@ -137,13 +136,11 @@ void texture_stitching( } } - // Construct the constraints - gd.stiffness(&x[0], &b[0]); - // Compute the system matrix const double eps = options.stiffness_regularization_weight; - Eigen::SparseMatrix M = - Pt * mesh_utils::laplacian_regularization(gd.stiffness(), eps) * P; + const Eigen::SparseMatrix S_reg = + mesh_utils::laplacian_regularization(gd.stiffness(), eps); + const Eigen::SparseMatrix M = Pt * S_reg * P; // Construct/factor the solver Solver solver(M); @@ -156,12 +153,13 @@ void texture_stitching( } // Solve the system per channel + // The constraints (rhs) are b = S_reg * x, reduced to b' = Pt * b for (unsigned int c = 0; c < NumChannels; c++) { Eigen::VectorXd _b(gd.numNodes()); for (size_t n = 0; n < gd.numNodes(); n++) { - _b[n] = b[n][c]; + _b[n] = x[n][c]; } - _b = Pt * _b; + _b = Pt * (S_reg * _b); Eigen::VectorXd _x = P * solver.solve(_b); for (size_t n = 0; n < gd.numNodes(); n++) { x[n][c] -= _x[n]; diff --git a/modules/texproc/tests/image_helpers.cpp b/modules/texproc/tests/image_helpers.cpp new file mode 100644 index 00000000..7363499e --- /dev/null +++ b/modules/texproc/tests/image_helpers.cpp @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include "image_helpers.h" + +#include + +auto test::scene_image_to_image_array(const lagrange::scene::ImageBufferExperimental& image) + -> lagrange::image::experimental::Array3D +{ + auto result = lagrange::image::experimental::create_image( + image.width, + image.height, + image.num_channels); + + auto copy_buffer = [&](auto scalar) { + using T = std::decay_t; + constexpr bool IsChar = std::is_integral_v && sizeof(T) == 1; + la_runtime_assert(sizeof(T) * 8 == image.get_bits_per_element()); + auto rawbuf = reinterpret_cast(image.data.data()); + for (size_t y = 0, i = 0; y < image.height; ++y) { + for (size_t x = 0; x < image.width; ++x) { + for (size_t c = 0; c < image.num_channels; ++c) { + if constexpr (IsChar) { + result(x, y, c) = static_cast(rawbuf[i++]) / 255.f; + } else { + result(x, y, c) = rawbuf[i++]; + } + } + } + } + }; + + using lagrange::AttributeValueType; + switch (image.element_type) { + case AttributeValueType::e_uint8_t: copy_buffer(uint8_t()); break; + case AttributeValueType::e_int8_t: copy_buffer(int8_t()); break; + case AttributeValueType::e_uint32_t: copy_buffer(uint32_t()); break; + case AttributeValueType::e_int32_t: copy_buffer(int32_t()); break; + case AttributeValueType::e_float: copy_buffer(float()); break; + case AttributeValueType::e_double: copy_buffer(double()); break; + default: throw std::runtime_error("Unsupported image scalar type"); + } + + return result; +} diff --git a/modules/texproc/tests/image_helpers.h b/modules/texproc/tests/image_helpers.h new file mode 100644 index 00000000..2cd655f7 --- /dev/null +++ b/modules/texproc/tests/image_helpers.h @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include + +namespace test { + +// This transposes the first two dimensions of the image. +// x_0 maps to width, x_1 maps to height and x_2 maps to channel. +// Also assumes (0, 0) is the top-left corner of the image. + +auto scene_image_to_image_array(const lagrange::scene::ImageBufferExperimental& image) + -> lagrange::image::experimental::Array3D; + +} // namespace test diff --git a/modules/texproc/tests/test_mesh_with_alpha_mask.cpp b/modules/texproc/tests/test_mesh_with_alpha_mask.cpp new file mode 100644 index 00000000..1e0f0a5b --- /dev/null +++ b/modules/texproc/tests/test_mesh_with_alpha_mask.cpp @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include "image_helpers.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include + +void run_mesh_with_alpha_mask(const lagrange::fs::path& path) +{ + auto scene_options = lagrange::io::LoadOptions(); + scene_options.stitch_vertices = true; + auto scene = lagrange::io::load_scene(path, scene_options); + + // retrieve single instance + REQUIRE(scene.nodes.size() == 1); + const auto& node = scene.nodes.front(); + REQUIRE(node.meshes.size() == 1); + const auto& instance = node.meshes.front(); + + // retrieve material + REQUIRE(instance.materials.size() == 1); + const auto& material = scene.materials.at(instance.materials.front()); + REQUIRE(material.alpha_mode == lagrange::scene::MaterialExperimental::AlphaMode::Blend); + REQUIRE(material.alpha_cutoff >= 0.0); + REQUIRE(material.alpha_cutoff <= 1.0); + const auto image = + test::scene_image_to_image_array(scene.images.at(material.base_color_texture.index).image); + REQUIRE(image.extent(2) == 4); + + // retrieve mesh + auto mesh = scene.meshes.at(instance.mesh); + const auto texcoord_id = + mesh.get_attribute_id(fmt::format("texcoord_{}", material.base_color_texture.texcoord)); + REQUIRE(texcoord_id != lagrange::invalid_attribute_id()); + REQUIRE(mesh.is_attribute_indexed(texcoord_id)); + REQUIRE(mesh.is_triangle_mesh()); + + SECTION("check transform") + { + const auto node_transform = lagrange::scene::utils::compute_global_node_transform(scene, 0); + REQUIRE(node_transform.isApprox(Eigen::Affine3f::Identity())); + } + + SECTION("extract mesh") + { + // extract mesh from mesh and alpha mask + lagrange::texproc::ExtractMeshWithAlphaMaskOptions extract_options; + extract_options.texcoord_id = texcoord_id; + extract_options.alpha_threshold = material.alpha_cutoff; + const auto mesh_ = lagrange::texproc::extract_mesh_with_alpha_mask( + mesh, + image.to_mdspan(), + extract_options); + REQUIRE(mesh_.get_num_facets() > 0); + } +} + +TEST_CASE("extract cube with transparent numbers", "[texproc][alpha_mask]" LA_CORP_FLAG) +{ + const auto path = lagrange::testing::get_data_dir() / "corp/texproc/alpha_cube_numbers.glb"; + run_mesh_with_alpha_mask(path); +} + +TEST_CASE("extract cube with transparent letters", "[texproc][alpha_mask]" LA_CORP_FLAG) +{ + const auto path = lagrange::testing::get_data_dir() / "corp/texproc/alpha_cube_letters.glb"; + run_mesh_with_alpha_mask(path); +} diff --git a/modules/texproc/tests/test_texture_filtering.cpp b/modules/texproc/tests/test_texture_filtering.cpp index f91295e1..b6bfecee 100644 --- a/modules/texproc/tests/test_texture_filtering.cpp +++ b/modules/texproc/tests/test_texture_filtering.cpp @@ -46,7 +46,7 @@ void require_approx_mdspan(View3Df a, View3Df b, float eps_rel = 1e-5f, float ep } // namespace // TODO: Also run in debug mode with 128x128 texture? -TEST_CASE("texture filtering", "[texproc]" LA_SLOW_DEBUG_FLAG) +TEST_CASE("texture filtering", "[texproc][filtering]" LA_SLOW_DEBUG_FLAG) { using Scalar = double; using Index = uint32_t; diff --git a/modules/texproc/tests/test_texture_stitching.cpp b/modules/texproc/tests/test_texture_stitching.cpp index 717cc0c0..5fa5e481 100644 --- a/modules/texproc/tests/test_texture_stitching.cpp +++ b/modules/texproc/tests/test_texture_stitching.cpp @@ -47,7 +47,7 @@ void require_approx_mdspan(View3Df a, View3Df b, float eps_rel = 1e-5f, float ep } // namespace -TEST_CASE("texture stitching quad", "[texproc]") +TEST_CASE("texture stitching quad", "[texproc][stitching]") { using Scalar = double; using Index = uint32_t; @@ -72,7 +72,7 @@ TEST_CASE("texture stitching quad", "[texproc]") REQUIRE_NOTHROW(lagrange::texproc::texture_stitching(quad_mesh, img.to_mdspan())); } -TEST_CASE("texture stitching cube", "[texproc]") +TEST_CASE("texture stitching cube", "[texproc][stitching]") { using Scalar = double; using Index = uint32_t; @@ -84,7 +84,7 @@ TEST_CASE("texture stitching cube", "[texproc]") REQUIRE_NOTHROW(lagrange::texproc::texture_stitching(mesh, img.to_mdspan())); } -TEST_CASE("texture stitching", "[texproc]" LA_SLOW_DEBUG_FLAG) +TEST_CASE("texture stitching", "[texproc][stitching]" LA_SLOW_DEBUG_FLAG) { using Scalar = double; using Index = uint32_t; @@ -126,7 +126,7 @@ TEST_CASE("texture stitching", "[texproc]" LA_SLOW_DEBUG_FLAG) } } -TEST_CASE("Penguin with flips", "[texproc]" LA_SLOW_DEBUG_FLAG LA_CORP_FLAG) +TEST_CASE("Penguin with flips", "[texproc][stitching]" LA_SLOW_DEBUG_FLAG LA_CORP_FLAG) { using Scalar = double; using Index = uint32_t; diff --git a/modules/ui/examples/ui_playground/main.cpp b/modules/ui/examples/ui_playground/main.cpp index 2a580586..16f90407 100644 --- a/modules/ui/examples/ui_playground/main.cpp +++ b/modules/ui/examples/ui_playground/main.cpp @@ -261,14 +261,14 @@ int main(int argc, char** argv) /* * Register new window type, set behavior of the window */ - auto panel_fn = [](ui::Registry& registry, ui::Entity e) { - auto& s = registry.get(e); + auto panel_fn = [](ui::Registry& registry_, ui::Entity e) { + auto& s = registry_.get(e); ImGui::Text("Local panel state:"); ImGui::InputInt("x", &s.x); ImGui::InputInt("y", &s.y); ImGui::Text("Shared state from other system:"); - auto pos = ui::get_input(registry).mouse.position; + auto pos = ui::get_input(registry_).mouse.position; ImGui::InputFloat2("Mouse pos:", pos.data()); ImGui::Text("Shared state created and modified by these panels"); @@ -280,12 +280,12 @@ int main(int argc, char** argv) ui::Entity viz_e = ui::NullEntity; }; - auto& priv = registry.ctx().emplace(MyPrivateContextVar{16.0f}); + auto& priv = registry_.ctx().emplace(MyPrivateContextVar{16.0f}); ImGui::InputFloat("MyPrivateContextVar.x:", &priv.x); if (priv.viz_e != ui::NullEntity) { - ui::show_widget(registry, priv.viz_e, entt::resolve(entt::type_id())); + ui::show_widget(registry_, priv.viz_e, entt::resolve(entt::type_id())); } }; @@ -343,7 +343,7 @@ int main(int argc, char** argv) float t = 0; - viewer.run([&](ui::Registry& registry) { + viewer.run([&](ui::Registry& registry_) { t += float(viewer.get_frame_elapsed_time()); @@ -352,8 +352,8 @@ int main(int argc, char** argv) a = Eigen::Translation3f(0, std::sin(t), 0); b = Eigen::Translation3f(std::sin(t), 0, 0); Eigen::Matrix4f bones[2] = {a.matrix(), b.matrix()}; - if (registry.valid(obj_pbr)) { - ui::get_material(registry, obj_pbr)->set_mat4_array("bones", bones, 2); + if (registry_.valid(obj_pbr)) { + ui::get_material(registry_, obj_pbr)->set_mat4_array("bones", bones, 2); } return true; diff --git a/modules/volume/CMakeLists.txt b/modules/volume/CMakeLists.txt index b0abe3a8..1db86c5a 100644 --- a/modules/volume/CMakeLists.txt +++ b/modules/volume/CMakeLists.txt @@ -15,10 +15,12 @@ lagrange_add_module(NO_INSTALL) # 2. dependencies lagrange_include_modules(core winding) lagrange_find_package(OpenVDB CONFIG REQUIRED GLOBAL COMPONENTS openvdb) -target_link_libraries(lagrange_volume PUBLIC - lagrange::core - lagrange::winding - OpenVDB::openvdb +target_link_libraries(lagrange_volume + PUBLIC + lagrange::core + OpenVDB::openvdb + PRIVATE + lagrange::winding ) # 3. unit tests and examples diff --git a/modules/volume/examples/register_grid.h b/modules/volume/examples/register_grid.h index b9c234d4..22dfdb8c 100644 --- a/modules/volume/examples/register_grid.h +++ b/modules/volume/examples/register_grid.h @@ -9,7 +9,6 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ - #pragma once #include diff --git a/modules/volume/include/lagrange/volume/legacy/mesh_to_volume.h b/modules/volume/include/lagrange/volume/legacy/mesh_to_volume.h index 2a58821b..d9eeec5d 100644 --- a/modules/volume/include/lagrange/volume/legacy/mesh_to_volume.h +++ b/modules/volume/include/lagrange/volume/legacy/mesh_to_volume.h @@ -86,7 +86,7 @@ class MeshAdapter /// /// @param[in] mesh Input mesh. /// @param[in] voxel_size Grid voxel size. If the target voxel size is too small, an exception -/// will will be raised. +/// will be raised. /// /// @tparam MeshType Mesh type. /// @tparam GridType OpenVDB grid type. diff --git a/modules/volume/python/src/volume.cpp b/modules/volume/python/src/volume.cpp index 1657df41..97082efd 100644 --- a/modules/volume/python/src/volume.cpp +++ b/modules/volume/python/src/volume.cpp @@ -398,6 +398,15 @@ void populate_volume_module(nb::module_& m) [](const GridWrapper& self) { return self.grid()->activeVoxelCount(); }, "Return the number of active voxels in the grid."); + g.def_prop_ro( + "background", + [](const GridWrapper& self) { + std::variant result; + apply_or_fail(self.grid(), [&](auto&& grid) { result = grid.background(); }); + return result; + }, + "Return the grid background value."); + g.def_prop_ro( "bbox_index", [](const GridWrapper& self) { diff --git a/modules/volume/python/tests/assets.py b/modules/volume/python/tests/assets.py index 934fb1fe..f1666094 100644 --- a/modules/volume/python/tests/assets.py +++ b/modules/volume/python/tests/assets.py @@ -1,5 +1,5 @@ # -# Copyright 2026 Adobe. All rights reserved. +# Copyright 2025 Adobe. All rights reserved. # This file is licensed to you under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. You may obtain a copy # of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/volume/python/tests/test_volume.py b/modules/volume/python/tests/test_volume.py index 89f8e5df..b56f562b 100644 --- a/modules/volume/python/tests/test_volume.py +++ b/modules/volume/python/tests/test_volume.py @@ -12,11 +12,10 @@ import lagrange from lagrange.lagrange.volume import Grid # why?? import numpy as np -import pytest import tempfile import pathlib -from .assets import cube +from .assets import cube # noqa: F401 class TestMeshToVolume: diff --git a/modules/volume/src/mesh_to_volume.cpp b/modules/volume/src/mesh_to_volume.cpp index e51d5e57..e49255dd 100644 --- a/modules/volume/src/mesh_to_volume.cpp +++ b/modules/volume/src/mesh_to_volume.cpp @@ -190,7 +190,16 @@ auto mesh_to_volume(const SurfaceMesh& mesh_, const MeshToVolumeO throw; } - logger().debug("Computed grid has {} active voxels", grid->activeVoxelCount()); + const auto bbox = grid->evalActiveVoxelBoundingBox(); + auto nx = bbox.max().x() - bbox.min().x() + 1; + auto ny = bbox.max().y() - bbox.min().y() + 1; + auto nz = bbox.max().z() - bbox.min().z() + 1; + logger().debug( + "Computed grid has {} active voxels in {}x{}x{}", + grid->activeVoxelCount(), + nx, + ny, + nz); return grid; } diff --git a/modules/volume/tests/CMakeLists.txt b/modules/volume/tests/CMakeLists.txt index bf5d2308..fcd7df1e 100644 --- a/modules/volume/tests/CMakeLists.txt +++ b/modules/volume/tests/CMakeLists.txt @@ -10,3 +10,5 @@ # governing permissions and limitations under the License. # lagrange_add_test() +lagrange_include_modules(scene) +target_link_libraries(test_lagrange_volume PRIVATE lagrange::scene) diff --git a/pyproject.toml b/pyproject.toml index 239e91f5..7f0270bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,5 @@ [build-system] build-backend = "scikit_build_core.build" - requires = [ "numpy>=1.25", # needed at build time for default dtype args "scikit-build-core==0.11.6", @@ -8,12 +7,11 @@ requires = [ ] [project] -name = "lagrange-open" +name = "adobe-lagrange" description = "A robust geometry processing engine" readme = "README.md" license = "Apache-2.0" license-files = [ "LICENSE", "NOTICE.txt" ] - maintainers = [ { name = "Lagrange Core Team", email = "lagrange-core@adobe.com" } ] requires-python = ">=3.10" classifiers = [ @@ -35,7 +33,6 @@ dependencies = [ "numpy>=1.25", "scipy>=1.13.0", ] - optional-dependencies.docs = [ "furo==2025.9.25; python_version>='3.12'", "sphinx==8.2.3; python_version>='3.12'", @@ -73,13 +70,10 @@ ninja.version = ">=1.11.1" ninja.make-fallback = false install.components = [ "Lagrange_Python_Runtime" ] wheel.packages = [ "modules/python/lagrange" ] - build-dir = "build-python" editable.rebuild = false - metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" sdist.include = [ "modules/python/lagrange/_version.py" ] - # Debug related options # cmd: uv sync [--verbose] [--reinstall-package lagrange] # --------------------- @@ -106,30 +100,33 @@ LAGRANGE_MODULE_POISSON = true LAGRANGE_MODULE_POLYDDG = true LAGRANGE_MODULE_PRIMITIVE = true LAGRANGE_MODULE_PYTHON = true +LAGRANGE_MODULE_RAYCASTING = true LAGRANGE_MODULE_SCENE = true LAGRANGE_MODULE_SOLVER = true LAGRANGE_MODULE_SUBDIVISION = true -LAGRANGE_MODULE_TEXPROC = true LAGRANGE_MODULE_VOLUME = true +LAGRANGE_MODULE_TEXPROC = true LAGRANGE_UNIT_TESTS = false LAGRANGE_WITH_ASSIMP = true TBB_PREFER_STATIC = false +[tool.uv] +python-preference = "only-managed" + [tool.black] line_length = 100 [tool.ruff] line-length = 100 +lint.extend-per-file-ignores."modules/**/python/tests/test_*.py" = [ "F811" ] [tool.pyproject-fmt] indent = 4 keep_full_version = true +expand_tables = [ "tool.scikit-build.cmake.define" ] -[tool.pytest.ini_options] -addopts = [ +[tool.pytest] +ini_options.addopts = [ "--import-mode=importlib", ] -pythonpath = "." - -[tool.uv] -python-preference = "only-managed" +ini_options.pythonpath = "."