From 98229da03d8647be3b636097b41acf4b1b527845 Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Mon, 5 Oct 2026 08:56:47 -0400 Subject: [PATCH 1/2] Sync with d60445f --- cmake/lagrange/lagrange_add_test.cmake | 36 +- cmake/lagrange/lagrange_download_data.cmake | 2 +- .../lagrange_runtime_dependencies.cmake | 3 + cmake/lagrange/lagrange_vcpkg_toolchain.cmake | 3 +- cmake/recipes/external/embree.cmake | 21 +- .../external/{embree.patch => embree3.patch} | 0 cmake/recipes/external/embree4.patch | 16 + .../external/instant-meshes-core.cmake | 2 +- cmake/recipes/external/pcdio.cmake | 34 + docs/cpp/doxyfile-predefined.txt | 1 + .../include/lagrange/bvh/resolve_tjunctions.h | 9 +- .../bvh/src/internal/resolve_tjunctions.cpp | 36 +- .../bvh/src/internal/split_tjunction_edges.h | 155 +++ modules/bvh/src/resolve_tjunctions.cpp | 37 +- modules/bvh/tests/test_resolve_tjunctions.cpp | 143 ++- modules/core/include/lagrange/mesh_bbox.h | 24 + .../lagrange/triangulate_polygonal_facets.h | 7 +- modules/core/include/lagrange/utils/build.h | 8 + .../core/include/lagrange/utils/fmt/join.h | 65 +- modules/core/js/src/core_utilities.cpp | 22 +- modules/core/js/ts/core.ts | 26 +- modules/core/python/src/bind_debug.h | 29 + modules/core/python/src/bind_utilities.h | 46 +- modules/core/python/src/core.cpp | 2 + modules/core/python/tests/test_chain_edges.py | 80 ++ modules/core/python/tests/test_debug.py | 25 + .../test_triangulate_polygonal_facets.py | 66 +- modules/core/src/internal/split_edges.cpp | 21 +- modules/core/src/mapbox/earcut.h | 759 +++++++++++---- modules/core/src/mesh_bbox.cpp | 26 +- .../core/src/triangulate_polygonal_facets.cpp | 30 +- modules/core/tests/fmt/test_fmt.cpp | 18 + modules/core/tests/test_mesh_bbox.cpp | 42 + .../test_triangulate_polygonal_facets.cpp | 88 ++ .../geometrycentral/GeodesicEngineFlip.cpp | 192 ++++ .../geometrycentral/geometry_central_utils.h | 14 + modules/geodesic/geodesic.md | 6 +- .../include/lagrange/geodesic/Algorithm.h | 8 +- .../lagrange/geodesic/GeodesicEngineFlip.h | 132 +++ modules/geodesic/python/src/geodesic.cpp | 26 + .../geodesic/python/tests/test_geodesic.py | 34 + modules/geodesic/src/GeodesicEngine.cpp | 3 +- .../tests/test_geodesic_path_flip.cpp | 202 ++++ modules/io/CMakeLists.txt | 2 + .../lagrange/io/internal/detect_file_format.h | 5 +- .../io/include/lagrange/io/load_mesh_pcd.h | 60 ++ .../io/include/lagrange/io/save_mesh_pcd.h | 65 ++ modules/io/include/lagrange/io/types.h | 2 +- modules/io/io.md | 6 + modules/io/python/src/io.cpp | 14 +- modules/io/python/tests/test_io.py | 39 + .../io/src/internal/detect_file_format.cpp | 55 +- modules/io/src/internal/pcd_utils.h | 70 ++ modules/io/src/load_mesh.cpp | 4 + modules/io/src/load_mesh_pcd.cpp | 190 ++++ modules/io/src/save_mesh.cpp | 4 + modules/io/src/save_mesh_pcd.cpp | 240 +++++ modules/io/src/stitch_mesh.h | 3 + modules/io/tests/test_load_gltf.cpp | 18 +- modules/io/tests/test_msh.cpp | 31 + modules/io/tests/test_obj.cpp | 27 + modules/io/tests/test_pcd.cpp | 371 ++++++++ modules/io/tests/test_ply.cpp | 28 + modules/python/CMakeLists.txt | 2 + .../raycasting/examples/remove_occluded.cpp | 382 ++++++-- .../raycasting/remove_occluded_facets.h | 185 ++-- .../raycasting/remove_occluded_instances.h | 151 ++- modules/raycasting/python/src/raycasting.cpp | 398 ++++++-- modules/raycasting/python/tests/conftest.py | 102 ++ .../python/tests/test_remove_occluded.py | 280 +++++- .../raycasting/src/occluded_sampler_common.h | 263 ++++-- .../raycasting/src/remove_occluded_facets.cpp | 887 ++++++++++++++---- .../src/remove_occluded_instances.cpp | 490 +++++++--- .../lagrange/scene/simple_scene_bbox.h | 40 + modules/scene/python/src/bind_value.h | 2 +- modules/scene/src/simple_scene_bbox.cpp | 43 + .../scene/tests/test_simple_scene_bbox.cpp | 95 ++ pyproject.toml | 8 +- 78 files changed, 6028 insertions(+), 1033 deletions(-) rename cmake/recipes/external/{embree.patch => embree3.patch} (100%) create mode 100644 cmake/recipes/external/embree4.patch create mode 100644 cmake/recipes/external/pcdio.cmake create mode 100644 modules/bvh/src/internal/split_tjunction_edges.h create mode 100644 modules/core/python/src/bind_debug.h create mode 100644 modules/core/python/tests/test_chain_edges.py create mode 100644 modules/core/python/tests/test_debug.py create mode 100644 modules/geodesic/extras/geometrycentral/GeodesicEngineFlip.cpp create mode 100644 modules/geodesic/include/lagrange/geodesic/GeodesicEngineFlip.h create mode 100644 modules/geodesic/tests/test_geodesic_path_flip.cpp create mode 100644 modules/io/include/lagrange/io/load_mesh_pcd.h create mode 100644 modules/io/include/lagrange/io/save_mesh_pcd.h create mode 100644 modules/io/src/internal/pcd_utils.h create mode 100644 modules/io/src/load_mesh_pcd.cpp create mode 100644 modules/io/src/save_mesh_pcd.cpp create mode 100644 modules/io/tests/test_pcd.cpp create mode 100644 modules/scene/include/lagrange/scene/simple_scene_bbox.h create mode 100644 modules/scene/src/simple_scene_bbox.cpp create mode 100644 modules/scene/tests/test_simple_scene_bbox.cpp diff --git a/cmake/lagrange/lagrange_add_test.cmake b/cmake/lagrange/lagrange_add_test.cmake index 5e2b0476..e6606118 100644 --- a/cmake/lagrange/lagrange_add_test.cmake +++ b/cmake/lagrange/lagrange_add_test.cmake @@ -17,7 +17,7 @@ function(lagrange_add_test) # Retrieve options set(options CUSTOM_MAIN) set(oneValueArgs "") - set(multiValueArgs "") + set(multiValueArgs ENVIRONMENT) cmake_parse_arguments(OPTIONS "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) # Create test executable @@ -44,13 +44,6 @@ function(lagrange_add_test) include(FetchContent) target_code_coverage(${test_target} AUTO ALL EXCLUDE "${FETCHCONTENT_BASE_DIR}/*") - # Sanitizer suppression files to be passed to catch_discover_tests - set(LAGRANGE_TESTS_ENVIRONMENT - "TSAN_OPTIONS=suppressions=${PROJECT_SOURCE_DIR}/.github/tsan.suppressions.ini" - "LSAN_OPTIONS=suppressions=${PROJECT_SOURCE_DIR}/.github/lsan.suppressions.ini" - "ASAN_SAVE_DUMPS=${module_name}.dmp" - ) - # Output directory set_target_properties(${test_target} PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tests") @@ -99,13 +92,36 @@ function(lagrange_add_test) OUTPUT_DIR "${CMAKE_BINARY_DIR}/reports" OUTPUT_SUFFIX ".xml" DISCOVERY_MODE ${_discovery_mode} - PROPERTIES ENVIRONMENT ${LAGRANGE_TESTS_ENVIRONMENT} + TEST_LIST ${test_target}_TESTS ) else() catch_discover_tests(${test_target} DISCOVERY_MODE ${_discovery_mode} - PROPERTIES ENVIRONMENT ${LAGRANGE_TESTS_ENVIRONMENT} + TEST_LIST ${test_target}_TESTS ) endif() + # Assemble env vars for every discovered test: sanitizer suppressions plus any caller-supplied + # entries via the ENVIRONMENT argument. Caller entries are appended last so they can override. + set(_test_env + "TSAN_OPTIONS=suppressions=${PROJECT_SOURCE_DIR}/.github/tsan.suppressions.ini" + "LSAN_OPTIONS=suppressions=${PROJECT_SOURCE_DIR}/.github/lsan.suppressions.ini" + "ASAN_SAVE_DUMPS=${module_name}.dmp" + ${OPTIONS_ENVIRONMENT} + ) + list(JOIN _test_env ";" _test_env_joined) + + # Work around Catch2 bug by applying the complete environment list after discovery; + # Catch populates ${test_target}_TESTS before this TEST_INCLUDE_FILES script runs. + # https://github.com/catchorg/Catch2/issues/2424 + set(_sanitizer_env_fixup "${CMAKE_CURRENT_BINARY_DIR}/${test_target}_sanitizer_env.cmake") + file(WRITE "${_sanitizer_env_fixup}" + "foreach(_t IN LISTS ${test_target}_TESTS)\n" + " set_tests_properties(\"\${_t}\" PROPERTIES ENVIRONMENT\n" + " \"${_test_env_joined}\"\n" + " )\n" + "endforeach()\n" + ) + set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES "${_sanitizer_env_fixup}") + endfunction() diff --git a/cmake/lagrange/lagrange_download_data.cmake b/cmake/lagrange/lagrange_download_data.cmake index 93655b1e..c5a5ea29 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 009e99371d7495f7ad81d42d32080ba8afd4d4cd + GIT_TAG 819110152ce4ccc9ad8459bd046702564a71b568 CONFIGURE_COMMAND "" BUILD_COMMAND "" INSTALL_COMMAND "" diff --git a/cmake/lagrange/lagrange_runtime_dependencies.cmake b/cmake/lagrange/lagrange_runtime_dependencies.cmake index b52165d6..7198a321 100644 --- a/cmake/lagrange/lagrange_runtime_dependencies.cmake +++ b/cmake/lagrange/lagrange_runtime_dependencies.cmake @@ -116,11 +116,14 @@ function(lagrange_populate_runtime_dependencies target) endif() # Instruction to copy target file if it exists + # TODO: Remove lock file guard when https://gitlab.kitware.com/cmake/cmake/-/work_items/27205 is fixed. string(APPEND COPY_SCRIPT_CONTENT "if(EXISTS \"$\")\n" " message(\"Copying dll file: $ for target ${target}\")\n" " file(MAKE_DIRECTORY \"$\")\n" + " file(LOCK \"${CMAKE_BINARY_DIR}/runtime_deps/$.lock\" GUARD PROCESS TIMEOUT 120)\n" " file(COPY_FILE \"$\" \"$/$\" ONLY_IF_DIFFERENT INPUT_MAY_BE_RECENT)\n" + " file(LOCK \"${CMAKE_BINARY_DIR}/runtime_deps/$.lock\" RELEASE)\n" " if(NOT EXISTS \"$/$\")\n" " message(FATAL_ERROR \"Failed to copy dll file: $ for target ${target}. Target folder: $\")\n" " endif()\n" diff --git a/cmake/lagrange/lagrange_vcpkg_toolchain.cmake b/cmake/lagrange/lagrange_vcpkg_toolchain.cmake index 051c196b..b373d66c 100644 --- a/cmake/lagrange/lagrange_vcpkg_toolchain.cmake +++ b/cmake/lagrange/lagrange_vcpkg_toolchain.cmake @@ -37,12 +37,11 @@ endif() CPMAddPackage( NAME vcpkg GIT_REPOSITORY https://github.com/microsoft/vcpkg.git - GIT_TAG 2025.01.13 + GIT_TAG 2026.06.24 QUIET ) set(ENV{VCPKG_ROOT} "${vcpkg_SOURCE_DIR}") -set(ENV{VCPKG_KEEP_ENV_VARS} "VCPKG_ROOT;$ENV{VCPKG_KEEP_ENV_VARS}") if(WIN32) CPMAddPackage( diff --git a/cmake/recipes/external/embree.cmake b/cmake/recipes/external/embree.cmake index 6e926f93..04ffd5a4 100644 --- a/cmake/recipes/external/embree.cmake +++ b/cmake/recipes/external/embree.cmake @@ -108,17 +108,29 @@ function(embree_import_target) endif() set(TBB_LIBRARIES TBB) + # Embree's MSVC AVX512 kernels have been observed to segfault at runtime on some + # Windows machines. Disable AVX512 (and APX, which depends on it) on MSVC until this is root-caused upstream. + # TODO: Report and fix issue upstream. See CGT-774 for internal tracking. + if(MSVC) + set(EMBREE_ISA_AVX512 OFF CACHE BOOL "Enables AVX512 ISA." FORCE) + set(EMBREE_ISA_APX OFF CACHE BOOL "Enables APX ISA." FORCE) + endif() + # Ready to include embree's atrocious CMake include(CPM) - set(EMBREE_VERSION v4.4.0) - set(EMBREE_PATCHES "") + set(EMBREE_VERSION 3d9cb89b9ea099c630e6272d37767e7dd4e78e74) # ahead of 4.4.1 + set(EMBREE_PATCHES) + if(EMSCRIPTEN) + # TODO: Remove when https://github.com/RenderKit/embree/pull/633 is merged + set(EMBREE_PATCHES PATCHES embree4.patch) + endif() 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) + set(EMBREE_PATCHES PATCHES embree3.patch) endif() CPMAddPackage( NAME embree @@ -155,7 +167,7 @@ function(embree_import_target) endif() # Suppress kernel dispatch function casts and AccelSet downcasts, not all UBSan checks. - # Keep exclusions private; GCC has no function-pointer sanitizer. + # https://github.com/RenderKit/embree/issues/635 if(USE_SANITIZER MATCHES "([Uu]ndefined)") foreach(target IN ITEMS embree @@ -166,6 +178,7 @@ function(embree_import_target) embree_apx ) if(TARGET ${target}) + # Note: GCC 13 has no function-pointer sanitizer. target_compile_options(${target} PRIVATE $<$:-fno-sanitize=vptr> $<$:-fno-sanitize=function> diff --git a/cmake/recipes/external/embree.patch b/cmake/recipes/external/embree3.patch similarity index 100% rename from cmake/recipes/external/embree.patch rename to cmake/recipes/external/embree3.patch diff --git a/cmake/recipes/external/embree4.patch b/cmake/recipes/external/embree4.patch new file mode 100644 index 00000000..ba157938 --- /dev/null +++ b/cmake/recipes/external/embree4.patch @@ -0,0 +1,16 @@ +diff --git i/common/simd/vboolf4_sse2.h w/common/simd/vboolf4_sse2.h +index 9b85adb70..8c16d730a 100644 +--- i/common/simd/vboolf4_sse2.h ++++ w/common/simd/vboolf4_sse2.h +@@ -58,10 +58,10 @@ namespace embree + #if !defined(__EMSCRIPTEN__) + __forceinline operator const __m128i() const { return _mm_castps_si128(v); } + __forceinline operator const __m128d() const { return _mm_castps_pd(v); } ++ #endif + /* kept for source compatibility with code that calls .m128i()/.m128d() explicitly */ + __forceinline const __m128i m128i() const { return _mm_castps_si128(v); } + __forceinline const __m128d m128d() const { return _mm_castps_pd(v); } +- #endif + #endif + + __forceinline vboolf(bool a) diff --git a/cmake/recipes/external/instant-meshes-core.cmake b/cmake/recipes/external/instant-meshes-core.cmake index 3b651718..a0502a43 100644 --- a/cmake/recipes/external/instant-meshes-core.cmake +++ b/cmake/recipes/external/instant-meshes-core.cmake @@ -19,7 +19,7 @@ include(CPM) CPMAddPackage( NAME instant-meshes-core GITHUB_REPOSITORY qnzhou/instant-meshes-core - GIT_TAG 8c87f12bec4b98ce29febcf5dd63ebb90e957104 + GIT_TAG 632605af06eae75c9aaa6a61a0e551c64de02b71 ) add_library(instant-meshes-core::instant-meshes-core ALIAS instant-meshes-core) diff --git a/cmake/recipes/external/pcdio.cmake b/cmake/recipes/external/pcdio.cmake new file mode 100644 index 00000000..83f65b26 --- /dev/null +++ b/cmake/recipes/external/pcdio.cmake @@ -0,0 +1,34 @@ +# +# 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. +# +if(TARGET pcdio::pcdio) + return() +endif() + +message(STATUS "Third-party (external): creating target 'pcdio::pcdio'") + +include(CPM) +block() + set(CMAKE_FOLDER "third_party") + CPMAddPackage( + NAME pcdio + GIT_REPOSITORY https://github.com/adobe/pcdio.git + GIT_TAG 2bf236d8a93a52957e4a752968d79926b7e30235 + OPTIONS + "PCDIO_BUILD_TESTS OFF" + "PCDIO_BUILD_EXAMPLES OFF" + "PCDIO_PYTHON OFF" + ) +endblock() + +if(TARGET pcdio) + set_target_properties(pcdio PROPERTIES FOLDER third_party POSITION_INDEPENDENT_CODE ON) +endif() diff --git a/docs/cpp/doxyfile-predefined.txt b/docs/cpp/doxyfile-predefined.txt index 00e722db..44c782b5 100644 --- a/docs/cpp/doxyfile-predefined.txt +++ b/docs/cpp/doxyfile-predefined.txt @@ -43,6 +43,7 @@ PREDEFINED = LA_IGNORE_DOCUMENTATION_WARNING_BEGIN= \ LA_SUBDIVISION_API= \ LA_TESTING_API= \ LA_TEXPROC_API= \ + LA_TRIANGULATION_API= \ LA_UI_API= \ LA_USD_API= \ LA_VOLUME_API= \ diff --git a/modules/bvh/include/lagrange/bvh/resolve_tjunctions.h b/modules/bvh/include/lagrange/bvh/resolve_tjunctions.h index 0e48332a..d492e8e8 100644 --- a/modules/bvh/include/lagrange/bvh/resolve_tjunctions.h +++ b/modules/bvh/include/lagrange/bvh/resolve_tjunctions.h @@ -50,10 +50,11 @@ struct ResolveTJunctionsOptions /// Vertices are not moved: only edges and facets are subdivided so the topology conforms to the /// existing vertex positions. The `tolerance` only controls detection, not geometric snapping. /// -/// Both triangle and polygonal meshes are supported. By default (`triangulate_affected == true`) -/// the facets touched by a split are triangulated, so a triangle-mesh input yields a triangle-mesh -/// output. Set `triangulate_affected` to false to instead keep those facets as polygons, with the -/// split points inserted as additional (collinear) boundary vertices. +/// Both triangle and polygonal meshes are supported. By default (`triangulate_affected == true`), +/// affected triangular facets are retriangulated directly, even in a hybrid mesh; affected +/// polygonal facets use polygon triangulation. Set `triangulate_affected` to false to instead +/// keep all affected facets as polygons, with the split points inserted as additional (collinear) +/// boundary vertices. /// /// @param[in,out] mesh Input mesh (triangle or polygonal). Modified in place. /// @param[in] options Optional settings. diff --git a/modules/bvh/src/internal/resolve_tjunctions.cpp b/modules/bvh/src/internal/resolve_tjunctions.cpp index f7d6b894..155dd237 100644 --- a/modules/bvh/src/internal/resolve_tjunctions.cpp +++ b/modules/bvh/src/internal/resolve_tjunctions.cpp @@ -11,11 +11,11 @@ */ #include +#include "split_tjunction_edges.h" + #include #include #include -#include -#include #include #include #include @@ -30,6 +30,8 @@ #include #include +#include +#include #include #include @@ -76,8 +78,9 @@ void resolve_tjunctions_impl( Tree tree; tree.build({boxes.data(), boxes.size()}); - // For each edge, query the tree with the edge's tolerance-expanded box for candidate vertices. + // Query split points and classify only facets incident to split edges in parallel. std::vector>> edge_splits(num_edges); + std::atomic affected_types{0}; tbb::parallel_for(Index(0), num_edges, [&](Index e) { if (options.boundary_only && !mesh.is_boundary_edge(e)) return; auto ev = mesh.get_edge_vertices(e); @@ -108,6 +111,9 @@ void resolve_tjunctions_impl( edge_splits[e].emplace_back(t, v); return true; })); + if (options.triangulate_affected && !edge_splits[e].empty()) { + classify_affected_facets(mesh, e, affected_types); + } std::sort(edge_splits[e].begin(), edge_splits[e].end(), [](const auto& a, const auto& b) { return a.first < b.first; }); @@ -123,23 +129,15 @@ void resolve_tjunctions_impl( if (split_pts.empty()) return; - // Split edges without retriangulating: each affected facet gets a polygonal copy appended at - // id >= old_num_facets, leaving the originals (to be removed) in place. - const Index old_num_facets = mesh.get_num_facets(); - auto facets_to_remove = lagrange::internal::split_edges_only( + auto get_edge_split_pts = [&](Index e) -> span { + const Index n = edge_split_offsets[e + 1] - edge_split_offsets[e]; + return span(split_pts.data() + edge_split_offsets[e], n); + }; + split_tjunction_edges( mesh, - function_ref(Index)>([&](Index e) -> span { - const Index n = edge_split_offsets[e + 1] - edge_split_offsets[e]; - return span(split_pts.data() + edge_split_offsets[e], n); - }), - function_ref([](Index) { return true; })); - - // Optionally triangulate only the new facets, then drop the original split facets. - if (options.triangulate_affected) { - auto is_new_facet = [old_num_facets](Index f) { return f >= old_num_facets; }; - triangulate_polygonal_facets(mesh, function_ref(is_new_facet)); - } - mesh.remove_facets(facets_to_remove); + function_ref(Index)>(get_edge_split_pts), + options.triangulate_affected, + affected_types.load(std::memory_order_relaxed)); } } // namespace diff --git a/modules/bvh/src/internal/split_tjunction_edges.h b/modules/bvh/src/internal/split_tjunction_edges.h new file mode 100644 index 00000000..711d8386 --- /dev/null +++ b/modules/bvh/src/internal/split_tjunction_edges.h @@ -0,0 +1,155 @@ +/* + * 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 +#include +#include + +namespace lagrange::bvh::internal { + +enum : std::uint8_t { AffectedTriangle = 1, AffectedPolygon = 2 }; + +template +void classify_affected_facets( + SurfaceMesh& mesh, + Index edge, + std::atomic& affected_types) +{ + const auto known = affected_types.load(std::memory_order_relaxed); + if (known == (AffectedTriangle | AffectedPolygon)) return; + std::uint8_t found = 0; + for (Index c = mesh.get_first_corner_around_edge(edge); c != invalid(); + c = mesh.get_next_corner_around_edge(c)) { + found |= + mesh.get_facet_size(mesh.get_corner_facet(c)) == 3 ? AffectedTriangle : AffectedPolygon; + if ((known | found) == (AffectedTriangle | AffectedPolygon)) break; + } + if (found & ~known) affected_types.fetch_or(found, std::memory_order_relaxed); +} + +// Both splitters retain the original facets, so their IDs remain stable until the final removal. +// Only the mixed case needs a second pass and an edge-ID remap after split_edges clears the edges. +template +void split_tjunction_edges( + SurfaceMesh& mesh, + function_ref(Index)> get_edge_split_pts, + bool triangulate_affected, + std::uint8_t affected_types) +{ + auto all_active = [](Index) { return true; }; + std::vector facets_to_remove; + + if (!triangulate_affected || !(affected_types & AffectedTriangle)) { + const Index old_num_facets = mesh.get_num_facets(); + facets_to_remove = lagrange::internal::split_edges_only( + mesh, + get_edge_split_pts, + function_ref(all_active)); + if (triangulate_affected) { + auto is_new_facet = [old_num_facets](Index f) { return f >= old_num_facets; }; + TriangulationOptions options; + options.scheme = TriangulationOptions::Scheme::Delaunay; + triangulate_polygonal_facets( + mesh, + function_ref(is_new_facet), + std::move(options)); + } + } else if (!(affected_types & AffectedPolygon)) { + facets_to_remove = lagrange::internal::split_edges( + mesh, + get_edge_split_pts, + function_ref(all_active)); + } else { + const Index original_num_facets = mesh.get_num_facets(); + + // In the very rare case that the region affected by T-junctions contains both triangles + // and polygons, we need to split the edges in two passes. Unfortunately, extra book + // keeping is needed to remap the split points of the original polygon edges after + // split_edges clears the edge table. + struct SplitPolygonEdge + { + Index edge; + Index v0; + Index v1; + }; + std::vector polygon_edges; + for (Index e = 0; e < mesh.get_num_edges(); ++e) { + if (get_edge_split_pts(e).empty()) continue; + for (Index c = mesh.get_first_corner_around_edge(e); c != invalid(); + c = mesh.get_next_corner_around_edge(c)) { + if (mesh.get_facet_size(mesh.get_corner_facet(c)) == 3) continue; + const auto ev = mesh.get_edge_vertices(e); + polygon_edges.push_back({e, ev[0], ev[1]}); + break; + } + } + + auto is_original_triangle = [&](Index f) { + return f < original_num_facets && mesh.get_facet_size(f) == 3; + }; + facets_to_remove = lagrange::internal::split_edges( + mesh, + get_edge_split_pts, + function_ref(is_original_triangle)); + + // split_edges clears the edge table. The original polygon facets still carry their old + // edges; rebuild it and map only their split edges to the original CSR ranges. + mesh.initialize_edges(); + std::vector old_edge_for_new_edge(mesh.get_num_edges(), invalid()); + for (const auto& old : polygon_edges) { + const Index e = mesh.find_edge_from_vertices(old.v0, old.v1); + la_debug_assert(e != invalid()); + old_edge_for_new_edge[e] = old.edge; + if (mesh.get_edge_vertices(e)[0] != old.v0) { + auto pts = get_edge_split_pts(old.edge); + std::reverse(pts.begin(), pts.end()); + } + } + auto get_remapped_split_pts = [&](Index e) -> span { + const Index old = old_edge_for_new_edge[e]; + return old == invalid() ? span() : get_edge_split_pts(old); + }; + auto is_original_polygon = [&](Index f) { + return f < original_num_facets && mesh.get_facet_size(f) != 3; + }; + const Index before_polygons = mesh.get_num_facets(); + auto polygons_to_remove = lagrange::internal::split_edges_only( + mesh, + function_ref(Index)>(get_remapped_split_pts), + function_ref(is_original_polygon)); + auto is_new_polygon = [before_polygons](Index f) { return f >= before_polygons; }; + TriangulationOptions options; + options.scheme = TriangulationOptions::Scheme::Delaunay; + triangulate_polygonal_facets( + mesh, + function_ref(is_new_polygon), + std::move(options)); + facets_to_remove.insert( + facets_to_remove.end(), + polygons_to_remove.begin(), + polygons_to_remove.end()); + std::sort(facets_to_remove.begin(), facets_to_remove.end()); + } + mesh.remove_facets(facets_to_remove); +} + +} // namespace lagrange::bvh::internal diff --git a/modules/bvh/src/resolve_tjunctions.cpp b/modules/bvh/src/resolve_tjunctions.cpp index a105ba98..eb99be1f 100644 --- a/modules/bvh/src/resolve_tjunctions.cpp +++ b/modules/bvh/src/resolve_tjunctions.cpp @@ -11,11 +11,10 @@ */ #include +#include "internal/split_tjunction_edges.h" + #include #include -#include -#include -#include #include #include #include @@ -28,6 +27,8 @@ // clang-format on #include +#include +#include #include #include #include @@ -104,12 +105,16 @@ void resolve_tjunctions(SurfaceMesh& mesh, ResolveTJunctionsOptio } }; - // Pass 1: count split points per edge, then prefix-sum into CSR offsets. + // Pass 1: count split points and classify only facets incident to split edges. std::vector edge_split_offsets(num_edges + 1, 0); + std::atomic affected_types{0}; tbb::parallel_for(Index(0), num_edges, [&](Index e) { Index count = 0; for_each_split_on_edge(e, [&](Scalar, Index) { ++count; }); edge_split_offsets[e + 1] = count; + if (options.triangulate_affected && count != 0) { + internal::classify_affected_facets(mesh, e, affected_types); + } }); for (Index e = 0; e < num_edges; e++) edge_split_offsets[e + 1] += edge_split_offsets[e]; @@ -131,23 +136,15 @@ void resolve_tjunctions(SurfaceMesh& mesh, ResolveTJunctionsOptio for (Index i = begin; i < end; i++) split_pts[i] = split_scratch[i].second; }); - // Split edges without retriangulating: each affected facet gets a polygonal copy appended at - // id >= old_num_facets, leaving the originals (to be removed) in place. - const Index old_num_facets = mesh.get_num_facets(); - auto facets_to_remove = lagrange::internal::split_edges_only( + auto get_edge_split_pts = [&](Index e) -> span { + const Index n = edge_split_offsets[e + 1] - edge_split_offsets[e]; + return span(split_pts.data() + edge_split_offsets[e], n); + }; + internal::split_tjunction_edges( mesh, - function_ref(Index)>([&](Index e) -> span { - const Index n = edge_split_offsets[e + 1] - edge_split_offsets[e]; - return span(split_pts.data() + edge_split_offsets[e], n); - }), - function_ref([](Index) { return true; })); - - // Optionally triangulate only the new facets, then drop the original split facets. - if (options.triangulate_affected) { - auto is_new_facet = [old_num_facets](Index f) { return f >= old_num_facets; }; - triangulate_polygonal_facets(mesh, function_ref(is_new_facet)); - } - mesh.remove_facets(facets_to_remove); + function_ref(Index)>(get_edge_split_pts), + options.triangulate_affected, + affected_types.load(std::memory_order_relaxed)); } #define LA_X_resolve_tjunctions(_, Scalar, Index) \ diff --git a/modules/bvh/tests/test_resolve_tjunctions.cpp b/modules/bvh/tests/test_resolve_tjunctions.cpp index 0ffdca47..e529735d 100644 --- a/modules/bvh/tests/test_resolve_tjunctions.cpp +++ b/modules/bvh/tests/test_resolve_tjunctions.cpp @@ -21,10 +21,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -154,8 +156,9 @@ void run_resolve_tjunctions_tests(Resolve&& resolve) REQUIRE(mesh.find_edge_from_vertices(5, 1) != invalid()); } - SECTION("triangulate_affected=true triangulates the split facet (the default)") + SECTION("triangulate_affected=true triangulates the split facet (polygon fallback)") { + // A non-triangle (quad) input falls back to triangulate_polygonal_facets. auto mesh = make_mesh(); Options options; options.triangulate_affected = true; @@ -248,6 +251,144 @@ void run_resolve_tjunctions_tests(Resolve&& resolve) } } + SECTION("unaffected polygon does not force polygon triangulation") + { + auto mesh = testing::load_surface_mesh("open/core/tjunction.fbx"); + const Index q0 = mesh.get_num_vertices(); + mesh.add_vertex({10, 10, 0}); + mesh.add_vertex({11, 10, 0}); + mesh.add_vertex({11, 11, 0}); + mesh.add_vertex({10, 11, 0}); + mesh.add_quad(q0, q0 + 1, q0 + 2, q0 + 3); + REQUIRE(mesh.get_num_facets() == 9); + + Options options; + options.boundary_only = false; + resolve(mesh, options); + + mesh.initialize_edges(); + REQUIRE(mesh.get_num_facets() == 14); + std::vector triangles; + Index num_quads = 0; + for (Index f = 0; f < mesh.get_num_facets(); ++f) { + if (mesh.get_facet_size(f) == 3) triangles.push_back(f); + if (mesh.get_facet_size(f) == 4) ++num_quads; + } + REQUIRE(num_quads == 1); + + // Split triangles fan to their opposite corner, avoiding the polygon fallback's slivers. + const auto vertices = vertex_view(mesh); + Scalar min_area = std::numeric_limits::max(); + Scalar total_area = 0; + for (Index f : triangles) { + const auto fv = mesh.get_facet_vertices(f); + const Eigen::Matrix e1 = vertices.row(fv[1]) - vertices.row(fv[0]); + const Eigen::Matrix e2 = vertices.row(fv[2]) - vertices.row(fv[0]); + const Scalar area = Scalar(0.5) * e1.cross(e2).norm(); + min_area = std::min(min_area, area); + total_area += area; + } + REQUIRE(min_area > Scalar(1e-3) * total_area / static_cast(triangles.size())); + } + + SECTION("affected polygon does not degrade split triangles") + { + auto mesh = testing::load_surface_mesh("open/core/tjunction.fbx"); + const Index num_triangle_vertices = mesh.get_num_vertices(); + const Index q = num_triangle_vertices; + mesh.add_vertex({10, 10, 0}); + mesh.add_vertex({12, 10, 0}); + mesh.add_vertex({12, 11, 0}); + mesh.add_vertex({10, 11, 0}); + mesh.add_vertex({10.5, 10, 0}); + mesh.add_vertex({11.5, 10, 0}); + mesh.add_quad(q, q + 1, q + 2, q + 3); + + Options options; + options.boundary_only = false; + resolve(mesh, options); + + // Both components have split edges; a split quad must not change the triangle strategy. + REQUIRE(mesh.get_num_facets() == 17); + const auto vertices = vertex_view(mesh); + Scalar min_area = std::numeric_limits::max(); + Scalar total_area = 0; + Index num_triangles = 0; + for (Index f = 0; f < mesh.get_num_facets(); ++f) { + const auto fv = mesh.get_facet_vertices(f); + REQUIRE(fv.size() == 3); + if (std::any_of(fv.begin(), fv.end(), [num_triangle_vertices](Index v) { + return v >= num_triangle_vertices; + })) { + continue; + } + const Eigen::Matrix e1 = vertices.row(fv[1]) - vertices.row(fv[0]); + const Eigen::Matrix e2 = vertices.row(fv[2]) - vertices.row(fv[0]); + const Scalar area = Scalar(0.5) * e1.cross(e2).norm(); + min_area = std::min(min_area, area); + total_area += area; + ++num_triangles; + } + REQUIRE(num_triangles == 13); + REQUIRE(min_area > Scalar(1e-3) * total_area / num_triangles); + } + + SECTION("mixed facets sharing a split edge retain indexed attributes") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); // 0 + mesh.add_vertex({4, 0, 0}); // 1 + mesh.add_vertex({2, 2, 0}); // 2: triangle apex + mesh.add_vertex({4, -2, 0}); // 3 + mesh.add_vertex({0, -2, 0}); // 4 + mesh.add_vertex({1, 0, 0}); // 5: first split point + mesh.add_vertex({3, 0, 0}); // 6: second split point + mesh.add_quad(1, 0, 4, 3); // opposite winding along the shared edge + mesh.add_triangle(0, 1, 2); // triangle comes after the quad in corner storage + + // The shared edge is a UV seam, including at both interpolated split points. + std::vector uv_values = {4, 100, 0, 100, 0, 98, 4, 98, 0, 0, 4, 0, 2, 2}; + std::vector uv_indices = {0, 1, 2, 3, 4, 5, 6}; + mesh.template create_attribute( + "uv", + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + uv_values, + uv_indices); + + Options options; + options.boundary_only = false; + resolve(mesh, options); + + REQUIRE(mesh.get_num_facets() == 7); + REQUIRE_THAT(compute_mesh_area(mesh), Catch::Matchers::WithinAbs(12., 1e-12)); + mesh.initialize_edges(); + REQUIRE(mesh.find_edge_from_vertices(0, 1) == invalid()); + REQUIRE(mesh.find_edge_from_vertices(0, 5) != invalid()); + REQUIRE(mesh.find_edge_from_vertices(5, 6) != invalid()); + REQUIRE(mesh.find_edge_from_vertices(6, 1) != invalid()); + + const auto positions = vertex_view(mesh); + const auto& uv_attr = mesh.template get_indexed_attribute("uv"); + const auto uv = matrix_view(uv_attr.values()); + const auto uv_idx = matrix_view(uv_attr.indices()); + for (Index f = 0; f < mesh.get_num_facets(); ++f) { + const auto fv = mesh.get_facet_vertices(f); + REQUIRE(fv.size() == 3); + const Scalar centroid_y = + (positions(fv[0], 1) + positions(fv[1], 1) + positions(fv[2], 1)) / 3; + const Scalar uv_offset = centroid_y < 0 ? 100 : 0; + for (Index k = 0; k < 3; ++k) { + const Index uid = uv_idx(mesh.get_facet_corner_begin(f) + k, 0); + REQUIRE_THAT(uv(uid, 0), Catch::Matchers::WithinAbs(positions(fv[k], 0), 1e-12)); + REQUIRE_THAT( + uv(uid, 1), + Catch::Matchers::WithinAbs(positions(fv[k], 1) + uv_offset, 1e-12)); + } + } + } + SECTION("indexed attribute is preserved") { SurfaceMesh mesh; diff --git a/modules/core/include/lagrange/mesh_bbox.h b/modules/core/include/lagrange/mesh_bbox.h index 2d91dbab..9d240b3e 100644 --- a/modules/core/include/lagrange/mesh_bbox.h +++ b/modules/core/include/lagrange/mesh_bbox.h @@ -38,9 +38,33 @@ namespace lagrange { /// @return The axis-aligned bounding box of the mesh vertices. /// template +[[nodiscard]] Eigen::AlignedBox(Dimension)> mesh_bbox( const SurfaceMesh& mesh); +/// +/// @overload +/// +/// Compute the axis-aligned bounding box after applying an affine transformation to the mesh +/// vertices. The input mesh is not modified. +/// +/// If the mesh has no vertices, the returned bounding box is empty. +/// +/// @param[in] mesh Input mesh. Its dimension must match the @p Dimension template parameter. +/// @param[in] transform Affine transformation to apply to the mesh vertices. +/// +/// @tparam Dimension Spatial dimension of the bounding box. Must be 2 or 3. +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return The axis-aligned bounding box of the transformed mesh vertices. +/// +template +[[nodiscard]] +Eigen::AlignedBox(Dimension)> mesh_bbox( + const SurfaceMesh& mesh, + const Eigen::Transform(Dimension), Eigen::Affine>& transform); + /// @} } // namespace lagrange diff --git a/modules/core/include/lagrange/triangulate_polygonal_facets.h b/modules/core/include/lagrange/triangulate_polygonal_facets.h index 07fd5636..0c6c8a21 100644 --- a/modules/core/include/lagrange/triangulate_polygonal_facets.h +++ b/modules/core/include/lagrange/triangulate_polygonal_facets.h @@ -30,7 +30,8 @@ struct TriangulationOptions { enum class Scheme { Earcut, ///< Use earcut algorithm to triangulate polygons - CentroidFan ///< Connect facet centroid to polygon edges to form a fan of triangles + CentroidFan, ///< Connect facet centroid to polygon edges to form a fan of triangles + Delaunay ///< Earcut triangulation refined toward a Delaunay triangulation via edge flips }; Scheme scheme = Scheme::Earcut; ///< Triangulation scheme to use @@ -64,8 +65,8 @@ void triangulate_polygonal_facets( /// @param[in, out] mesh Polygonal mesh to triangulate in place. /// @param[in] should_triangulate Predicate determining whether a facet with more than 3 /// vertices should be triangulated. Facets for which it -/// returns false are left untouched. This applies to both the -/// earcut and centroid-fan schemes. +/// returns false are left untouched. This applies to all +/// triangulation schemes. /// @param[in] options Options for triangulation. /// /// @tparam Scalar Mesh scalar type. diff --git a/modules/core/include/lagrange/utils/build.h b/modules/core/include/lagrange/utils/build.h index c82d5c88..ff55acbe 100644 --- a/modules/core/include/lagrange/utils/build.h +++ b/modules/core/include/lagrange/utils/build.h @@ -112,6 +112,14 @@ #define LAGRANGE_TARGET_BUILD_TYPE_PRIVATE_DEFINITION_DEBUG() 1 #endif +// MSVC always reports `__cplusplus` as `199711L` unless `/Zc:__cplusplus` is passed, +// so we use `_MSVC_LANG` with MSVC instead. +#if defined(_MSVC_LANG) + #define LAGRANGE_CPLUSPLUS _MSVC_LANG +#else + #define LAGRANGE_CPLUSPLUS __cplusplus +#endif + #define LAGRANGE_TARGET_FEATURE(X) LAGRANGE_TARGET_FEATURE_PRIVATE_DEFINITION_##X() #if __clang__ && !__INTEL_COMPILER diff --git a/modules/core/include/lagrange/utils/fmt/join.h b/modules/core/include/lagrange/utils/fmt/join.h index fd4fdd80..6737a5a1 100644 --- a/modules/core/include/lagrange/utils/fmt/join.h +++ b/modules/core/include/lagrange/utils/fmt/join.h @@ -34,9 +34,15 @@ // clang-format on #endif -// std::format is available when explicitly selected for spdlog or when the standard library -// exposes the C++20 header. -#if defined(SPDLOG_USE_STD_FORMAT) || defined(__cpp_lib_format) +#include + +// In C++20, will forward-declare std::formatter and related types, so we need to include +// and define `std::formatter` to avoid compilation errors. Note that older versions of +// Xcode (e.g. 15.3) ship with a header, but do not define `__cpp_lib_format`, so we cannot +// rely on that macro to detect availability. +#if defined(SPDLOG_USE_STD_FORMAT) || (defined(__has_include) && __has_include() && \ + LAGRANGE_CPLUSPLUS >= 202002L) + #define LAGRANGE_JOIN_HAS_STD_FORMAT 1 #include #endif @@ -145,28 +151,36 @@ auto join(const std::pair& p, std::string_view sep) } // namespace lagrange -// std::formatter specializations. Required when std::format is selected at the call site, which -// can happen via ADL even if `lagrange::format` resolves to `fmt::format` — `fmt::v12::join_view` -// template arguments pull `std::` into the candidate set. Delegates the format spec to the -// element formatter so that e.g. `format("{:.3g}", join(vec, ", "))` formats each float with -// `.3g` precision. -#if defined(SPDLOG_USE_STD_FORMAT) || defined(__cpp_lib_format) - +/// std::formatter specializations. Required when std::format is selected at the call site, which +/// can happen via ADL even if `lagrange::format` resolves to `fmt::format` — `fmt::v12::join_view` +/// template arguments pull `std::` into the candidate set. Delegates the format spec to the +/// element formatter so that e.g. `format("{:.3g}", join(vec, ", "))` formats each float with +/// `.3g` precision. +#if defined(LAGRANGE_JOIN_HAS_STD_FORMAT) + +/// `parse`/`format` must be templates (not hardcoded to `std::format_parse_context` / +/// `std::format_context`): libc++'s `__formattable_with` concept probes formattability using a +/// dummy context/iterator type distinct from the real ones, so a non-template `format` overload +/// would fail that check and make the type appear unformattable even though real calls would work. template struct std::formatter, char> { using value_type = lagrange::fmt_detail::range_value_t; std::formatter m_elem; - constexpr auto parse(std::format_parse_context& ctx) { return m_elem.parse(ctx); } + template + constexpr auto parse(ParseContext& ctx) + { + return m_elem.parse(ctx); + } - auto format(const lagrange::fmt_detail::range_join_view& jv, std::format_context& ctx) - const + template + auto format(const lagrange::fmt_detail::range_join_view& jv, FormatContext& ctx) const { return lagrange::fmt_detail::format_range( jv, ctx, - [this](const auto& elem, std::format_context& c) { return m_elem.format(elem, c); }); + [this](const auto& elem, FormatContext& c) { return m_elem.format(elem, c); }); } }; @@ -174,7 +188,8 @@ struct std::formatter, char> template struct std::formatter, char> { - constexpr auto parse(std::format_parse_context& ctx) + template + constexpr auto parse(ParseContext& ctx) { if (ctx.begin() != ctx.end() && *ctx.begin() != '}') { throw std::format_error("format spec not supported for tuple/pair join"); @@ -182,20 +197,26 @@ struct std::formatter, char> return ctx.begin(); } - auto format(const lagrange::fmt_detail::tuple_join_view& jv, std::format_context& ctx) - const + /// Formats one element via a default-constructed `std::formatter` rather than calling + /// `std::format_to`: some libc++ releases (e.g. Xcode 15.2) gate the free `std::format`/ + /// `std::format_to` functions behind `_LIBCPP_HAS_NO_INCOMPLETE_FORMAT` while still shipping + /// the `std::formatter` machinery itself, so relying on the free function would fail to link + /// against `std::` name lookup on those toolchains. + template + auto format(const lagrange::fmt_detail::tuple_join_view& jv, FormatContext& ctx) const { return lagrange::fmt_detail::format_tuple(jv, ctx, [](const auto& elem, auto& c) { - return std::format_to(c.out(), "{}", elem); + using ElemType = std::remove_cv_t>; + return std::formatter{}.format(elem, c); }); } }; -#endif // defined(SPDLOG_USE_STD_FORMAT) || defined(__cpp_lib_format) +#endif // defined(LAGRANGE_JOIN_HAS_STD_FORMAT) -// fmt::formatter specializations — required for callers routing through {fmt} (e.g. spdlog -// logger calls when SPDLOG_USE_STD_FORMAT is not set, or when `lagrange::format` resolves to -// `fmt::format`). +/// fmt::formatter specializations — required for callers routing through {fmt} (e.g. spdlog +/// logger calls when SPDLOG_USE_STD_FORMAT is not set, or when `lagrange::format` resolves to +/// `fmt::format`). #if !defined(SPDLOG_USE_STD_FORMAT) /// @cond LA_INTERNAL_DOCS diff --git a/modules/core/js/src/core_utilities.cpp b/modules/core/js/src/core_utilities.cpp index b448a164..13bc31d8 100644 --- a/modules/core/js/src/core_utilities.cpp +++ b/modules/core/js/src/core_utilities.cpp @@ -37,6 +37,7 @@ #include #include +#include #include #include @@ -53,6 +54,14 @@ NormalWeightingType parse_weight_type(const std::string& s) return NormalWeightingType::Angle; } +TriangulationOptions::Scheme parse_triangulation_scheme(const std::string& s) +{ + if (s == "earcut") return TriangulationOptions::Scheme::Earcut; + if (s == "centroidFan") return TriangulationOptions::Scheme::CentroidFan; + if (s == "delaunay") return TriangulationOptions::Scheme::Delaunay; + throw std::runtime_error("Unsupported triangulation scheme: " + s); +} + } // namespace EMSCRIPTEN_BINDINGS(lagrange_core_utilities) @@ -121,7 +130,18 @@ EMSCRIPTEN_BINDINGS(lagrange_core_utilities) // --- Mesh operations --- - function("triangulatePolygonalFacets", +[](MeshType& m) { triangulate_polygonal_facets(m); }); + function( + "triangulatePolygonalFacets", + +[](MeshType& mesh, val opts) { + TriangulationOptions o; + if (!opts.isUndefined()) { + auto scheme = opts["scheme"]; + if (!scheme.isUndefined()) { + o.scheme = parse_triangulation_scheme(scheme.as()); + } + } + triangulate_polygonal_facets(mesh, o); + }); function( "combineMeshes", diff --git a/modules/core/js/ts/core.ts b/modules/core/js/ts/core.ts index 4f48c385..e9966cc1 100644 --- a/modules/core/js/ts/core.ts +++ b/modules/core/js/ts/core.ts @@ -252,8 +252,14 @@ export interface CoreModule { // --- Mesh operations --- - /** Convert every polygon with more than 3 vertices into triangles. In-place. */ - triangulatePolygonalFacets(mesh: SurfaceMesh): void; + /** + * Convert every polygon with more than 3 vertices into triangles. In-place. + * The `scheme` option selects the triangulation strategy (default `"earcut"`). + */ + triangulatePolygonalFacets( + mesh: SurfaceMesh, + opts?: TriangulationOptions, + ): void; /** Merge several meshes into a single mesh. Returns the combined result. */ combineMeshes( meshes: SurfaceMesh[], @@ -408,6 +414,22 @@ export interface CombineMeshesOptions { preserveAttributes?: boolean; } +/** + * Triangulation strategy for {@link CoreModule.triangulatePolygonalFacets}. + * - `"earcut"`: ear-clipping (fast, arbitrary interior diagonals). + * - `"centroidFan"`: fan from an inserted facet centroid (adds vertices). + * - `"delaunay"`: earcut refined toward a constrained Delaunay triangulation via + * edge flips in the facet's dominant-axis 2D projection. This maximizes the + * minimum angle and removes most slivers in that projected plane; for tilted 3D + * facets the guarantee is on the projected angles, not the intrinsic 3D angles. + */ +export type TriangulationScheme = "earcut" | "centroidFan" | "delaunay"; + +export interface TriangulationOptions { + /** Triangulation scheme. Default: `"earcut"`. */ + scheme?: TriangulationScheme; +} + /** * How connectivity is traversed: * - `"edge"`: two facets are connected iff they share an edge. diff --git a/modules/core/python/src/bind_debug.h b/modules/core/python/src/bind_debug.h new file mode 100644 index 00000000..80b7a338 --- /dev/null +++ b/modules/core/python/src/bind_debug.h @@ -0,0 +1,29 @@ +/* + * 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 { + +namespace nb = nanobind; + +void bind_debug(nb::module_& m) +{ +#ifdef NDEBUG + m.attr("_ndebug") = true; +#else + m.attr("_ndebug") = false; +#endif +} + +} // namespace lagrange::python diff --git a/modules/core/python/src/bind_utilities.h b/modules/core/python/src/bind_utilities.h index 5f050699..5221b846 100644 --- a/modules/core/python/src/bind_utilities.h +++ b/modules/core/python/src/bind_utilities.h @@ -65,11 +65,13 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -86,6 +88,44 @@ void bind_utilities(nanobind::module_& m) using namespace nb::literals; using MeshType = SurfaceMesh; + m.def( + "chain_edges", + [](Tensor edges, + bool directed, + bool output_edge_index, + bool close_loop_with_identical_vertices) { + auto [data, shape, stride] = tensor_to_span(edges); + la_runtime_assert( + shape.size() == 2 && shape[1] == 2, + "Edge tensor must have shape num_edges x 2"); + la_runtime_assert(data.empty() || is_dense(shape, stride)); + la_runtime_assert( + std::find(data.begin(), data.end(), invalid()) == data.end(), + "Edge vertex indices cannot equal invalid_index"); + + ChainEdgesOptions options; + options.output_edge_index = output_edge_index; + options.close_loop_with_identical_vertices = close_loop_with_identical_vertices; + const span edge_span(data.data(), data.size()); + auto result = directed ? chain_directed_edges(edge_span, options) + : chain_undirected_edges(edge_span, options); + return std::make_tuple(std::move(result.loops), std::move(result.chains)); + }, + "edges"_a, + nb::kw_only(), + "directed"_a, + "output_edge_index"_a = ChainEdgesOptions().output_edge_index, + "close_loop_with_identical_vertices"_a = + ChainEdgesOptions().close_loop_with_identical_vertices, + R"(Chain a set of edges into loops and chains. + +:param edges: An N x 2 tensor of edge vertex indices. +:param directed: Whether to treat edges as directed (``[v0, v1]`` goes from ``v0`` to ``v1``) or undirected. +:param output_edge_index: Whether to return edge indices instead of vertex indices. +:param close_loop_with_identical_vertices: Whether to repeat the first vertex at the end of each loop. Only applies when ``output_edge_index`` is false. + +:returns: A tuple ``(loops, chains)`` of lists of edge or vertex index lists.)"); + nb::enum_(m, "NormalWeightingType", "Normal weighting type.") .value("Uniform", NormalWeightingType::Uniform, "Uniform weighting") .value( @@ -617,6 +657,8 @@ Vertices listed in `cone_vertices` are considered as cone vertices, which is alw opt.scheme = lagrange::TriangulationOptions::Scheme::Earcut; } else if (scheme == "centroid_fan") { opt.scheme = lagrange::TriangulationOptions::Scheme::CentroidFan; + } else if (scheme == "delaunay") { + opt.scheme = lagrange::TriangulationOptions::Scheme::Delaunay; } else { throw Error(lagrange::format("Unsupported triangulation scheme {}", scheme)); } @@ -680,10 +722,10 @@ Vertices listed in `cone_vertices` are considered as cone vertices, which is alw R"(Triangulate polygonal facets of the mesh. :param mesh: The input mesh to be triangulated in place. -:param scheme: The triangulation scheme (options are 'earcut' and 'centroid_fan'). +:param scheme: The triangulation scheme (options are 'earcut', 'centroid_fan', and 'delaunay'). :param selected_facets: Optional subset of facets to triangulate. Either a list/array of facet ids, or a boolean per-facet mask (a length ``num_facets`` array whose ``True`` entries mark facets to - triangulate). Honored by both schemes; facets not selected are left untouched. If omitted, all + triangulate). Honored by all schemes; facets not selected are left untouched. If omitted, all polygonal facets are triangulated.)"); nb::enum_(m, "ConnectivityType", "Mesh connectivity type") diff --git a/modules/core/python/src/core.cpp b/modules/core/python/src/core.cpp index 82da6244..82fd488d 100644 --- a/modules/core/python/src/core.cpp +++ b/modules/core/python/src/core.cpp @@ -13,6 +13,7 @@ #include #include "bind_attribute.h" #include "bind_camera_transforms.h" +#include "bind_debug.h" #include "bind_enum.h" #include "bind_indexed_attribute.h" #include "bind_mesh_cleanup.h" @@ -37,6 +38,7 @@ void populate_core_module(nb::module_& m) m.attr("invalid_scalar") = lagrange::invalid(); m.attr("invalid_index") = nb::int_(lagrange::invalid()); + lagrange::python::bind_debug(m); lagrange::python::bind_enum(m); lagrange::python::bind_surface_mesh(m); lagrange::python::bind_attribute(m); diff --git a/modules/core/python/tests/test_chain_edges.py b/modules/core/python/tests/test_chain_edges.py new file mode 100644 index 00000000..f43540b5 --- /dev/null +++ b/modules/core/python/tests/test_chain_edges.py @@ -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. +# +import lagrange +import numpy as np +import pytest + + +class TestChainEdges: + def test_chain_directed_edges(self): + edges = np.array([[0, 1], [1, 2], [2, 0], [3, 4]], dtype=np.uint32) + + loops, chains = lagrange.chain_edges(edges, directed=True) + + assert sorted(loops[0]) == [0, 1, 2] + assert chains == [[3, 4]] + + def test_chain_edges_orientation(self): + edges = np.array([[0, 1], [2, 1], [2, 0]], dtype=np.uint32) + + loops, _ = lagrange.chain_edges(edges, directed=False) + assert len(loops) == 1 + assert sorted(loops[0]) == [0, 1, 2] + + loops, chains = lagrange.chain_edges(edges, directed=True) + assert loops == [] + assert sum(len(chain) - 1 for chain in chains) == 3 + + def test_chain_undirected_edges_with_kwargs(self): + edges = np.array([[10, 11], [11, 12], [12, 10]], dtype=np.uint32) + + loops, chains = lagrange.chain_edges(edges, directed=False, output_edge_index=True) + + assert len(loops) == 1 + assert sorted(loops[0]) == [0, 1, 2] + assert chains == [] + + def test_chain_edges_close_loop(self): + edges = np.array([[0, 1], [1, 2], [2, 0]], dtype=np.uint32) + + loops, chains = lagrange.chain_edges( + edges, directed=True, close_loop_with_identical_vertices=True + ) + + assert loops == [[0, 1, 2, 0]] + assert chains == [] + + @pytest.mark.parametrize("directed", [True, False]) + def test_chain_edges_empty(self, directed): + loops, chains = lagrange.chain_edges(np.empty((0, 2), dtype=np.uint32), directed=directed) + + assert loops == [] + assert chains == [] + + def test_chain_edges_keyword_only_options(self): + edges = np.array([[0, 1]], dtype=np.uint32) + + with pytest.raises(TypeError): + lagrange.chain_edges(edges) # ty: ignore[missing-argument] + with pytest.raises(TypeError): + lagrange.chain_edges(edges, True) # ty: ignore[missing-argument, too-many-positional-arguments] + + @pytest.mark.parametrize( + "edges", + [ + np.array([0, 1], dtype=np.uint32), + np.array([[lagrange.invalid_index, 0]], dtype=np.uint32), + ], + ) + def test_chain_edges_reject_invalid_input(self, edges): + with pytest.raises(RuntimeError): + lagrange.chain_edges(edges, directed=True) diff --git a/modules/core/python/tests/test_debug.py b/modules/core/python/tests/test_debug.py new file mode 100644 index 00000000..0617975d --- /dev/null +++ b/modules/core/python/tests/test_debug.py @@ -0,0 +1,25 @@ +# +# 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 os + +import lagrange + + +class TestDebug: + def test_ndebug(self): + assert hasattr(lagrange, "_ndebug"), "lagrange._ndebug attribute does not exist" + assert isinstance(lagrange._ndebug, bool) + + expected = os.environ.get("LAGRANGE_EXPECT_NDEBUG") + if expected is not None: + assert expected in {"0", "1"} + assert lagrange._ndebug is (expected == "1") diff --git a/modules/core/python/tests/test_triangulate_polygonal_facets.py b/modules/core/python/tests/test_triangulate_polygonal_facets.py index 6811b05c..e433c920 100644 --- a/modules/core/python/tests/test_triangulate_polygonal_facets.py +++ b/modules/core/python/tests/test_triangulate_polygonal_facets.py @@ -48,9 +48,9 @@ def test_cube_with_attribute(self, cube): normal_indices = normal_attr.indices assert normal_indices.num_elements == mesh.num_corners - @pytest.mark.parametrize("scheme", ["earcut", "centroid_fan"]) + @pytest.mark.parametrize("scheme", ["earcut", "centroid_fan", "delaunay"]) def test_selected_facets_bool_mask(self, cube, scheme): - # Both schemes honor `selected_facets`, including for quads. Triangulate only two of the + # All schemes honor `selected_facets`, including for quads. Triangulate only two of the # six cube (quad) facets and check the other four survive as quads. mesh = cube mask = np.zeros(mesh.num_facets, dtype=bool) @@ -61,8 +61,8 @@ def test_selected_facets_bool_mask(self, cube, scheme): sizes = sorted(mesh.get_facet_size(f) for f in range(mesh.num_facets)) assert sizes.count(4) == 4 # four untouched quads - # A quad becomes 2 triangles (earcut) or 4 triangles via a centroid fan (centroid_fan). - expected_triangles = {"earcut": 4, "centroid_fan": 8}[scheme] + # A quad becomes 2 triangles (earcut / delaunay) or 4 triangles via a centroid fan. + expected_triangles = {"earcut": 4, "delaunay": 4, "centroid_fan": 8}[scheme] assert sizes.count(3) == expected_triangles def test_selected_facets_inputs_are_equivalent(self, cube): @@ -114,6 +114,64 @@ def test_selected_facets_out_of_range(self, cube): with pytest.raises(RuntimeError): lagrange.triangulate_polygonal_facets(mesh, "centroid_fan", [mesh.num_facets]) + def test_cube_delaunay(self, cube): + mesh = cube + area = lagrange.compute_mesh_area(mesh) + + # Update growth policy to allow copy. + mesh.attribute( + mesh.attr_id_corner_to_vertex + ).growth_policy = lagrange.AttributeGrowthPolicy.WarnAndCopy + + lagrange.triangulate_polygonal_facets(mesh, "delaunay") + # Delaunay is earcut + edge flips: no new vertices, each quad -> 2 triangles. + assert mesh.num_vertices == 8 + assert mesh.num_facets == 12 + assert all(mesh.get_facet_size(f) == 3 for f in range(mesh.num_facets)) + assert lagrange.compute_mesh_area(mesh) == pytest.approx(area, rel=1e-5) + + def test_delaunay_refines_polygon_sliver(self): + # A near-triangle 8-gon: edge A--B is subdivided by points on a shallow convex arc bulging + # away from apex C, so each interior point is a thin convex ear. Plain earcut clips these + # into sliver triangles; the delaunay scheme flips them away. A polygon with more than four + # vertices is required here because quads bypass `mapbox::refine`. + A = np.array([0.0, 0.0, 0.0]) + B = np.array([1.0, 0.0, 0.0]) + C = np.array([0.5, 1.0, 0.0]) + n = 6 + pts = [A] + for i in range(1, n): + t = i / n + pts.append(np.array([t, -1e-5 * t * (1.0 - t), 0.0])) + pts += [B, C] + pts = np.array(pts) + + def triangulate(scheme): + mesh = lagrange.SurfaceMesh() + mesh.vertices = pts + mesh.add_polygon(np.arange(len(pts), dtype=np.uint32)) + lagrange.triangulate_polygonal_facets(mesh, scheme) + assert mesh.num_vertices == len(pts) # no new vertices + assert mesh.num_facets == n # (n + 2)-gon -> n triangles + v = mesh.vertices + areas = [ + 0.5 * np.linalg.norm(np.cross(v[b] - v[a], v[c] - v[a])) for a, b, c in mesh.facets + ] + return min(areas), sorted(tuple(sorted(t)) for t in mesh.facets) + + earcut_min, earcut_tris = triangulate("earcut") + delaunay_min, delaunay_tris = triangulate("delaunay") + + # The two schemes must differ (guards against "delaunay" silently mapping to earcut), and + # delaunay's smallest triangle must be far larger than earcut's near-degenerate sliver. + assert earcut_tris != delaunay_tris + assert earcut_min < 1e-6 + assert delaunay_min > 1e-3 + + def test_unsupported_scheme(self, cube): + with pytest.raises(RuntimeError): + lagrange.triangulate_polygonal_facets(cube, "not_a_scheme") + def test_cube_with_attribute_centroid_fan(self, cube): mesh = cube attr_id = lagrange.compute_normal(mesh) diff --git a/modules/core/src/internal/split_edges.cpp b/modules/core/src/internal/split_edges.cpp index ea0e6614..f34b5e05 100644 --- a/modules/core/src/internal/split_edges.cpp +++ b/modules/core/src/internal/split_edges.cpp @@ -229,7 +229,6 @@ std::vector split_edges( }); auto vertices = vertex_view(mesh); - auto facets = facet_view(mesh); auto edge_barycentric = [&](Index v0, Index v1, Index v) { Scalar diff = 0; Scalar t = 0; @@ -246,9 +245,10 @@ std::vector split_edges( auto barycentric_coordinates = [&](Index ori_fid, Index vid, Index p_begin, Index p_end) -> std::array { - auto vid_0 = facets(ori_fid, 0); - auto vid_1 = facets(ori_fid, 1); - auto vid_2 = facets(ori_fid, 2); + const auto facet_vertices = mesh.get_facet_vertices(ori_fid); + auto vid_0 = facet_vertices[0]; + auto vid_1 = facet_vertices[1]; + auto vid_2 = facet_vertices[2]; if (vid == vid_0) return {1, 0, 0}; if (vid == vid_1) return {0, 1, 0}; if (vid == vid_2) return {0, 0, 1}; @@ -292,8 +292,9 @@ std::vector split_edges( for (size_t j = split_triangles_offsets[i] / 3; j < split_triangles_offsets[i + 1] / 3; j++) { Index fid = num_input_facets + static_cast(j); + const auto facet_vertices = mesh.get_facet_vertices(fid); for (Index k = 0; k < 3; k++) { - Index vid = facets(fid, k); + Index vid = facet_vertices[k]; corner_bc[j * 3 + k] = barycentric_coordinates(ori_fid, vid, parent_offsets[i], parent_offsets[i + 1]); } @@ -303,16 +304,18 @@ std::vector split_edges( auto map_corner_attribute = [&](auto&& data, auto&& corner_to_index) { for (size_t i = 0; i < original_triangle_index.size(); i++) { Index ori_fid = original_triangle_index[i]; + const Index ori_corner_begin = mesh.get_facet_corner_begin(ori_fid); std::array ori_corners{ - corner_to_index(ori_fid * 3), - corner_to_index(ori_fid * 3 + 1), - corner_to_index(ori_fid * 3 + 2)}; + corner_to_index(ori_corner_begin), + corner_to_index(ori_corner_begin + 1), + corner_to_index(ori_corner_begin + 2)}; for (size_t j = split_triangles_offsets[i] / 3; j < split_triangles_offsets[i + 1] / 3; j++) { Index fid = num_input_facets + static_cast(j); + const Index corner_begin = mesh.get_facet_corner_begin(fid); for (Index k = 0; k < 3; k++) { - Index curr_cid = fid * 3 + k; + Index curr_cid = corner_begin + k; auto& bc = corner_bc[j * 3 + k]; interpolate_row( data, diff --git a/modules/core/src/mapbox/earcut.h b/modules/core/src/mapbox/earcut.h index 64c53c55..8b3ea7e6 100644 --- a/modules/core/src/mapbox/earcut.h +++ b/modules/core/src/mapbox/earcut.h @@ -13,10 +13,12 @@ #include #include #include +#include #include #include namespace lagrange { + namespace mapbox { namespace util { @@ -47,10 +49,12 @@ class Earcut private: struct Node { + // i is a (bits(N)-1)-wide field packed alongside the 1-bit steiner flag; mask index to that + // width so it fits without a narrowing warning (a no-op for any real vertex index). Node(N index, double x_, double y_) : x(x_) , y(y_) - , i(index) + , i(index & ((N(1) << (sizeof(N) * 8 - 1)) - 1)) , steiner(0) {} Node(const Node&) = delete; @@ -85,6 +89,9 @@ class Earcut const double ax, ay; const double bx, by; const double cx, cy; + // triangle bounding box, used to cheaply reject most candidate points before the + // full point-in-triangle test (which is 6 multiplies) + const double minX, minY, maxX, maxY; Triangle(const Node* a, const Node* b, const Node* c) : ax(a->x) @@ -93,22 +100,37 @@ class Earcut , by(b->y) , cx(c->x) , cy(c->y) + , minX(std::min(ax, std::min(bx, cx))) + , minY(std::min(ay, std::min(by, cy))) + , maxX(std::max(ax, std::max(bx, cx))) + , maxY(std::max(ay, std::max(by, cy))) {} inline double area() const { return (by - ay) * (cx - bx) - (bx - ax) * (cy - by); } + inline bool inBBox(double px, double py) const + { + return px >= minX && px <= maxX && py >= minY && py <= maxY; + } + inline bool containsPoint(double px, double py) const { return (cx - px) * (ay - py) >= (ax - px) * (cy - py) && (ax - px) * (by - py) >= (bx - px) * (ay - py) && (bx - px) * (cy - py) >= (cx - px) * (by - py); } + + // as containsPoint, but false when the point coincides with the triangle's first vertex (a) + inline bool containsPointExceptFirst(double px, double py) const + { + return !(ax == px && ay == py) && containsPoint(px, py); + } }; template Node* linkedList(const Ring& points, const bool clockwise); Node* filterPoints(Node* start, Node* end = nullptr); - void earcutLinked(Node* ear, int pass = 0); + void earcutLinked(Node* ear); bool isEar(Node* ear); bool isEarHashed(Node* ear); Node* cureLocalIntersections(Node* start); @@ -117,6 +139,11 @@ class Earcut Node* eliminateHoles(const Polygon& points, Node* outerNode); Node* eliminateHole(Node* hole, Node* outerNode); Node* findHoleBridge(Node* hole, Node* outerNode); + void buildBlockIndex(std::size_t maxNodes, std::size_t numHoles); + void indexSegment(Node* head, Node* stop); + void growBlock(Node* head, Node* tail); + Node* liveBlockHead(std::size_t b); + Node* liveBlockStop(std::size_t b); bool sectorContainsSector(const Node* m, const Node* p); void indexCurve(Node* start); Node* sortLinked(Node* list); @@ -134,9 +161,13 @@ class Earcut bool isValidDiagonal(Node* a, Node* b); double area(const Node* p, const Node* q, const Node* r) const; bool equals(const Node* p1, const Node* p2); - bool intersects(const Node* p1, const Node* q1, const Node* p2, const Node* q2); + bool intersects( + const Node* p1, + const Node* q1, + const Node* p2, + const Node* q2, + bool includeBoundary = true); bool onSegment(const Node* p, const Node* q, const Node* r); - int sign(double val); bool intersectsPolygon(const Node* a, const Node* b); bool locallyInside(const Node* a, const Node* b); bool middleInside(const Node* a, const Node* b); @@ -146,6 +177,9 @@ class Earcut void removeNode(Node* p); bool hashing; + // set by filterPoints whenever it removes at least one node; read by earcutLinked's stall + // handler to decide whether another clip pass is worth attempting before the costlier stages + bool filteredOut = false; double minX, maxX; double minY, maxY; double inv_size = 0; @@ -154,11 +188,10 @@ class Earcut class ObjectPool { public: - ObjectPool() { allocateNewBlock(256); } ObjectPool(std::size_t blockSize_) - : baseBlockSize(blockSize_) + : baseBlockSize(std::max(blockSize_, 256)) { - allocateNewBlock(std::max(blockSize_, 256)); + allocateNewBlock(); } ~ObjectPool() { clear(); } template @@ -172,7 +205,7 @@ class Earcut currentIndex = 0; } else { // Allocate a new one - allocateNewBlock(baseBlockSize); + allocateNewBlock(); } } @@ -182,7 +215,6 @@ class Earcut currentIndex++; return object; } - void reset() { clear(); } void clear() { // Destroy all objects, but keep blocks allocated for reuse @@ -218,19 +250,17 @@ class Earcut }; std::vector> memoryBlocks; - std::vector blockCapacities; std::size_t currentBlockIndex = 0; std::size_t currentIndex = 0; std::size_t totalObjects = 0; - std::size_t baseBlockSize = 256; + const std::size_t baseBlockSize; - void allocateNewBlock(std::size_t capacity) + void allocateNewBlock() { - T* rawMemory = alloc_traits::allocate(alloc, capacity); + T* rawMemory = alloc_traits::allocate(alloc, baseBlockSize); auto newBlock = - std::unique_ptr(rawMemory, AllocDeleter{alloc, capacity}); + std::unique_ptr(rawMemory, AllocDeleter{alloc, baseBlockSize}); memoryBlocks.push_back(std::move(newBlock)); - blockCapacities.push_back(capacity); currentBlockIndex = memoryBlocks.size() - 1; currentIndex = 0; } @@ -238,6 +268,29 @@ class Earcut std::unique_ptr> nodes; std::vector holeQueue; + // reused scratch buffer for sortLinked: materialize the z-linked ring, std::sort, relink + std::vector sortBuffer; + + // Block-bbox index for findHoleBridge (issue #183): one [minX,minY,maxX,maxY] bbox per K + // consecutive ring edges, so the leftward-ray scan can skip whole blocks in O(1) instead of + // walking the whole merged ring. Grown append-only — the outer ring seeds it, then each merged + // hole appends a segment (head node, stop node, K-blocks over head..stop); independent + // segments, + // not a ring tiling, since splices land mid-ring. Buffers reused/grown across calls. + // + // filterPoints only drops collinear/coincident points, so a stale bbox stays a conservative + // superset of its live edges (never a false skip); the scan skips dead nodes (p->prev->next != + // p) + // and lazily advances a dead head/stop. Blocks are scanned in append (not ring) order, so the + // chosen bridge can differ from the un-indexed code — a different but equally valid result. + static constexpr int32_t K = 16; // edges per block + std::vector blockBBox; // [minX,minY,maxX,maxY] per block + std::vector blockHead; // first node of each block's segment + std::vector blockStop; // node just past each block's segment (exclusive walk bound) + std::size_t numBlocks = 0; + // true only while eliminateHoles merges holes, so removeNode keeps the block index live + // (growBlock) + bool indexActive = false; }; template @@ -255,9 +308,10 @@ void Earcut::operator()(const Polygon& points) int threshold = 80; std::size_t len = 0; - for (size_t i = 0; threshold >= 0 && i < points.size(); i++) { + const std::size_t numRings = static_cast(points.size()); + for (std::size_t i = 0; threshold >= 0 && i < numRings; i++) { threshold -= static_cast(points[i].size()); - len += points[i].size(); + len += static_cast(points[i].size()); } // estimate size of nodes and indices @@ -265,7 +319,7 @@ void Earcut::operator()(const Polygon& points) std::size_t estimatedNodes = len * 3 / 2; nodes = std::make_unique>(std::max(estimatedNodes, 256)); } - indices.reserve(len + points[0].size()); + indices.reserve(len + static_cast(points[0].size())); Node* outerNode = linkedList(points[0], true); if (!outerNode || outerNode->prev == outerNode->next) return; @@ -307,7 +361,7 @@ typename Earcut::Node* Earcut::linkedList(const Ring& points, const bool c { using Point = typename Ring::value_type; double sum = 0; - const std::size_t len = points.size(); + const std::size_t len = static_cast(points.size()); std::size_t i, j; Node* last = nullptr; @@ -339,26 +393,31 @@ typename Earcut::Node* Earcut::linkedList(const Ring& points, const bool c return last; } -// eliminate colinear or duplicate points +// Remove collinear or coincident points; removability depends only on a node's immediate +// neighbors, so we sweep forward and re-check the predecessor after each removal. With no `end` +// we sweep the whole ring, lapping until nothing is removable (the fixpoint the clipper needs). +// With an explicit `end` we heal only the dirty window around a bridge/diagonal cut, stopping at +// `end` rather than lapping — O(window) instead of O(ring). template typename Earcut::Node* Earcut::filterPoints(Node* start, Node* end) { - if (!end) end = start; + if (!start) return start; + const bool full = !end; + if (full) end = start; Node* p = start; bool again; do { again = false; - - if (!p->steiner && (equals(p, p->next) || area(p->prev, p, p->next) == 0)) { + if (p != p->next && !p->steiner && (equals(p, p->next) || area(p->prev, p, p->next) == 0)) { + if (full || p == end) end = p->prev; // pull the stop bound back past the removal + filteredOut = true; removeNode(p); - p = end = p->prev; - - if (p == p->next) break; + p = p->prev; // re-check the predecessor again = true; - - } else { + } else if (full || p != end) { p = p->next; + again = !full; // local heal: keep looping until the sweep reaches end } } while (again || p != end); @@ -367,23 +426,25 @@ typename Earcut::Node* Earcut::filterPoints(Node* start, Node* end) // main ear slicing loop which triangulates a polygon (given as a linked list) template -void Earcut::earcutLinked(Node* ear, int pass) +void Earcut::earcutLinked(Node* ear) { if (!ear) return; // interlink polygon nodes in z-order - if (!pass && hashing) indexCurve(ear); + if (hashing) indexCurve(ear); Node* stop = ear; Node* prev; Node* next; + bool cured = false; // iterate through ears, slicing them one by one while (ear->prev != ear->next) { prev = ear->prev; next = ear->next; - if (hashing ? isEarHashed(ear) : isEar(ear)) { + // reflex check is hoisted here to avoid constructing the Triangle for reflex corners + if (area(prev, ear, next) < 0 && (hashing ? isEarHashed(ear) : isEar(ear))) { // cut off the triangle indices.emplace_back(prev->i); indices.emplace_back(ear->i); @@ -391,9 +452,8 @@ void Earcut::earcutLinked(Node* ear, int pass) removeNode(ear); - // skipping the next vertice leads to less sliver triangles - ear = next->next; - stop = next->next; + ear = next; + stop = next; continue; } @@ -402,18 +462,25 @@ void Earcut::earcutLinked(Node* ear, int pass) // if we looped through the whole remaining polygon and can't find any more ears if (ear == stop) { - // try filtering points and slicing again - if (!pass) earcutLinked(filterPoints(ear), 1); - - // if this didn't work, try curing all small self-intersections locally - else if (pass == 1) { - ear = cureLocalIntersections(filterPoints(ear)); - earcutLinked(ear, 2); + // try filtering collinear/coincident points and slicing again — repeat as long as + // filtering actually removes nodes, since each removal can expose new ears + filteredOut = false; + ear = filterPoints(ear); + if (filteredOut) { + stop = ear; + continue; + } - // as a last resort, try splitting the remaining polygon into two - } else if (pass == 2) - splitEarcut(ear); + // filtering is exhausted: cure small local self-intersections once, then retry + if (!cured) { + ear = cureLocalIntersections(ear); + stop = ear; + cured = true; + continue; + } + // as a last resort, try splitting the remaining polygon into two + splitEarcut(ear); break; } } @@ -427,15 +494,16 @@ bool Earcut::isEar(Node* ear) const Node* b = ear; const Node* c = ear->next; - // Create triangle with cached coordinates and bounding box + // reflex check is hoisted into the earcutLinked caller const Triangle tri(a, b, c); - if (tri.area() >= 0) return false; // reflex, can't be an ear // now make sure we don't have other points inside the potential ear Node* p = ear->next->next; while (p != ear->prev) { - if (tri.containsPoint(p->x, p->y) && area(p->prev, p, p->next) >= 0) return false; + if (tri.inBBox(p->x, p->y) && tri.containsPointExceptFirst(p->x, p->y) && + area(p->prev, p, p->next) >= 0) + return false; p = p->next; } @@ -449,25 +517,18 @@ bool Earcut::isEarHashed(Node* ear) const Node* b = ear; const Node* c = ear->next; - // Create triangle with cached coordinates and bounding box + // reflex check is hoisted into the earcutLinked caller const Triangle tri(a, b, c); - if (tri.area() >= 0) return false; // reflex, can't be an ear - - // triangle bbox; min & max are calculated like this for speed - const double minTX = std::min(tri.ax, std::min(tri.bx, tri.cx)); - const double minTY = std::min(tri.ay, std::min(tri.by, tri.cy)); - const double maxTX = std::max(tri.ax, std::max(tri.bx, tri.cx)); - const double maxTY = std::max(tri.ay, std::max(tri.by, tri.cy)); // z-order range for the current triangle bbox; - const int32_t minZ = zOrder(minTX, minTY); - const int32_t maxZ = zOrder(maxTX, maxTY); + const int32_t minZ = zOrder(tri.minX, tri.minY); + const int32_t maxZ = zOrder(tri.maxX, tri.maxY); // first look for points inside the triangle in increasing z-order Node* p = ear->nextZ; while (p && p->z <= maxZ) { - if (p != ear->prev && p != ear->next && tri.containsPoint(p->x, p->y) && + if (p != ear->next && tri.inBBox(p->x, p->y) && tri.containsPointExceptFirst(p->x, p->y) && area(p->prev, p, p->next) >= 0) return false; p = p->nextZ; @@ -477,7 +538,7 @@ bool Earcut::isEarHashed(Node* ear) p = ear->prevZ; while (p && p->z >= minZ) { - if (p != ear->prev && p != ear->next && tri.containsPoint(p->x, p->y) && + if (p != ear->next && tri.inBBox(p->x, p->y) && tri.containsPointExceptFirst(p->x, p->y) && area(p->prev, p, p->next) >= 0) return false; p = p->prevZ; @@ -491,13 +552,14 @@ template typename Earcut::Node* Earcut::cureLocalIntersections(Node* start) { Node* p = start; + bool cured = false; do { Node* a = p->prev; Node* b = p->next->next; - // a self-intersection where edge (v[i-1],v[i]) intersects (v[i+1],v[i+2]) - if (!equals(a, b) && intersects(a, p, p->next, b) && locallyInside(a, b) && - locallyInside(b, a)) { + // a self-intersection where edge (v[i-1],v[i]) intersects (v[i+1],v[i+2]); + // includeBoundary=false so a mere collinear touch isn't treated as a crossing + if (intersects(a, p, p->next, b, false) && locallyInside(a, b) && locallyInside(b, a)) { indices.emplace_back(a->i); indices.emplace_back(p->i); indices.emplace_back(b->i); @@ -507,11 +569,12 @@ typename Earcut::Node* Earcut::cureLocalIntersections(Node* start) removeNode(p->next); p = start = b; + cured = true; } p = p->next; - } while ((p != start) && (p != p->next) && (p != p->next->next)); + } while (p != start); - return filterPoints(p); + return cured ? filterPoints(p) : p; } // try splitting polygon into two and triangulate them independently @@ -547,7 +610,7 @@ template template typename Earcut::Node* Earcut::eliminateHoles(const Polygon& points, Node* outerNode) { - const size_t len = points.size(); + const size_t len = static_cast(points.size()); holeQueue.clear(); for (size_t i = 1; i < len; i++) { @@ -557,16 +620,34 @@ typename Earcut::Node* Earcut::eliminateHoles(const Polygon& points, Node* holeQueue.push_back(getLeftmost(list)); } } + // compareXYSlope: sort by x, then y, then slope. When two holes' leftmost points coincide, the + // slope tiebreak makes the bridge land on the shared vertex instead of bridging the wrong hole. std::sort(holeQueue.begin(), holeQueue.end(), [](const Node* a, const Node* b) { - return a->x < b->x; + if (a->x != b->x) return a->x < b->x; + if (a->y != b->y) return a->y < b->y; + const double adx = a->next->x - a->x, ady = a->next->y - a->y; + const double bdx = b->next->x - b->x, bdy = b->next->y - b->y; + const bool aDegenerate = adx == 0 && ady == 0; + const bool bDegenerate = bdx == 0 && bdy == 0; + if (aDegenerate != bDegenerate) return aDegenerate; + return ady * bdx < bdy * adx; }); - // process holes from left to right + // block-bbox index for findHoleBridge, grown append-only as holes merge. Seed it with the + // outer ring, then append each merged hole. + buildBlockIndex(vertices, holeQueue.size()); + indexSegment(outerNode, outerNode); + + // process holes from left to right; indexActive lets removeNode keep block bboxes live as + // filterPoints heals edges during merges (see growBlock) + indexActive = true; for (size_t i = 0; i < holeQueue.size(); i++) { outerNode = eliminateHole(holeQueue[i], outerNode); } + indexActive = false; - return outerNode; + // collapse collinear/coincident points across the whole merged ring once before clipping + return filterPoints(outerNode); } // find a bridge between vertices that connects hole with an outer ring and and link it @@ -580,10 +661,15 @@ typename Earcut::Node* Earcut::eliminateHole(Node* hole, Node* outerNode) Node* bridgeReverse = splitPolygon(bridge, hole); - // filter collinear points around the cuts - filterPoints(bridgeReverse, bridgeReverse->next); + // index the merged-in segment before filtering: in ring order the splice runs + // bridge -> hole -> bridgeReverse -> bridge2 -> (bridge's old next), covering the hole's edges + // and both new slit edges. filterPoints below only drops collinear/coincident points, so these + // bboxes stay valid (conservative) supersets. + Node* bridge2 = bridgeReverse->next; + indexSegment(bridge, bridge2->next); - // Check if input node was removed by the filtering + // heal collinear/coincident points around the two new slit edges + filterPoints(bridgeReverse, bridgeReverse->next); return filterPoints(bridge, bridge->next); } @@ -594,22 +680,40 @@ typename Earcut::Node* Earcut::findHoleBridge(Node* hole, Node* outerNode) Node* p = outerNode; double hx = hole->x; double hy = hole->y; - double qx = -std::numeric_limits::infinity(); + double qx = -std::numeric_limits::max(); Node* m = nullptr; // find a segment intersected by a ray from the hole's leftmost Vertex to the left; - // segment's endpoint with lesser x will be potential connection Vertex - do { - if (hy <= p->y && hy >= p->next->y && p->next->y != p->y) { - double x = p->x + (hy - p->y) * (p->next->x - p->x) / (p->next->y - p->y); - if (x <= hx && x > qx) { - qx = x; - m = p->x < p->next->x ? p : p->next; - if (x == hx) return m; // hole touches outer segment; pick leftmost endpoint + // segment's endpoint with lesser x will be potential connection Vertex, + // unless they intersect at a vertex, then choose the vertex + if (equals(hole, p)) return p; + + // scan blocks; skip any whose bbox can't hold a crossing that beats qx and lies left of hx + // (the prune Morton order can't express — explicit per-axis [minY,maxY]/[minX,maxX]) + for (std::size_t b = 0, g = 0; b < numBlocks; b++, g += 4) { + if (hy < blockBBox[g + 1] || hy > blockBBox[g + 3] || blockBBox[g] > hx || + blockBBox[g + 2] <= qx) + continue; + + // ensure the walk's exclusive bound is live so we don't overrun into other blocks + const Node* stop = liveBlockStop(b); + p = liveBlockHead(b); + do { + if (p->prev->next == p) { // skip nodes removed by filterPoints (stale in the index) + if (equals(hole, p->next)) + return p->next; + else if (hy <= p->y && hy >= p->next->y && p->next->y != p->y) { + double x = p->x + (hy - p->y) * (p->next->x - p->x) / (p->next->y - p->y); + if (x <= hx && x > qx) { + qx = x; + m = p->x < p->next->x ? p : p->next; + if (x == hx) return m; // hole touches outer segment; pick leftmost endpoint + } + } } - } - p = p->next; - } while (p != outerNode); + p = p->next; + } while (p != stop); + } if (!m) return 0; @@ -617,31 +721,129 @@ typename Earcut::Node* Earcut::findHoleBridge(Node* hole, Node* outerNode) // if there are no points found, we have a valid connection; // otherwise choose the Vertex of the minimum angle with the ray as connection Vertex - const Node* stop = m; - double tanMin = std::numeric_limits::infinity(); - double tanCur = 0; - - p = m; - double mx = m->x; - double my = m->y; + const double mx = m->x; + const double my = m->y; + const double tminY = std::min(hy, my); // the triangle's y span; x span is [mx, hx] + const double tmaxY = std::max(hy, my); + double tanMin = std::numeric_limits::max(); + + // scan the same blocks; skip any whose bbox can't overlap the triangle's [mx,hx]x[tminY,tmaxY] + // box + for (std::size_t b = 0, g = 0; b < numBlocks; b++, g += 4) { + if (blockBBox[g + 2] < mx || blockBBox[g] > hx || blockBBox[g + 3] < tminY || + blockBBox[g + 1] > tmaxY) + continue; - do { - if (hx >= p->x && p->x >= mx && hx != p->x && - pointInTriangle(hy < my ? hx : qx, hy, mx, my, hy < my ? qx : hx, hy, p->x, p->y)) { - tanCur = std::abs(hy - p->y) / (hx - p->x); // tangential - - if (locallyInside(p, hole) && - (tanCur < tanMin || - (tanCur == tanMin && (p->x > m->x || sectorContainsSector(m, p))))) { - m = p; - tanMin = tanCur; + const Node* stop = liveBlockStop(b); + p = liveBlockHead(b); + do { + if (p->prev->next == p && hx >= p->x && p->x >= mx && hx != p->x && // skip dead nodes + pointInTriangle(hy < my ? hx : qx, hy, mx, my, hy < my ? qx : hx, hy, p->x, p->y)) { + const double tanCur = std::abs(hy - p->y) / (hx - p->x); // tangential + + // if hole point sits on p's horizontal edge (T-junction touch): the bridge runs + // along that edge — locallyInside rejects it as collinear, but it's valid + if ((locallyInside(p, hole) || + (p->y == hy && p->next->y == hy && p->next->x > hx)) && + (tanCur < tanMin || + (tanCur == tanMin && + (p->x > m->x || (p->x == m->x && sectorContainsSector(m, p)))))) { + m = p; + tanMin = tanCur; + } } - } + p = p->next; + } while (p != stop); + } - p = p->next; + return m; +} + +// Block-bbox index buffers: size once from the input upper bound and reuse across calls. +template +void Earcut::buildBlockIndex(std::size_t maxNodes, std::size_t numHoles) +{ + // upper bound: every input node indexed once, +2 bridge nodes per hole, plus a partial + // trailing block per appended segment (outer ring + one per hole) + const std::size_t maxBlocks = (maxNodes + 2 * numHoles + K - 1) / K + numHoles + 2; + if (blockBBox.size() < maxBlocks * 4) blockBBox.resize(maxBlocks * 4); + if (blockHead.size() < maxBlocks) { + blockHead.resize(maxBlocks); + blockStop.resize(maxBlocks); + } + numBlocks = 0; +} + +// index the ring run head..stop (exclusive) as ceil(len / K) blocks; head == stop means the whole +// ring. each block's bbox covers both endpoints of every edge it owns. +template +void Earcut::indexSegment(Node* head, Node* stop) +{ + Node* p = head; + do { + const std::size_t b = numBlocks++; + blockHead[b] = p; + double bMinX = std::numeric_limits::max(); + double bMinY = std::numeric_limits::max(); + double bMaxX = std::numeric_limits::lowest(); + double bMaxY = std::numeric_limits::lowest(); + int32_t k = 0; + do { + Node* c = p->next; // edge p->c; bbox must bound both endpoints + p->z = static_cast( + b); // reuse z as the owning block during eliminateHoles (see growBlock) + if (p->x < bMinX) bMinX = p->x; + if (p->x > bMaxX) bMaxX = p->x; + if (p->y < bMinY) bMinY = p->y; + if (p->y > bMaxY) bMaxY = p->y; + if (c->x < bMinX) bMinX = c->x; + if (c->x > bMaxX) bMaxX = c->x; + if (c->y < bMinY) bMinY = c->y; + if (c->y > bMaxY) bMaxY = c->y; + p = c; + } while (++k < K && p != stop); + blockStop[b] = p; + const std::size_t g = b * 4; + blockBBox[g] = bMinX; + blockBBox[g + 1] = bMinY; + blockBBox[g + 2] = bMaxX; + blockBBox[g + 3] = bMaxY; } while (p != stop); +} - return m; +// when filterPoints heals an edge head->tail (removing the collinear node between them), the healed +// edge can extend past head's frozen block bbox if its old far endpoint lived in another block; +// grow head's block bbox to cover tail so the leftward-ray prune can't false-skip it. +template +void Earcut::growBlock(Node* head, Node* tail) +{ + const std::size_t g = static_cast(head->z) * 4; + if (tail->x < blockBBox[g]) blockBBox[g] = tail->x; + if (tail->y < blockBBox[g + 1]) blockBBox[g + 1] = tail->y; + if (tail->x > blockBBox[g + 2]) blockBBox[g + 2] = tail->x; + if (tail->y > blockBBox[g + 3]) blockBBox[g + 3] = tail->y; +} + +// the block's head node can be removed by filterPoints during merges; advance it to the next live +// node so the walk doesn't start on (and immediately terminate at) a dead node. For the single +// full-ring seed block (head == stop) the same forward advance keeps them equal, so the do-while +// still laps the whole ring instead of collapsing to an empty walk. +template +typename Earcut::Node* Earcut::liveBlockHead(std::size_t b) +{ + Node* head = blockHead[b]; + while (head->prev->next != head) head = head->next; + blockHead[b] = head; + return head; +} + +template +typename Earcut::Node* Earcut::liveBlockStop(std::size_t b) +{ + Node* stop = blockStop[b]; + while (stop->prev->next != stop) stop = stop->next; + blockStop[b] = stop; + return stop; } // whether sector in vertex m contains sector in vertex p in the same coordinates @@ -659,7 +861,8 @@ void Earcut::indexCurve(Node* start) Node* p = start; do { - p->z = p->z ? p->z : zOrder(p->x, p->y); + // always (re)compute: z may still hold a block index left over from eliminateHoles + p->z = zOrder(p->x, p->y); p->prevZ = p->prev; p->nextZ = p->next; p = p->next; @@ -671,74 +874,31 @@ void Earcut::indexCurve(Node* start) sortLinked(p); } -// Simon Tatham's linked list merge sort algorithm -// http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html +// Sort the z-linked ring by z-order. Upstream earcut replaced its linked merge sort with an +// array sort (materialize node refs → sort → relink); in C++ std::sort over a contiguous +// Node* buffer inlines the comparator fully and beats both a linked merge sort and a hand radix +// (measured on the MVT tiles fixture) — JS's rejection of native Array.sort does not transfer. template typename Earcut::Node* Earcut::sortLinked(Node* list) { assert(list); - Node* p; - Node* q; - Node* e; - Node* tail; - int i, numMerges, pSize, qSize; - int inSize = 1; - - for (;;) { - p = list; - list = nullptr; - tail = nullptr; - numMerges = 0; - - while (p) { - numMerges++; - q = p; - pSize = 0; - for (i = 0; i < inSize; i++) { - pSize++; - q = q->nextZ; - if (!q) break; - } - - qSize = inSize; - - while (pSize > 0 || (qSize > 0 && q)) { - if (pSize == 0) { - e = q; - q = q->nextZ; - qSize--; - } else if (qSize == 0 || !q) { - e = p; - p = p->nextZ; - pSize--; - } else if (p->z <= q->z) { - e = p; - p = p->nextZ; - pSize--; - } else { - e = q; - q = q->nextZ; - qSize--; - } - - if (tail) - tail->nextZ = e; - else - list = e; - - e->prevZ = tail; - tail = e; - } - - p = q; - } - - tail->nextZ = nullptr; + // list is a null-terminated nextZ chain (see indexCurve); walk it into the scratch buffer + sortBuffer.clear(); + for (Node* p = list; p; p = p->nextZ) sortBuffer.push_back(p); - if (numMerges <= 1) return list; + std::sort(sortBuffer.begin(), sortBuffer.end(), [](const Node* a, const Node* b) { + return a->z < b->z; + }); - inSize *= 2; + // relink in sorted order + Node* prev = nullptr; + for (Node* p : sortBuffer) { + p->prevZ = prev; + if (prev) prev->nextZ = p; + prev = p; } + prev->nextZ = nullptr; + return sortBuffer.front(); } // z-order of a Vertex given coords and size of the data bounding box @@ -797,13 +957,15 @@ bool Earcut::pointInTriangle( template bool Earcut::isValidDiagonal(Node* a, Node* b) { - return a->next->i != b->i && a->prev->i != b->i && - !intersectsPolygon(a, b) && // dones't intersect other edges - ((locallyInside(a, b) && locallyInside(b, a) && middleInside(a, b) && // locally visible - (area(a->prev, a, b->prev) != 0.0 || - area(a, b->prev, b) != 0.0)) || // does not create opposite-facing sectors - (equals(a, b) && area(a->prev, a, a->next) > 0 && - area(b->prev, b, b->next) > 0)); // special zero-length case + // degenerate zero-length case + const bool zeroLength = + equals(a, b) && area(a->prev, a, a->next) > 0 && area(b->prev, b, b->next) > 0; + return a->next->i != b->i && + (zeroLength || (locallyInside(a, b) && locallyInside(b, a) && // locally visible + (area(a->prev, a, b->prev) != 0.0 || + area(a, b->prev, b) != 0.0))) && // no opposite-facing sectors + !intersectsPolygon(a, b) && // doesn't intersect other edges + (zeroLength || middleInside(a, b)); // diagonal inside polygon } // signed area of a triangle @@ -820,16 +982,25 @@ bool Earcut::equals(const Node* p1, const Node* p2) return p1->x == p2->x && p1->y == p2->y; } -// check if two segments intersect +// check if two segments intersect; by default includes collinear boundary touches template -bool Earcut::intersects(const Node* p1, const Node* q1, const Node* p2, const Node* q2) +bool Earcut::intersects( + const Node* p1, + const Node* q1, + const Node* p2, + const Node* q2, + bool includeBoundary) { - int o1 = sign(area(p1, q1, p2)); - int o2 = sign(area(p1, q1, q2)); - int o3 = sign(area(p2, q2, p1)); - int o4 = sign(area(p2, q2, q1)); + const double o1 = area(p1, q1, p2); + const double o2 = area(p1, q1, q2); + const double o3 = area(p2, q2, p1); + const double o4 = area(p2, q2, q1); + + // general case: the two segments straddle each other (proper crossing) + if (((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0))) + return true; - if (o1 != o2 && o3 != o4) return true; // general case + if (!includeBoundary) return false; if (o1 == 0 && onSegment(p1, p2, q1)) return true; // p1, q1 and p2 are collinear and p2 lies on p1q1 @@ -851,22 +1022,28 @@ bool Earcut::onSegment(const Node* p, const Node* q, const Node* r) q->y <= std::max(p->y, r->y) && q->y >= std::min(p->y, r->y); } -template -int Earcut::sign(double val) -{ - return (0.0 < val) - (val < 0.0); -} - // check if a polygon diagonal intersects any polygon segments template bool Earcut::intersectsPolygon(const Node* a, const Node* b) { + // diagonal bbox; an edge whose bbox can't overlap it can't intersect it, so + // skip the orientation test for those (the common case — the diagonal is short) + const double diagMinX = std::min(a->x, b->x); + const double diagMaxX = std::max(a->x, b->x); + const double diagMinY = std::min(a->y, b->y); + const double diagMaxY = std::max(a->y, b->y); + const Node* p = a; do { - if (p->i != a->i && p->next->i != a->i && p->i != b->i && p->next->i != b->i && - intersects(p, p->next, a, b)) + const Node* n = p->next; + if ((p->x > diagMaxX && n->x > diagMaxX) || (p->x < diagMinX && n->x < diagMinX) || + (p->y > diagMaxY && n->y > diagMaxY) || (p->y < diagMinY && n->y < diagMinY)) { + p = n; + continue; + } + if (p->i != a->i && n->i != a->i && p->i != b->i && n->i != b->i && intersects(p, n, a, b)) return true; - p = p->next; + p = n; } while (p != a); return false; @@ -889,10 +1066,11 @@ bool Earcut::middleInside(const Node* a, const Node* b) double px = (a->x + b->x) / 2; double py = (a->y + b->y) / 2; do { - if (((p->y > py) != (p->next->y > py)) && p->next->y != p->y && - (px < (p->next->x - p->x) * (py - p->y) / (p->next->y - p->y) + p->x)) + const Node* n = p->next; + if (((p->y > py) != (n->y > py)) && + (px < (n->x - p->x) * (py - p->y) / (n->y - p->y) + p->x)) inside = !inside; - p = p->next; + p = n; } while (p != a); return inside; @@ -956,6 +1134,9 @@ void Earcut::removeNode(Node* p) if (p->prevZ) p->prevZ->nextZ = p->nextZ; if (p->nextZ) p->nextZ->prevZ = p->prevZ; + + // keep the hole-bridge index's block bboxes covering the healed prev->next edge + if (indexActive) growBlock(p->prev, p->next); } } // namespace detail @@ -966,5 +1147,201 @@ std::vector earcut(const Polygon& poly) earcut(poly); return std::move(earcut.indices); } + +namespace detail { + +// Refine a triangulation toward the constrained Delaunay triangulation by legalizing every interior +// edge in place with Lawson flips — maximizing the minimum angle and removing most slivers. Adapted +// from delaunator's edge legalization. Uses non-robust predicates: float input is fine, and the +// worst case is a not-quite-Delaunay edge, never an invalid mesh. Ported from earcut v3.2.3. +template +class Refiner +{ +public: + // triangles: triangle indices as returned by earcut, mutated in place. + // coords: random-access container of points, indexed by vertex index (coords[i] -> point i), + // read through the same util::nth<0>/<1> accessors as earcut's input. + template + void operator()(std::vector& triangles, const Coords& coords) + { + using Point = typename std::decay::type; + const int n = static_cast(triangles.size()); + if (n < 6) return; + ensureScratch(static_cast(n)); + gen++; // bumping the generation logically empties the hash (no clearing) + std::fill(heVec.begin(), heVec.begin() + n, -1); + + // Raw pointers into the scratch: indexed by the int/uint half-edge and hash indices below, + // where operator[]'s size_type would trip -Wsign-conversion on every subscript. + N* t = triangles.data(); + int32_t* he = heVec.data(); + int32_t* edgeStack = edgeStackVec.data(); + int32_t* hTable = hTableVec.data(); + uint32_t* hStamp = hStampVec.data(); + uint8_t* edgeStamp = edgeStampVec.data(); + + auto X = [&](N p) -> double { + return static_cast(util::nth<0, Point>::get(coords[p])); + }; + auto Y = [&](N p) -> double { + return static_cast(util::nth<1, Point>::get(coords[p])); + }; + + // Build half-edge twins with an undirected-edge hash; consumed slots mark linked pairs. As + // each pair is linked we seed the stack with one representative (s, the earlier-inserted + // edge) — this fuses the initial "push every interior edge" pass into the build, saving a + // full O(n) scan. edgeStamp is all-zero here (balanced push/pop leaves it clean) and each + // pair links once, so the seed write needs no dedup guard. + int i = 0; + for (int e = 0; e < n; e++) { + const N a = t[e], b = t[nextHE(e)]; + const N lo = a < b ? a : b, hi = a < b ? b : a; + uint32_t h = (uint32_t(lo) * 0x9e3779b1u ^ uint32_t(hi) * 0x85ebca6bu) & hMask; + while (hStamp[h] == gen) { + const int32_t s = hTable[h]; + // s == -1 marks a consumed slot (a pair already linked) — skip past it + if (s != -1) { + const N sa = t[s], sb = t[nextHE(s)]; + if ((sa == lo && sb == hi) || (sa == hi && sb == lo)) { + he[e] = s; + he[s] = e; + hTable[h] = -1; // link, then consume the slot + edgeStamp[s] = 1; + edgeStack[i++] = s; // seed the interior edge for the cascade + break; + } + } + h = (h + 1) & hMask; + } + if (hStamp[h] != gen) { + hTable[h] = e; + hStamp[h] = gen; + } // first occurrence: insert + } + + while (i > 0) { + const int a = edgeStack[--i]; + edgeStamp[a] = 0; + const int b = he[a]; + if (b == -1) continue; + + const int a0 = a - a % 3; + const int b0 = b - b % 3; + const int ar = a0 + (a + 2) % 3; + const int al = a0 + (a + 1) % 3; + const int bl = b0 + (b + 2) % 3; + const int br = b0 + (b + 1) % 3; + const N p0 = t[ar], pr = t[a], pl = t[al], p1 = t[bl]; + + const double x0 = X(p0), y0 = Y(p0); + const double xr = X(pr), yr = Y(pr); + const double xl = X(pl), yl = Y(pl); + const double x1 = X(p1), y1 = Y(p1); + + // Test inCircle first: most interior edges are already Delaunay (inCircle true → no + // flip), so this short-circuits before the two convexity orients on the common path. + // The quad must also be convex (both new triangles CCW) — flipping a reflex quad would + // push + // a triangle outside the polygon. Boundary/hole edges self-protect via he == -1. + if (!inCircle(x0, y0, xr, yr, xl, yl, x1, y1) && orient(x0, y0, xr, yr, x1, y1) > 0 && + orient(x0, y0, x1, y1, xl, yl) > 0) { + t[a] = p1; + t[b] = p0; + const int32_t hbl = he[bl], har = he[ar]; + he[a] = hbl; + if (hbl != -1) he[hbl] = a; + he[b] = har; + if (har != -1) he[har] = b; + he[ar] = bl; + he[bl] = ar; + + // re-check the quad's four outer edges; skip boundary edges (he == -1) and any + // already queued (edgeStamp), which also keeps the stack bounded by n. + if (hbl != -1 && edgeStamp[a] == 0) { + edgeStamp[a] = 1; + edgeStack[i++] = a; + } + if (har != -1 && edgeStamp[b] == 0) { + edgeStamp[b] = 1; + edgeStack[i++] = b; + } + if (he[al] != -1 && edgeStamp[al] == 0) { + edgeStamp[al] = 1; + edgeStack[i++] = al; + } + if (he[br] != -1 && edgeStamp[br] == 0) { + edgeStamp[br] = 1; + edgeStack[i++] = br; + } + } + } + } + +private: + // Reusable scratch, grown on demand like earcut's z-order arrays and reused across calls: + // he = twin half-edge of each edge, or -1 on the polygon boundary + // hTable = open-addressing hash, slot -> half-edge index, valid iff hStamp[slot] == gen + // edgeStamp = pending-in-stack flag, cleared when the edge is popped + std::vector heVec, edgeStackVec, hTableVec; + std::vector hStampVec; + std::vector edgeStampVec; + uint32_t hMask = 0, gen = 0; + + static int nextHE(int e) { return e - e % 3 + (e + 1) % 3; } // next half-edge in same triangle + + static double orient(double ax, double ay, double bx, double by, double cx, double cy) + { + return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + } + + // Whether p is inside or exactly on the circumcircle of triangle (a, b, c). Sign is negated vs + // the usual predicate to match earcut's CCW winding — the standard sign builds the + // anti-Delaunay mesh. Cocircular quads are legal ties, so refine only flips when this returns + // false. + static bool + inCircle(double ax, double ay, double bx, double by, double cx, double cy, double px, double py) + { + const double dx = ax - px, dy = ay - py, ex = bx - px, ey = by - py, fx = cx - px, + fy = cy - py; + const double ap = dx * dx + dy * dy, bp = ex * ex + ey * ey, cp = fx * fx + fy * fy; + // A near-cocircular quad is a legal Delaunay tie, but roundoff can flag both an edge and + // its flip as illegal, cascading into an endless flip loop (#205) — so treat a determinant + // within a small margin of zero as a tie. The determinant's worst-case roundoff error is + // provably below 9e-16·(ap+bp+cp)² (Shewchuk-style bound), so the margin guarantees every + // executed flip is illegal in exact arithmetic, and Lawson flipping always terminates. + const double s = ap + bp + cp; + return dx * (ey * cp - bp * fy) - dy * (ex * cp - bp * fx) + ap * (ex * fy - ey * fx) <= + 1e-13 * s * s; + } + + void ensureScratch(std::size_t n) + { + // edgeStack holds at most one entry per half-edge (edgeStamp dedups), so n is a safe cap. + if (edgeStackVec.size() < n) edgeStackVec.resize(n); + if (heVec.size() < n) heVec.resize(n); + if (edgeStampVec.size() < n) edgeStampVec.resize(n, 0); + std::size_t size = 1; + while (size < n * 4) size <<= 1; // power-of-two table, load factor <= 0.25 + if (hTableVec.size() < size) { + hTableVec.resize(size); + hStampVec.resize(size, 0); + } + hMask = uint32_t(size) - 1; + } +}; + +} // namespace detail + +// Opt-in Delaunay-refinement post-pass for earcut() output (or any manifold triangle-index array). +// Legalizes every interior edge in place with Lawson flips. See detail::Refiner. `coords` is a +// random-access container of points indexed by vertex index; `triangles` is mutated in place. +template +void refine(std::vector& triangles, const Coords& coords) +{ + static thread_local mapbox::detail::Refiner refiner; + refiner(triangles, coords); +} + } // namespace mapbox + } // namespace lagrange diff --git a/modules/core/src/mesh_bbox.cpp b/modules/core/src/mesh_bbox.cpp index 0a5a7311..cf80448d 100644 --- a/modules/core/src/mesh_bbox.cpp +++ b/modules/core/src/mesh_bbox.cpp @@ -35,11 +35,35 @@ Eigen::AlignedBox(Dimension)> mesh_bbox( return bbox; } +template +Eigen::AlignedBox(Dimension)> mesh_bbox( + const SurfaceMesh& mesh, + const Eigen::Transform(Dimension), Eigen::Affine>& transform) +{ + static_assert(Dimension == 2 || Dimension == 3, "Only 2D and 3D meshes are supported."); + la_runtime_assert( + mesh.get_dimension() == Dimension, + "Mesh dimension does not match the requested bounding box dimension."); + const auto vertices = vertex_view(mesh); + Eigen::AlignedBox(Dimension)> bbox; + for (Eigen::Index i = 0; i < vertices.rows(); ++i) { + bbox.extend( + transform * vertices.row(i).transpose().template tail(Dimension)>()); + } + return bbox; +} + #define LA_X_mesh_bbox(_, Scalar, Index) \ template LA_CORE_API Eigen::AlignedBox mesh_bbox<2u, Scalar, Index>( \ const SurfaceMesh&); \ + template LA_CORE_API Eigen::AlignedBox mesh_bbox<2u, Scalar, Index>( \ + const SurfaceMesh&, \ + const Eigen::Transform&); \ + template LA_CORE_API Eigen::AlignedBox mesh_bbox<3u, Scalar, Index>( \ + const SurfaceMesh&); \ template LA_CORE_API Eigen::AlignedBox mesh_bbox<3u, Scalar, Index>( \ - const SurfaceMesh&); + const SurfaceMesh&, \ + const Eigen::Transform&); LA_SURFACE_MESH_X(mesh_bbox, 0) } // namespace lagrange diff --git a/modules/core/src/triangulate_polygonal_facets.cpp b/modules/core/src/triangulate_polygonal_facets.cpp index efd1d6a7..8428141e 100644 --- a/modules/core/src/triangulate_polygonal_facets.cpp +++ b/modules/core/src/triangulate_polygonal_facets.cpp @@ -122,6 +122,7 @@ void append_triangles_from_polygon( Index f, std::vector>& polygon, mapbox::detail::Earcut& earcut, + bool refine, std::vector& new_to_old_corners, std::vector& new_to_old_facets) { @@ -146,6 +147,11 @@ void append_triangles_from_polygon( la_debug_assert(earcut.indices.size() % 3 == 0); + // Legalize interior edges toward a Delaunay triangulation (in place on earcut.indices). + if (refine) { + mapbox::refine(earcut.indices, polygon); + } + // Append new triangles { LAGRANGE_ZONE_SCOPED; @@ -165,6 +171,7 @@ void triangulate_polygonal_facets_earcut( SurfaceMesh& mesh, bool preserve_edges, bool preserve_points, + bool refine, function_ref* should_triangulate = nullptr) { LAGRANGE_ZONE_SCOPED; @@ -211,6 +218,7 @@ void triangulate_polygonal_facets_earcut( f, polygon, earcut, + refine, new_to_old_corners, new_to_old_facets); } @@ -545,7 +553,18 @@ void triangulate_polygonal_facets( { switch (options.scheme) { case TriangulationOptions::Scheme::Earcut: - triangulate_polygonal_facets_earcut(mesh, options.preserve_edges, options.preserve_points); + triangulate_polygonal_facets_earcut( + mesh, + options.preserve_edges, + options.preserve_points, + /*refine=*/false); + break; + case TriangulationOptions::Scheme::Delaunay: + triangulate_polygonal_facets_earcut( + mesh, + options.preserve_edges, + options.preserve_points, + /*refine=*/true); break; case TriangulationOptions::Scheme::CentroidFan: triangulate_polygonal_facets_centroid_fan( @@ -568,6 +587,15 @@ void triangulate_polygonal_facets( mesh, options.preserve_edges, options.preserve_points, + /*refine=*/false, + &should_triangulate); + break; + case TriangulationOptions::Scheme::Delaunay: + triangulate_polygonal_facets_earcut( + mesh, + options.preserve_edges, + options.preserve_points, + /*refine=*/true, &should_triangulate); break; case TriangulationOptions::Scheme::CentroidFan: diff --git a/modules/core/tests/fmt/test_fmt.cpp b/modules/core/tests/fmt/test_fmt.cpp index d7148544..0799273d 100644 --- a/modules/core/tests/fmt/test_fmt.cpp +++ b/modules/core/tests/fmt/test_fmt.cpp @@ -11,8 +11,14 @@ */ #include "../../include/lagrange/utils/fmt_eigen.h" +#include +#include + #include +#include +#include + TEST_CASE("Format Vector", "[fmt]") { Eigen::Vector3f v(1.f, 2.35f, 3.9999f); @@ -58,3 +64,15 @@ TEST_CASE("Format Nested", "[fmt]") spdlog::info("{:.2f}\n", test); } #endif + +TEST_CASE("Join range", "[fmt]") +{ + std::vector v{1, 2, 3}; + REQUIRE(lagrange::format("{}", lagrange::join(v, ", ")) == "1, 2, 3"); +} + +TEST_CASE("Join tuple", "[fmt]") +{ + auto t = std::make_tuple(1, 2.5f, std::string("three")); + REQUIRE(lagrange::format("{}", lagrange::join(t, ", ")) == "1, 2.5, three"); +} diff --git a/modules/core/tests/test_mesh_bbox.cpp b/modules/core/tests/test_mesh_bbox.cpp index a38c66fd..ca9bd482 100644 --- a/modules/core/tests/test_mesh_bbox.cpp +++ b/modules/core/tests/test_mesh_bbox.cpp @@ -50,6 +50,35 @@ TEST_CASE("mesh_bbox 3D", "[core][mesh_bbox]") REQUIRE(bbox.max().y() == 1); REQUIRE(bbox.max().z() == 0); } + + SECTION("transformed triangle") + { + lagrange::SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({2, 0, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_triangle(0, 1, 2); + + const Eigen::Affine3d transform = + Eigen::Translation3d(1, 2, 3) * + Eigen::AngleAxisd(0.7853981633974483, Eigen::Vector3d::UnitZ()); + const auto bbox = lagrange::mesh_bbox<3>(mesh, transform); + const Eigen::Vector3d p0 = transform * Eigen::Vector3d(0, 0, 0); + const Eigen::Vector3d p1 = transform * Eigen::Vector3d(2, 0, 0); + const Eigen::Vector3d p2 = transform * Eigen::Vector3d(0, 1, 0); + const Eigen::Vector3d expected_min = p0.cwiseMin(p1).cwiseMin(p2); + const Eigen::Vector3d expected_max = p0.cwiseMax(p1).cwiseMax(p2); + + REQUIRE(bbox.min().isApprox(expected_min)); + REQUIRE(bbox.max().isApprox(expected_max)); + } + + SECTION("transformed empty mesh") + { + lagrange::SurfaceMesh mesh; + const auto bbox = lagrange::mesh_bbox<3>(mesh, Eigen::Affine3d::Identity()); + REQUIRE(bbox.isEmpty()); + } } TEST_CASE("mesh_bbox 2D", "[core][mesh_bbox]") @@ -75,4 +104,17 @@ TEST_CASE("mesh_bbox 2D", "[core][mesh_bbox]") REQUIRE(bbox.max().x() == 4); REQUIRE(bbox.max().y() == 3); } + + SECTION("transformed vertices") + { + lagrange::SurfaceMesh mesh(2); + mesh.add_vertex({-1, 3}); + mesh.add_vertex({4, -2}); + const Eigen::Affine2f transform = Eigen::Translation2f(2, -1) * Eigen::Scaling(2.f, 3.f); + const auto bbox = lagrange::mesh_bbox<2>(mesh, transform); + REQUIRE(bbox.min().x() == 0); + REQUIRE(bbox.min().y() == -7); + REQUIRE(bbox.max().x() == 10); + REQUIRE(bbox.max().y() == 8); + } } diff --git a/modules/core/tests/test_triangulate_polygonal_facets.cpp b/modules/core/tests/test_triangulate_polygonal_facets.cpp index 7672a900..3b6ae30f 100644 --- a/modules/core/tests/test_triangulate_polygonal_facets.cpp +++ b/modules/core/tests/test_triangulate_polygonal_facets.cpp @@ -486,6 +486,76 @@ void test_should_triangulate() } } +template +void test_subdivided_edge(lagrange::TriangulationOptions::Scheme scheme) +{ + using namespace lagrange; + + // Slanted, offset triangle A-B-C: subdividing edge A--B by interpolation puts interior points + // only *near* the line (FP rounding), the case earcut's exact `area == 0` filter misses. + const std::array A{Scalar(0.2), Scalar(0.1), Scalar(0)}; + const std::array B{Scalar(1.7), Scalar(0.9), Scalar(0)}; + const std::array C{Scalar(0.5), Scalar(1.6), Scalar(0)}; + + // 2 * signed area of the original slanted triangle (in the z = 0 plane). + const Scalar det = (B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0]); + const Scalar expected_area = Scalar(0.5) * (det < 0 ? -det : det); + + // Guards that at least one interior point is genuinely off the line A--B (n == 2's exact + // midpoint stays on it), so this isn't silently re-testing the exactly-collinear case. + Scalar overall_max_dev = 0; + + for (Index n : {Index(2), Index(3), Index(5), Index(10)}) { + SurfaceMesh mesh(3); + for (Index i = 0; i < n; ++i) { + const Scalar t = Scalar(i) / Scalar(n); // A (i == 0) and interior subdivisions + mesh.add_vertex({A[0] + t * (B[0] - A[0]), A[1] + t * (B[1] - A[1]), Scalar(0)}); + } + mesh.add_vertex({B[0], B[1], B[2]}); // B + mesh.add_vertex({C[0], C[1], C[2]}); // C + mesh.add_polygon(n + 2, [](span t) { std::iota(t.begin(), t.end(), Index(0)); }); + + auto in = vertex_view(mesh); + for (Index i = 1; i < n; ++i) { + const Scalar d = (B[0] - A[0]) * (in(i, 1) - A[1]) - (B[1] - A[1]) * (in(i, 0) - A[0]); + overall_max_dev = std::max(overall_max_dev, d < 0 ? -d : d); + } + + const Index old_num_vertices = mesh.get_num_vertices(); + TriangulationOptions options; + options.scheme = scheme; + triangulate_polygonal_facets(mesh, options); + + // Triangulation does not insert new vertices. + REQUIRE(mesh.get_num_vertices() == old_num_vertices); + mesh.compress_if_regular(); + REQUIRE(mesh.is_triangle_mesh()); + REQUIRE(mesh.get_num_facets() == n); + + auto positions = vertex_view(mesh); + Scalar total_area = 0; + for (Index f = 0; f < mesh.get_num_facets(); ++f) { + auto verts = mesh.get_facet_vertices(f); + const Eigen::Matrix p0 = positions.row(verts[0]).transpose(); + const Eigen::Matrix e1 = positions.row(verts[1]).transpose() - p0; + const Eigen::Matrix e2 = positions.row(verts[2]).transpose() - p0; + const Eigen::Matrix cross( + e1.y() * e2.z() - e1.z() * e2.y(), + e1.z() * e2.x() - e1.x() * e2.z(), + e1.x() * e2.y() - e1.y() * e2.x()); + const Scalar area = Scalar(0.5) * cross.norm(); + CAPTURE(n, f, area); + REQUIRE(area > Scalar(1e-6)); + total_area += area; + } + // The triangulation must tile the original slanted triangle. + REQUIRE_THAT(total_area, Catch::Matchers::WithinAbs(expected_area, 1e-6)); + } + + CAPTURE(overall_max_dev); + REQUIRE(overall_max_dev > Scalar(0)); +} + } // namespace TEST_CASE("earcut", "[core]") @@ -545,5 +615,23 @@ TEST_CASE("triangulate_polygonal_facets: should_triangulate", "[core]") LA_SURFACE_MESH_X(should_triangulate, 0) } +// May-fail (open earcut robustness gap): with the plain Earcut scheme, near-collinear subdivision +// points get triangulated into a sliver triangle. The Delaunay scheme below refines this away. +TEST_CASE("triangulate_polygonal_facets: subdivided edge", "[core][!mayfail]") +{ +#define LA_X_subdivided_edge(_, Scalar, Index) \ + test_subdivided_edge(lagrange::TriangulationOptions::Scheme::Earcut); + LA_SURFACE_MESH_X(subdivided_edge, 0) +} + +// The Delaunay scheme (earcut + Lawson edge flips) legalizes the sliver's interior diagonal, so the +// same near-collinear input yields only well-shaped triangles. +TEST_CASE("triangulate_polygonal_facets: subdivided edge delaunay", "[core]") +{ +#define LA_X_subdivided_edge_delaunay(_, Scalar, Index) \ + test_subdivided_edge(lagrange::TriangulationOptions::Scheme::Delaunay); + LA_SURFACE_MESH_X(subdivided_edge_delaunay, 0) +} + // TODO: Test removal degenerate facets, once we allow sizes <= 2 // TODO: Test with 2d meshes diff --git a/modules/geodesic/extras/geometrycentral/GeodesicEngineFlip.cpp b/modules/geodesic/extras/geometrycentral/GeodesicEngineFlip.cpp new file mode 100644 index 00000000..52a4452f --- /dev/null +++ b/modules/geodesic/extras/geometrycentral/GeodesicEngineFlip.cpp @@ -0,0 +1,192 @@ +/* + * 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 "geometry_central_utils.h" + +// clang-format off +#include +#include +#include +// clang-format on + +#include + +namespace lagrange::geodesic { + +using gcManifoldMesh = gc::gcManifoldMesh; +using gcGeometry = gc::gcGeometry; +using gcSurfacePoint = gc::gcSurfacePoint; + +template +struct GeodesicEngineFlip::Impl +{ + std::unique_ptr m_gc_mesh; + std::unique_ptr m_gc_geom; + Options m_options; +}; + +template +GeodesicEngineFlip::GeodesicEngineFlip(Mesh& mesh, const Options& options) + : Super(mesh) + , m_impl(lagrange::make_value_ptr()) +{ + auto [gc_mesh, gc_geom] = gc::extract_gc_manifold_mesh(this->mesh()); + m_impl->m_gc_mesh = std::move(gc_mesh); + m_impl->m_gc_geom = std::move(gc_geom); + m_impl->m_options = options; +} + +/// @cond LA_INTERNAL_DOCS +template +GeodesicEngineFlip::~GeodesicEngineFlip() = default; +template +GeodesicEngineFlip::GeodesicEngineFlip(GeodesicEngineFlip&&) = + default; +template +GeodesicEngineFlip& GeodesicEngineFlip::operator=( + GeodesicEngineFlip&&) = default; +/// @endcond + +template +SingleSourceGeodesicResult GeodesicEngineFlip::single_source_geodesic( + const SingleSourceGeodesicOptions& /*options*/) +{ + throw Error( + "Single source geodesic is not supported by GeodesicEngineFlip. " + "Use GeodesicEngineHeat or GeodesicEngineMMP instead."); +} + +namespace { + +/// Find the nearest vertex to a surface point specified by facet and barycentric coordinates. +geometrycentral::surface::Vertex +find_nearest_vertex(gcManifoldMesh& gc_mesh, size_t facet_id, const std::array& facet_bc) +{ + gcSurfacePoint sp( + gc_mesh.face(facet_id), + geometrycentral::Vector3{1.0 - facet_bc[0] - facet_bc[1], facet_bc[0], facet_bc[1]}); + return sp.nearestVertex(); +} + +} // namespace + +template +Scalar GeodesicEngineFlip::point_to_point_geodesic( + const PointToPointGeodesicOptions& options) +{ + auto source_vertex = + find_nearest_vertex(*m_impl->m_gc_mesh, options.source_facet_id, options.source_facet_bc); + auto target_vertex = + find_nearest_vertex(*m_impl->m_gc_mesh, options.target_facet_id, options.target_facet_bc); + + if (source_vertex == target_vertex) { + return Scalar(0); + } + + auto network = geometrycentral::surface::FlipEdgeNetwork::constructFromDijkstraPath( + *m_impl->m_gc_mesh, + *m_impl->m_gc_geom, + source_vertex, + target_vertex); + + la_runtime_assert(network != nullptr, "Failed to construct flip edge network"); + + network->iterativeShorten( + m_impl->m_options.max_iterations == 0 ? geometrycentral::INVALID_IND + : m_impl->m_options.max_iterations, + m_impl->m_options.max_relative_length_decrease); + + return static_cast(network->length()); +} + +template +GeodesicPathResult GeodesicEngineFlip::point_to_point_geodesic_path( + const PointToPointGeodesicPathOptions& options) +{ + auto source_vertex = + find_nearest_vertex(*m_impl->m_gc_mesh, options.source_facet_id, options.source_facet_bc); + auto target_vertex = + find_nearest_vertex(*m_impl->m_gc_mesh, options.target_facet_id, options.target_facet_bc); + + GeodesicPathResult result; + + // Handle degenerate case: source and target are the same vertex + if (source_vertex == target_vertex) { + geometrycentral::Vector3 pos = m_impl->m_gc_geom->inputVertexPositions[source_vertex]; + result.points.push_back( + {static_cast(pos.x), static_cast(pos.y), static_cast(pos.z)}); + return result; + } + + // Construct a Dijkstra path and iteratively shorten it via edge flips + auto network = geometrycentral::surface::FlipEdgeNetwork::constructFromDijkstraPath( + *m_impl->m_gc_mesh, + *m_impl->m_gc_geom, + source_vertex, + target_vertex); + + la_runtime_assert(network != nullptr, "Failed to construct flip edge network"); + + // Iteratively shorten the path via edge flips until locally shortest + network->iterativeShorten( + m_impl->m_options.max_iterations == 0 ? geometrycentral::INVALID_IND + : m_impl->m_options.max_iterations, + m_impl->m_options.max_relative_length_decrease); + + // Extract the path as surface points on the original mesh + auto polylines = network->getPathPolyline(); + la_runtime_assert(!polylines.empty(), "Flip geodesic produced no paths"); + + // We expect a single path + const auto& path_points = polylines[0]; + + if (path_points.empty()) { + return result; + } + + const size_t n = path_points.size(); + result.points.resize(n); + result.facet_ids.resize(n > 1 ? n - 1 : 0); + + for (size_t i = 0; i < n; ++i) { + geometrycentral::Vector3 pos = + path_points[i].interpolate(m_impl->m_gc_geom->inputVertexPositions); + result.points[i] = { + static_cast(pos.x), + static_cast(pos.y), + static_cast(pos.z)}; + } + + // Determine the facet for each path segment + for (size_t i = 0; i + 1 < n; ++i) { + auto f = geometrycentral::surface::sharedFace(path_points[i], path_points[i + 1]); + la_runtime_assert( + f != geometrycentral::surface::Face(), + "No shared face found for path segment"); + result.facet_ids[i] = static_cast(f.getIndex()); + } + + return result; +} + +#define LA_X_GeodesicEngineFlip(_, Scalar, Index) \ + template class LA_GEODESIC_API GeodesicEngineFlip; +LA_SURFACE_MESH_X(GeodesicEngineFlip, 0) + +} // namespace lagrange::geodesic diff --git a/modules/geodesic/extras/geometrycentral/geometry_central_utils.h b/modules/geodesic/extras/geometrycentral/geometry_central_utils.h index b89de70d..8907497c 100644 --- a/modules/geodesic/extras/geometrycentral/geometry_central_utils.h +++ b/modules/geodesic/extras/geometrycentral/geometry_central_utils.h @@ -12,9 +12,11 @@ #pragma once #include +#include // clang-format off #include +#include #include #include #include @@ -25,6 +27,7 @@ namespace lagrange::geodesic::gc { using gcSurfaceMesh = geometrycentral::surface::SurfaceMesh; +using gcManifoldMesh = geometrycentral::surface::ManifoldSurfaceMesh; using gcGeometry = geometrycentral::surface::VertexPositionGeometry; using gcSurfacePoint = geometrycentral::surface::SurfacePoint; @@ -39,4 +42,15 @@ std::tuple, std::unique_ptr> extract_ return std::make_tuple(std::move(gc_mesh), std::move(gc_geom)); } +template +std::tuple, std::unique_ptr> extract_gc_manifold_mesh( + SurfaceMesh& mesh) +{ + auto vertices = vertex_view(mesh); + auto facets = facet_view(mesh); + auto gc_mesh = std::make_unique(facets); + auto gc_geom = std::make_unique(*gc_mesh, vertices); + return std::make_tuple(std::move(gc_mesh), std::move(gc_geom)); +} + } // namespace lagrange::geodesic::gc diff --git a/modules/geodesic/geodesic.md b/modules/geodesic/geodesic.md index d40b409e..2ad1b2ae 100644 --- a/modules/geodesic/geodesic.md +++ b/modules/geodesic/geodesic.md @@ -7,7 +7,7 @@ Geodesic Module @brief Geodesic distance computation on meshes. The base `lagrange::geodesic` target provides the generic and DGPC engines without a -geometry-central dependency. The heat-method and MMP engines are provided by the optional +geometry-central dependency. The heat-method, MMP, and flip engines are provided by the optional `geometrycentral` component. @code{.cmake} @@ -15,5 +15,5 @@ lagrange_include_module(geodesic COMPONENTS geometrycentral) target_link_libraries(my_target PRIVATE lagrange::geodesic::geometrycentral) @endcode -Including `GeodesicEngineHeat.h` or `GeodesicEngineMMP.h` without linking the component produces a -compile-time error that names the required target. +Including `GeodesicEngineHeat.h`, `GeodesicEngineMMP.h`, or `GeodesicEngineFlip.h` without linking +the component produces a compile-time error that names the required target. diff --git a/modules/geodesic/include/lagrange/geodesic/Algorithm.h b/modules/geodesic/include/lagrange/geodesic/Algorithm.h index 1996e446..874a7d90 100644 --- a/modules/geodesic/include/lagrange/geodesic/Algorithm.h +++ b/modules/geodesic/include/lagrange/geodesic/Algorithm.h @@ -26,7 +26,11 @@ namespace lagrange::geodesic { /// approach to computing distance based on heat flow." ACM Transactions on Graphics (TOG) 32.5 /// (2013): 1-11. /// -/// @note MMP is often considered as as exact method, while DGPC and HEAT are approximations. -enum class Algorithm { DGPC, MMP, HEAT }; +/// - FLIP: Sharp, Nicholas, and Keenan Crane. "You can find geodesic paths in triangle meshes +/// by just flipping edges." ACM Transactions on Graphics (SIGGRAPH Asia 2020). +/// +/// @note MMP is often considered an exact method, while DGPC, HEAT, and FLIP are +/// approximations. FLIP is particularly well-suited for geodesic path computation. +enum class Algorithm { DGPC, MMP, HEAT, FLIP }; } // namespace lagrange::geodesic diff --git a/modules/geodesic/include/lagrange/geodesic/GeodesicEngineFlip.h b/modules/geodesic/include/lagrange/geodesic/GeodesicEngineFlip.h new file mode 100644 index 00000000..e06a5686 --- /dev/null +++ b/modules/geodesic/include/lagrange/geodesic/GeodesicEngineFlip.h @@ -0,0 +1,132 @@ +/* + * 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 + +#ifndef LAGRANGE_GEODESIC_WITH_GEOMETRYCENTRAL + #error "GeodesicEngineFlip requires the lagrange::geodesic::geometrycentral component" +#endif + +#include + +namespace lagrange::geodesic { + +/// +/// Computes surface geodesics using the edge-flip method. This method finds geodesic paths by +/// iteratively flipping edges to straighten an initial Dijkstra path, producing high-quality +/// approximate geodesics efficiently. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// Based on the following paper: +/// +/// Sharp, Nicholas, and Keenan Crane. "You can find geodesic paths in triangle meshes by just +/// flipping edges." ACM Transactions on Graphics (SIGGRAPH Asia 2020). +/// +template +class GeodesicEngineFlip : public GeodesicEngine +{ +public: + using Super = GeodesicEngine; ///< Parent class type. + using Mesh = typename Super::Mesh; ///< The mesh type. + + /// Options for the flip geodesic engine. + struct Options + { + /// Maximum number of iterations for the iterative shortening procedure. + /// If 0, uses the geometry-central default (unlimited). + size_t max_iterations = 0; + + /// Fraction of the initial path length below which shortening stops. + /// If 0, length-based early stopping is disabled. + double max_relative_length_decrease = 0.0; + }; + +public: + /// + /// Precompute any data required for repeated geodesic path computation. + /// + /// @param mesh Reference to the input mesh. + /// @param options Options for the flip geodesic engine. + /// + explicit GeodesicEngineFlip(Mesh& mesh, const Options& options = {}); + + virtual ~GeodesicEngineFlip(); + GeodesicEngineFlip(GeodesicEngineFlip&&); + GeodesicEngineFlip& operator=(GeodesicEngineFlip&&); + GeodesicEngineFlip(const GeodesicEngineFlip&) = delete; + GeodesicEngineFlip& operator=(const GeodesicEngineFlip&) = delete; + + /// + /// @note Single source geodesic is not supported by this engine. Use GeodesicEngineHeat or + /// GeodesicEngineMMP instead. + /// + /// @throws lagrange::Error always. + /// + SingleSourceGeodesicResult single_source_geodesic( + const SingleSourceGeodesicOptions& options) override; + + /// + /// Compute the geodesic distance between two points using the edge-flip method. + /// + /// @note The source and target points are snapped to the nearest mesh vertex before computing + /// the geodesic. This is a limitation of the flip-based algorithm. + /// + /// @param options The options for the computation. + /// + /// @return The geodesic distance between the source and target points. + /// + Scalar point_to_point_geodesic(const PointToPointGeodesicOptions& options) override; + + /// + /// Compute the geodesic path between two points using the edge-flip method. + /// + /// This function finds a geodesic path by first computing a Dijkstra path between the two + /// closest mesh vertices, then iteratively shortening it via edge flips until the path is + /// locally shortest. + /// + /// @note The source and target points are snapped to the nearest mesh vertex before computing + /// the geodesic. This is a limitation of the flip-based algorithm. + /// + /// @param options The options for the path computation. + /// + /// @return A GeodesicPathResult containing the ordered path points and segment facet + /// indices. Returns an empty result if no path exists. + /// + GeodesicPathResult point_to_point_geodesic_path( + const PointToPointGeodesicPathOptions& options) override; + +protected: + struct Impl; + lagrange::value_ptr m_impl; +}; + +/// +/// Helper function to create a Flip geodesic engine. +/// +/// @param mesh Input mesh. +/// @param options Options for the flip geodesic engine. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return Flip geodesic engine. +/// +template +GeodesicEngineFlip make_flip_engine( + SurfaceMesh& mesh, + const typename GeodesicEngineFlip::Options& options = {}) +{ + return GeodesicEngineFlip(mesh, options); +} + +} // namespace lagrange::geodesic diff --git a/modules/geodesic/python/src/geodesic.cpp b/modules/geodesic/python/src/geodesic.cpp index 05fd9af8..634c12d6 100644 --- a/modules/geodesic/python/src/geodesic.cpp +++ b/modules/geodesic/python/src/geodesic.cpp @@ -11,6 +11,7 @@ */ #include +#include #include #include #include @@ -201,6 +202,31 @@ void populate_geodesic_module(nb::module_& m) :returns: The attribute ID of the computed geodesic distance attributes.)"); def_point_to_point_geodesic(cls_mmp); def_point_to_point_geodesic_path(cls_mmp); + + // Flip engine + using FlipOptions = GeodesicEngineFlip::Options; + nb::class_> cls_flip(m, "GeodesicEngineFlip"); + cls_flip.def( + "__init__", + [](GeodesicEngineFlip* self, + lagrange::SurfaceMesh& mesh, + size_t max_iterations, + double max_relative_length_decrease) { + FlipOptions options; + options.max_iterations = max_iterations; + options.max_relative_length_decrease = max_relative_length_decrease; + new (self) GeodesicEngineFlip(mesh, options); + }, + "mesh"_a, + "max_iterations"_a = FlipOptions{}.max_iterations, + "max_relative_length_decrease"_a = FlipOptions{}.max_relative_length_decrease, + R"(Create a flip geodesic engine. + +:param mesh: The input triangle mesh. +:param max_iterations: Maximum number of shortening iterations (0 = unlimited). +:param max_relative_length_decrease: Stop shortening when the path length falls below this fraction of its initial length (0 = disabled).)"); + def_point_to_point_geodesic(cls_flip); + def_point_to_point_geodesic_path(cls_flip); } } // namespace lagrange::python diff --git a/modules/geodesic/python/tests/test_geodesic.py b/modules/geodesic/python/tests/test_geodesic.py index 2f434a19..3052da45 100644 --- a/modules/geodesic/python/tests/test_geodesic.py +++ b/modules/geodesic/python/tests/test_geodesic.py @@ -108,6 +108,40 @@ def test_point_to_point_path(self, single_triangle): assert len(facet_ids) == len(points) - 1 # One facet per segment +class TestFlipEngine: + def test_point_to_point(self, single_triangle): + engine = lagrange.geodesic.GeodesicEngineFlip(single_triangle) + distance = engine.point_to_point_geodesic( + source_facet_id=0, + source_facet_bc=[0.3, 0.3], + target_facet_id=0, + target_facet_bc=[0.6, 0.2], + ) + assert distance >= 0.0 + + def test_point_to_point_path(self, single_triangle): + engine = lagrange.geodesic.GeodesicEngineFlip(single_triangle) + points, facet_ids = engine.point_to_point_geodesic_path( + source_facet_id=0, + source_facet_bc=[0.3, 0.3], + target_facet_id=0, + target_facet_bc=[0.6, 0.2], + ) + assert len(points) >= 2 + assert all(len(p) == 3 for p in points) + assert len(facet_ids) == len(points) - 1 + + def test_point_to_point_on_sphere(self, sphere_mesh): + engine = lagrange.geodesic.GeodesicEngineFlip(sphere_mesh) + distance = engine.point_to_point_geodesic( + source_facet_id=0, + source_facet_bc=[0.33, 0.33], + target_facet_id=50, + target_facet_bc=[0.33, 0.33], + ) + assert distance > 0.0 + + class TestSphereGeodesic: """ Test geodesic distances on a sphere. diff --git a/modules/geodesic/src/GeodesicEngine.cpp b/modules/geodesic/src/GeodesicEngine.cpp index c59602b3..40978969 100644 --- a/modules/geodesic/src/GeodesicEngine.cpp +++ b/modules/geodesic/src/GeodesicEngine.cpp @@ -58,7 +58,8 @@ GeodesicPathResult GeodesicEngine::point_to_point_ // Derived classes should override this method to provide actual path computation throw Error( "Geodesic path extraction is not supported by this engine. " - "Use GeodesicEngineMMP for exact path computation."); + "Use GeodesicEngineMMP for exact path computation, " + "or GeodesicEngineFlip for flip-based geodesic path computation."); } #define LA_X_GeodesicEngine(_, Scalar, Index) \ diff --git a/modules/geodesic/tests/test_geodesic_path_flip.cpp b/modules/geodesic/tests/test_geodesic_path_flip.cpp new file mode 100644 index 00000000..39f581b9 --- /dev/null +++ b/modules/geodesic/tests/test_geodesic_path_flip.cpp @@ -0,0 +1,202 @@ +/* + * 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 + +namespace { + +using Scalar = float; +using Index = uint32_t; + +} // namespace + +TEST_CASE("geodesic_path_flip", "[geodesic][path][flip]") +{ + auto mesh = lagrange::testing::load_surface_mesh("open/core/ball.obj"); + + SECTION("Path exists") + { + auto engine = lagrange::geodesic::make_flip_engine(mesh); + + lagrange::geodesic::PointToPointGeodesicPathOptions options; + options.source_facet_id = 0; + options.target_facet_id = 10; + options.source_facet_bc = {0.0, 0.0}; + options.target_facet_bc = {0.0, 0.0}; + + auto result = engine.point_to_point_geodesic_path(options); + + REQUIRE(!result.points.empty()); + REQUIRE(result.points.size() >= 2); + REQUIRE(result.facet_ids.size() == result.points.size() - 1); + } + + SECTION("Path from point to itself") + { + auto engine = lagrange::geodesic::make_flip_engine(mesh); + + lagrange::geodesic::PointToPointGeodesicPathOptions options; + options.source_facet_id = 0; + options.target_facet_id = 0; + options.source_facet_bc = {0.0, 0.0}; + options.target_facet_bc = {0.0, 0.0}; + + auto result = engine.point_to_point_geodesic_path(options); + + // Same vertex: should return a single point + REQUIRE(!result.points.empty()); + } + + SECTION("Path endpoints are valid vertices") + { + auto engine = lagrange::geodesic::make_flip_engine(mesh); + + lagrange::geodesic::PointToPointGeodesicPathOptions options; + options.source_facet_id = 5; + options.target_facet_id = 25; + options.source_facet_bc = {0.3, 0.3}; + options.target_facet_bc = {0.2, 0.4}; + + auto result = engine.point_to_point_geodesic_path(options); + REQUIRE(!result.points.empty()); + + // Verify facet_ids are valid + for (auto fid : result.facet_ids) { + REQUIRE(fid < mesh.get_num_facets()); + } + } + + SECTION("Path length on sphere (north to south pole)") + { + // Use subdivided icosphere to ensure a manifold mesh (no duplicate edges at poles). + lagrange::primitive::SubdividedSphereOptions sphere_options; + sphere_options.radius = 10.0f; + sphere_options.subdiv_level = 3; + + auto ico = lagrange::primitive::generate_icosahedron({}); + auto sphere = lagrange::primitive::generate_subdivided_sphere(ico, sphere_options); + + auto engine = lagrange::geodesic::make_flip_engine(sphere); + auto vertices = lagrange::vertex_view(sphere); + auto facets = lagrange::facet_view(sphere); + + Scalar radius = static_cast(sphere_options.radius); + + // Find north pole + Index north_vertex = 0; + Scalar max_z = std::numeric_limits::lowest(); + for (Index v = 0; v < sphere.get_num_vertices(); ++v) { + Scalar z = vertices(v, 2); + if (z > max_z) { + max_z = z; + north_vertex = v; + } + } + + // Find south pole + Index south_vertex = 0; + Scalar min_z = std::numeric_limits::max(); + for (Index v = 0; v < sphere.get_num_vertices(); ++v) { + Scalar z = vertices(v, 2); + if (z < min_z) { + min_z = z; + south_vertex = v; + } + } + + // Find facets containing poles + Index north_facet = lagrange::invalid(); + Index south_facet = lagrange::invalid(); + Index north_lc = 0; + Index south_lc = 0; + + for (Index f = 0; f < sphere.get_num_facets(); ++f) { + auto facet = facets.row(f); + if (north_facet == lagrange::invalid()) { + if (facet[0] == north_vertex) { + north_facet = f; + north_lc = 0; + } else if (facet[1] == north_vertex) { + north_facet = f; + north_lc = 1; + } else if (facet[2] == north_vertex) { + north_facet = f; + north_lc = 2; + } + } + if (south_facet == lagrange::invalid()) { + if (facet[0] == south_vertex) { + south_facet = f; + south_lc = 0; + } else if (facet[1] == south_vertex) { + south_facet = f; + south_lc = 1; + } else if (facet[2] == south_vertex) { + south_facet = f; + south_lc = 2; + } + } + + if (north_facet != lagrange::invalid() && + south_facet != lagrange::invalid()) { + break; + } + } + + REQUIRE(north_facet != lagrange::invalid()); + REQUIRE(south_facet != lagrange::invalid()); + + lagrange::geodesic::PointToPointGeodesicPathOptions options; + options.source_facet_id = north_facet; + options.target_facet_id = south_facet; + options.source_facet_bc = {0.0, 0.0}; + options.target_facet_bc = {0.0, 0.0}; + if (north_lc != 0) { + options.source_facet_bc[north_lc - 1] = 1; + } + if (south_lc != 0) { + options.target_facet_bc[south_lc - 1] = 1; + } + + auto result = engine.point_to_point_geodesic_path(options); + + REQUIRE(result.points.size() >= 2); + REQUIRE(result.facet_ids.size() == result.points.size() - 1); + + // Compute total path length + Scalar total_length = Scalar(0); + for (size_t i = 0; i + 1 < result.points.size(); ++i) { + Scalar dx = result.points[i + 1][0] - result.points[i][0]; + Scalar dy = result.points[i + 1][1] - result.points[i][1]; + Scalar dz = result.points[i + 1][2] - result.points[i][2]; + total_length += std::sqrt(dx * dx + dy * dy + dz * dz); + } + + // For a sphere, the geodesic distance between antipodal points is pi * radius. + // Use a 2% tolerance (flip method is approximate, slightly less accurate than MMP). + Scalar expected_length = static_cast(M_PI) * radius; + + REQUIRE_THAT(total_length, Catch::Matchers::WithinRel(expected_length, 0.02f)); + + for (auto fid : result.facet_ids) { + REQUIRE(fid < sphere.get_num_facets()); + } + } +} diff --git a/modules/io/CMakeLists.txt b/modules/io/CMakeLists.txt index c05eef4a..160dd4a0 100644 --- a/modules/io/CMakeLists.txt +++ b/modules/io/CMakeLists.txt @@ -25,6 +25,7 @@ include(libigl) # TODO: remove libigl later include(mshio) lagrange_find_package(happly REQUIRED) include(ufbx) +include(pcdio) target_link_libraries(lagrange_io PUBLIC lagrange::core @@ -39,6 +40,7 @@ target_link_libraries(lagrange_io tinygltf::tinygltf ufbx::ufbx mshio::mshio + pcdio::pcdio ) option(LAGRANGE_WITH_ASSIMP "Add assimp functionality to lagrange::io" OFF) diff --git a/modules/io/include/lagrange/io/internal/detect_file_format.h b/modules/io/include/lagrange/io/internal/detect_file_format.h index 4738bf6b..9381471c 100644 --- a/modules/io/include/lagrange/io/internal/detect_file_format.h +++ b/modules/io/include/lagrange/io/internal/detect_file_format.h @@ -11,6 +11,7 @@ */ #pragma once +#include #include #include @@ -24,6 +25,8 @@ namespace lagrange::io::internal { /// /// @return The detected file format. /// -lagrange::io::FileFormat detect_file_format(std::istream& input_stream); +/// @note This function is exported from the IO library in shared builds. +/// +LA_IO_API lagrange::io::FileFormat detect_file_format(std::istream& input_stream); } // namespace lagrange::io::internal diff --git a/modules/io/include/lagrange/io/load_mesh_pcd.h b/modules/io/include/lagrange/io/load_mesh_pcd.h new file mode 100644 index 00000000..38ca2f8d --- /dev/null +++ b/modules/io/include/lagrange/io/load_mesh_pcd.h @@ -0,0 +1,60 @@ +/* + * 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::io { + +/** + * Loads a point cloud from a stream in PCD (Point Cloud Data) format. + * + * The resulting mesh contains only vertices (no facets). The `x`, `y`, `z` fields populate the + * vertex positions. `normal_x`/`normal_y`/`normal_z` (or `nx`/`ny`/`nz`) are loaded as a + * `AttributeName::normal` vertex attribute. Packed `rgb`/`rgba` fields are unpacked into a + * `AttributeName::color` vertex attribute (uint8, 3 or 4 channels). Any remaining field is loaded + * as a vertex attribute named after the field. The `ascii`, `binary`, and `binary_compressed` + * (LZF) PCD data sections are all supported. + * + * @param[in] input_stream Input stream. + * @param[in] options Load options. + * + * @tparam MeshType Mesh type to load. + * + * @return Loaded point cloud. + */ +template +LA_IO_API MeshType load_mesh_pcd(std::istream& input_stream, const LoadOptions& options = {}); + +/** + * @overload + * + * Loads a point cloud from a file in PCD (Point Cloud Data) format. + * + * @param[in] filename Input filename. + * @param[in] options Load options. + * + * @tparam MeshType Mesh type to load. + * + * @see @ref load_mesh_pcd + * + * @return Loaded point cloud. + */ +template +LA_IO_API MeshType load_mesh_pcd(const fs::path& filename, const LoadOptions& options = {}); + +} // namespace lagrange::io diff --git a/modules/io/include/lagrange/io/save_mesh_pcd.h b/modules/io/include/lagrange/io/save_mesh_pcd.h new file mode 100644 index 00000000..6ad34c37 --- /dev/null +++ b/modules/io/include/lagrange/io/save_mesh_pcd.h @@ -0,0 +1,65 @@ +/* + * 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::io { + +/// +/// Saves a mesh (point cloud) to a stream in PCD (Point Cloud Data) format. +/// +/// Only vertices are written (facets are ignored). Vertex positions are written as `x`/`y`/`z` +/// fields. A vertex attribute with `AttributeUsage::Normal` is written as +/// `normal_x`/`normal_y`/`normal_z`. A vertex attribute with `AttributeUsage::Color` is written as +/// a packed `rgb` (3 channels) or `rgba` (4 channels) field. Any other vertex attribute is written +/// as a field named after the attribute. When `encoding` is `Ascii` the data is written as `ascii`; +/// otherwise it is written as LZF-compressed `binary_compressed` (binary output is always +/// compressed). +/// +/// @param[in,out] output_stream Output stream. +/// @param[in] mesh Mesh to write. +/// @param[in] options Save options. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_IO_API void save_mesh_pcd( + std::ostream& output_stream, + const SurfaceMesh& mesh, + const SaveOptions& options = {}); + +/// +/// @overload +/// +/// Saves a mesh (point cloud) to a file in PCD (Point Cloud Data) format. +/// +/// @param[in] filename Output filename. +/// @param[in] mesh Mesh to write. +/// @param[in] options Save options. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_IO_API void save_mesh_pcd( + const fs::path& filename, + const SurfaceMesh& mesh, + const SaveOptions& options = {}); + +} // namespace lagrange::io diff --git a/modules/io/include/lagrange/io/types.h b/modules/io/include/lagrange/io/types.h index 2bf55565..bea55a4e 100644 --- a/modules/io/include/lagrange/io/types.h +++ b/modules/io/include/lagrange/io/types.h @@ -23,7 +23,7 @@ namespace lagrange { namespace io { enum class FileEncoding { Binary, Ascii }; -enum class FileFormat { Obj, Ply, Gltf, Msh, Fbx, Stl, Unknown }; +enum class FileFormat { Obj, Ply, Gltf, Msh, Fbx, Stl, Pcd, Unknown }; /** * Options used when saving a mesh or a scene. diff --git a/modules/io/io.md b/modules/io/io.md index f621c2c1..8a59c75b 100644 --- a/modules/io/io.md +++ b/modules/io/io.md @@ -16,3 +16,9 @@ Quick links - [save_mesh](@ref lagrange::io::save_mesh) - [save_mesh_obj](@ref lagrange::io::save_mesh_obj) - [save_mesh_ply](@ref lagrange::io::save_mesh_ply) + +PCD format support +------------------ + +PCD input and output are always available in CMake and MetaBuild builds through the public +[pcdio](https://github.com/adobe/pcdio) dependency. diff --git a/modules/io/python/src/io.cpp b/modules/io/python/src/io.cpp index f6ec9640..1e68260c 100644 --- a/modules/io/python/src/io.cpp +++ b/modules/io/python/src/io.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -179,11 +181,11 @@ void populate_io_module(nb::module_& m) "selected_attributes"_a = nb::none(), R"(Save mesh to file. -Filename extension determines the file format. Supported formats are: `obj`, `ply`, `msh`, `glb`, `gltf` and `lgm`. +Filename extension determines the file format. Supported formats are: `obj`, `ply`, `msh`, `glb`, `gltf`, `pcd` and `lgm`. :param filename: The output file name. :param mesh: The input mesh. -:param binary: Whether to save the mesh in binary format if supported. Defaults to True. Only `msh`, `ply` and `glb` support binary format. +:param binary: Whether to save the mesh in binary format if supported. Defaults to True. Only `msh`, `ply`, `glb` and `pcd` support binary format. :param exact_match: Whether to save attributes in their exact form. Some mesh formats may not support all the attribute types. If set to False, attributes will be converted to the closest supported attribute type. Defaults to True. :param selected_attributes: A list of attribute ids to save. If not specified, all attributes will be saved. Defaults to None.)"); @@ -366,6 +368,8 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl } else if (format == "glb") { opts.encoding = io::FileEncoding::Binary; io::save_mesh_gltf(ss, mesh, opts); + } else if (format == "pcd") { + io::save_mesh_pcd(ss, mesh, opts); } else { throw std::invalid_argument(lagrange::format("Unsupported format: {}", format)); } @@ -381,8 +385,8 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl R"(Convert a mesh to a binary string based on specified format. :param mesh: The input mesh. -:param format: Format to use. Supported formats are "obj", "ply", "msh", "gltf" and "glb". -:param binary: Whether to save the mesh in binary format if supported. Defaults to True. Only `msh`, `ply` and `glb` support binary format. +:param format: Format to use. Supported formats are "obj", "ply", "msh", "gltf", "glb" and "pcd". +:param binary: Whether to save the mesh in binary format if supported. Defaults to True. Only `msh`, `ply`, `glb` and `pcd` support binary format. :param exact_match: Whether to save attributes in their exact form. Some mesh formats may not support all the attribute types. If set to False, attributes will be converted to the closest supported attribute type. Defaults to True. :param selected_attributes: A list of attribute ids to save. If not specified, all attributes will be saved. Defaults to None. @@ -402,7 +406,7 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl R"(Convert a binary string to a mesh. The binary string should use one of the supported formats. Supported formats include `obj`, `ply`, -`gltf`, `glb`, `fbx` and `msh`. Format is automatically detected. +`gltf`, `glb`, `fbx`, `pcd` and `msh`. Format is automatically detected. :param data: A binary string representing the mesh data in a supported format. :param triangulate: Whether to triangulate the mesh if it is not already triangulated. Defaults to False. diff --git a/modules/io/python/tests/test_io.py b/modules/io/python/tests/test_io.py index 972749f5..fc95dafb 100644 --- a/modules/io/python/tests/test_io.py +++ b/modules/io/python/tests/test_io.py @@ -221,6 +221,45 @@ def test_mesh_string_conversion(self, triangle_with_uv_normal_color): mesh2 = lagrange.io.string_to_mesh(gltf_data) assert_same_vertices_and_facets(mesh, mesh2) + def test_pcd(self): + mesh = lagrange.SurfaceMesh() + mesh.add_vertices( + np.array([[0.0, 0.0, 0.0], [1.0, 2.0, 3.0], [-1.0, -2.0, -3.0]], dtype=np.float64) + ) + mesh.create_attribute( + "normal", + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Normal, + initial_values=np.array( + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32 + ), + ) + mesh.create_attribute( + "color", + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Color, + initial_values=np.array( + [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]], dtype=np.uint8 + ), + ) + + for binary in [False, True]: + with tempfile.TemporaryDirectory() as tmp_dir: + filename = pathlib.Path(tmp_dir) / "cloud.pcd" + lagrange.io.save_mesh(filename, mesh, binary=binary) + mesh2 = lagrange.io.load_mesh(filename) + assert mesh2.num_vertices == mesh.num_vertices + assert mesh2.num_facets == 0 + assert np.allclose(mesh.vertices, mesh2.vertices) + assert_same_attribute(mesh, mesh2, lagrange.AttributeUsage.Normal, True) + assert_same_attribute(mesh, mesh2, lagrange.AttributeUsage.Color, True) + + # String conversion round-trip. + pcd_data = lagrange.io.mesh_to_string(mesh, "pcd") + mesh3 = lagrange.io.string_to_mesh(pcd_data) + assert mesh3.num_vertices == mesh.num_vertices + assert np.allclose(mesh.vertices, mesh3.vertices) + def test_io_with_selected_attributed(self, triangle): mesh = triangle selected_attributes = [ diff --git a/modules/io/src/internal/detect_file_format.cpp b/modules/io/src/internal/detect_file_format.cpp index c392371b..a5681f7c 100644 --- a/modules/io/src/internal/detect_file_format.cpp +++ b/modules/io/src/internal/detect_file_format.cpp @@ -14,10 +14,37 @@ #include #include +#include #include namespace lagrange::io::internal { +namespace { + +// Returns true if the header window looks like a PCD file: skipping blank and comment lines, the +// first meaningful line is the canonical "# .PCD" banner or begins with a PCD header keyword. +bool looks_like_pcd(std::string_view window) +{ + while (!window.empty()) { + const size_t end = window.find_first_of("\r\n"); + std::string_view line = window.substr(0, end); + window.remove_prefix(end == std::string_view::npos ? window.size() : end + 1); + + const size_t start = line.find_first_not_of(" \t"); + if (start == std::string_view::npos) continue; + line.remove_prefix(start); + if (starts_with(line, "# .PCD")) return true; + if (line[0] == '#') continue; // Other comment lines are allowed before the header. + + // First meaningful line decides: PCD headers start with VERSION/FIELDS/COLUMNS. + return starts_with(line, "VERSION") || starts_with(line, "FIELDS") || + starts_with(line, "COLUMNS"); + } + return false; +} + +} // namespace + FileFormat detect_file_format(std::istream& input_stream) { if (input_stream.peek() == EOF) { @@ -25,29 +52,35 @@ FileFormat detect_file_format(std::istream& input_stream) } la_runtime_assert(input_stream.good(), "Input stream is not good."); - // Extract file header. + // Read a header window (restoring the stream position) so we can look past leading comments. auto pos = input_stream.tellg(); - char header[5]; - input_stream.read(header, 5); + char buffer[1024]; + input_stream.read(buffer, sizeof(buffer)); + const std::streamsize count = input_stream.gcount(); + input_stream.clear(); input_stream.seekg(pos); - std::string_view header_str(header, 5); + std::string_view window(buffer, static_cast(count)); + std::string_view header = window.substr(0, std::min(window.size(), 5)); - if (starts_with(header_str, "glTF")) { + if (starts_with(header, "glTF")) { return FileFormat::Gltf; - } else if (starts_with(header_str, "{")) { + } else if (starts_with(header, "{")) { return FileFormat::Gltf; - } else if (starts_with(header_str, "ply")) { + } else if (starts_with(header, "ply")) { return FileFormat::Ply; - } else if (starts_with(header_str, "$Mesh")) { + } else if (starts_with(header, "$Mesh")) { return FileFormat::Msh; - } else if (starts_with(header_str, "Kayda")) { + } else if (starts_with(header, "Kayda")) { // FBX binary header starts with "Kaydara FBX Binary". return FileFormat::Fbx; - } else if (starts_with(header_str, "solid")) { + } else if (starts_with(header, "solid")) { return FileFormat::Stl; + } else if (looks_like_pcd(window)) { + // PCD files may begin with arbitrary comment lines before the VERSION/FIELDS header. + return FileFormat::Pcd; } else { for (auto& flag : {"v", "f", "o", "u", "s", "g", "#"}) { - if (starts_with(header_str, flag)) { + if (starts_with(header, flag)) { return FileFormat::Obj; } } diff --git a/modules/io/src/internal/pcd_utils.h b/modules/io/src/internal/pcd_utils.h new file mode 100644 index 00000000..9db0e856 --- /dev/null +++ b/modules/io/src/internal/pcd_utils.h @@ -0,0 +1,70 @@ +/* + * 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::io::internal { + +/// Returns the PCD `TYPE` character corresponding to a C++ value type: signed integer ('I'), +/// unsigned integer ('U'), or floating point ('F'). +template +constexpr char pcd_type_char() +{ + if constexpr (std::is_floating_point_v) { + return 'F'; + } else if constexpr (std::is_signed_v) { + return 'I'; + } else { + return 'U'; + } +} + +/// +/// Dispatches a (type, size) pair from a PCD header to the matching C++ value type and invokes +/// `func` with a value-initialized instance of that type. The concrete type is one of the types +/// supported by Lagrange attributes (see LA_ATTRIBUTE_X). +/// +/// @param[in] type PCD type character ('I', 'U', or 'F'). +/// @param[in] size Size in bytes of the field element (1, 2, 4, or 8). +/// @param[in] func Callable invoked as `func(ValueType{})`. +/// +template +void dispatch_pcd_type(char type, int size, Func&& func) +{ + switch (type) { + case 'F': + if (size == 4) return void(func(float{})); + if (size == 8) return void(func(double{})); + break; + case 'I': + if (size == 1) return void(func(int8_t{})); + if (size == 2) return void(func(int16_t{})); + if (size == 4) return void(func(int32_t{})); + if (size == 8) return void(func(int64_t{})); + break; + case 'U': + if (size == 1) return void(func(uint8_t{})); + if (size == 2) return void(func(uint16_t{})); + if (size == 4) return void(func(uint32_t{})); + if (size == 8) return void(func(uint64_t{})); + break; + default: break; + } + throw Error(lagrange::format("Unsupported PCD field type '{}' with size {}", type, size)); +} + +} // namespace lagrange::io::internal diff --git a/modules/io/src/load_mesh.cpp b/modules/io/src/load_mesh.cpp index bd38cf45..2e4837c5 100644 --- a/modules/io/src/load_mesh.cpp +++ b/modules/io/src/load_mesh.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -45,6 +46,7 @@ MeshType load_mesh(std::istream& input_stream, const LoadOptions& options) case FileFormat::Obj: return load_mesh_obj(input_stream, options); case FileFormat::Fbx: return load_mesh_fbx(input_stream, options); case FileFormat::Stl: return load_mesh_stl(input_stream, options); + case FileFormat::Pcd: return load_mesh_pcd(input_stream, options); default: #ifdef LAGRANGE_WITH_ASSIMP return load_mesh_assimp(input_stream, options); @@ -83,6 +85,8 @@ MeshType load_mesh(const fs::path& filename, const LoadOptions& options) return load_mesh_fbx(filename, options); } else if (ext == ".stl") { return load_mesh_stl(filename, options); + } else if (ext == ".pcd") { + return load_mesh_pcd(filename, options); } else { #ifdef LAGRANGE_WITH_ASSIMP return load_mesh_assimp(filename, options); diff --git a/modules/io/src/load_mesh_pcd.cpp b/modules/io/src/load_mesh_pcd.cpp new file mode 100644 index 00000000..2ea632fc --- /dev/null +++ b/modules/io/src/load_mesh_pcd.cpp @@ -0,0 +1,190 @@ +/* + * 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 "internal/pcd_utils.h" + +#include + +#include +#include +#include +#include +#include + +namespace lagrange::io { + +namespace { + +using internal::dispatch_pcd_type; + +// Read the value of `field` at point `i`, channel `c`, cast to `Out`. `PcdField::data` is stored in +// point-major order: point i, channel c occupies bytes [(i * count + c) * size, ...). +template +Out read_field_value(const pcdio::PcdField& field, size_t i, int c) +{ + const uint8_t* ptr = + field.data.data() + (i * static_cast(field.count) + static_cast(c)) * + static_cast(field.size); + Out out{}; + dispatch_pcd_type(field.type, field.size, [&](auto zero) { + using ValueType = decltype(zero); + ValueType value; + std::memcpy(&value, ptr, sizeof(ValueType)); + out = static_cast(value); + }); + return out; +} + +} // namespace + +template +MeshType load_mesh_pcd(std::istream& input_stream, const LoadOptions& options) +{ + using Scalar = typename MeshType::Scalar; + using Index = typename MeshType::Index; + + MeshType mesh; + + // The PCD container (ascii, binary, and LZF-compressed binary_compressed) is parsed by the + // pcdio library; this function only maps the resulting fields onto Lagrange mesh attributes. + const pcdio::PcdSpec spec = pcdio::load_pcd(input_stream); + const size_t points = spec.points; + + // Positions. + const pcdio::PcdField* fx = spec.find_field("x"); + const pcdio::PcdField* fy = spec.find_field("y"); + const pcdio::PcdField* fz = spec.find_field("z"); + la_runtime_assert(fx && fy && fz, "PCD file must contain x, y and z fields."); + mesh.add_vertices(static_cast(points), [&](Index v, span p) { + const size_t i = static_cast(v); + p[0] = read_field_value(*fx, i, 0); + p[1] = read_field_value(*fy, i, 0); + p[2] = read_field_value(*fz, i, 0); + }); + + // Normals. + if (options.load_normals) { + const pcdio::PcdField* nx = spec.find_field("normal_x"); + const pcdio::PcdField* ny = spec.find_field("normal_y"); + const pcdio::PcdField* nz = spec.find_field("normal_z"); + if (!nx) nx = spec.find_field("nx"); + if (!ny) ny = spec.find_field("ny"); + if (!nz) nz = spec.find_field("nz"); + if (nx && ny && nz) { + dispatch_pcd_type(nx->type, nx->size, [&](auto zero) { + using ValueType = decltype(zero); + auto id = mesh.template create_attribute( + AttributeName::normal, + AttributeElement::Vertex, + AttributeUsage::Normal, + 3); + auto attr = mesh.template ref_attribute(id).ref_all(); + for (size_t i = 0; i < points; ++i) { + attr[i * 3 + 0] = read_field_value(*nx, i, 0); + attr[i * 3 + 1] = read_field_value(*ny, i, 0); + attr[i * 3 + 2] = read_field_value(*nz, i, 0); + } + }); + } + } + + // Colors: packed rgb (float bits) or rgba (uint32), unpacked into uint8 channels. + if (options.load_vertex_colors) { + const pcdio::PcdField* rgba = spec.find_field("rgba"); + const pcdio::PcdField* rgb = spec.find_field("rgb"); + auto unpack = [&](const pcdio::PcdField& field, Index channels) { + auto id = mesh.template create_attribute( + AttributeName::color, + AttributeElement::Vertex, + AttributeUsage::Color, + channels); + auto attr = mesh.template ref_attribute(id).ref_all(); + const size_t n = static_cast(channels); + for (size_t i = 0; i < points; ++i) { + uint32_t packed; + std::memcpy(&packed, field.data.data() + i * field.stride(), 4); + attr[i * n + 0] = static_cast((packed >> 16) & 0xffu); + attr[i * n + 1] = static_cast((packed >> 8) & 0xffu); + attr[i * n + 2] = static_cast(packed & 0xffu); + if (channels == 4) attr[i * n + 3] = static_cast((packed >> 24) & 0xffu); + } + }; + if (rgba && rgba->size == 4) { + unpack(*rgba, 4); + } else if (rgb && rgb->size == 4) { + unpack(*rgb, 3); + } + } + + // Remaining fields become generic vertex attributes named after the field. + static const std::string_view reserved_fields[] = + {"x", "y", "z", "normal_x", "normal_y", "normal_z", "nx", "ny", "nz", "rgb", "rgba", "_"}; + auto is_reserved_field = [&](std::string_view name) { + for (auto reserved : reserved_fields) { + if (name == reserved) return true; + } + return false; + }; + + for (const auto& field : spec.fields) { + if (field.name.empty() || is_reserved_field(field.name)) continue; + dispatch_pcd_type(field.type, field.size, [&](auto zero) { + using ValueType = decltype(zero); + const Index num_channels = static_cast(field.count); + const AttributeUsage usage = + num_channels == 1 ? AttributeUsage::Scalar : AttributeUsage::Vector; + auto id = mesh.template create_attribute( + field.name, + AttributeElement::Vertex, + usage, + num_channels); + auto attr = mesh.template ref_attribute(id).ref_all(); + const size_t n = static_cast(num_channels); + for (size_t i = 0; i < points; ++i) { + for (int c = 0; c < field.count; ++c) { + attr[i * n + static_cast(c)] = read_field_value(field, i, c); + } + } + }); + } + + return mesh; +} + +template +MeshType load_mesh_pcd(const fs::path& filename, const LoadOptions& options) +{ + fs::ifstream fin(filename, std::ios::binary); + la_runtime_assert(fin.good(), format("Unable to open file {}", filename.string())); + return load_mesh_pcd(fin, options); +} + +#define LA_X_load_mesh_pcd(_, S, I) \ + template LA_IO_API SurfaceMesh load_mesh_pcd(std::istream&, const LoadOptions& options); \ + template LA_IO_API SurfaceMesh load_mesh_pcd( \ + const fs::path& filename, \ + const LoadOptions& options); +LA_SURFACE_MESH_X(load_mesh_pcd, 0) + +} // namespace lagrange::io diff --git a/modules/io/src/save_mesh.cpp b/modules/io/src/save_mesh.cpp index 0fcefa56..abbe297a 100644 --- a/modules/io/src/save_mesh.cpp +++ b/modules/io/src/save_mesh.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -39,6 +40,7 @@ void save_mesh( case FileFormat::Ply: save_mesh_ply(output_stream, mesh, options); break; case FileFormat::Msh: save_mesh_msh(output_stream, mesh, options); break; case FileFormat::Gltf: save_mesh_gltf(output_stream, mesh, options); break; + case FileFormat::Pcd: save_mesh_pcd(output_stream, mesh, options); break; default: la_runtime_assert(false, "Unrecognized file format!"); } } @@ -60,6 +62,8 @@ void save_mesh( save_mesh_msh(filename, mesh, options); } else if (ext == ".gltf" || ext == ".glb") { save_mesh_gltf(filename, mesh, options); + } else if (ext == ".pcd") { + save_mesh_pcd(filename, mesh, options); } else { la_runtime_assert(false, string_format("Unrecognized filetype: {}!", ext)); } diff --git a/modules/io/src/save_mesh_pcd.cpp b/modules/io/src/save_mesh_pcd.cpp new file mode 100644 index 00000000..6c431e94 --- /dev/null +++ b/modules/io/src/save_mesh_pcd.cpp @@ -0,0 +1,240 @@ +/* + * 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 "internal/pcd_utils.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace lagrange::io { + +namespace { + +using internal::pcd_type_char; + +template +uint8_t to_u8(ValueType v) +{ + if constexpr (std::is_floating_point_v) { + double d = std::round(static_cast(v) * 255.0); + return static_cast(std::clamp(d, 0.0, 255.0)); + } else { + long long x = static_cast(v); + return static_cast(std::clamp(x, 0, 255)); + } +} + +// Append the raw bytes of `value` to `bytes`. +template +void append_bytes(std::vector& bytes, ValueType value) +{ + const auto* p = reinterpret_cast(&value); + bytes.insert(bytes.end(), p, p + sizeof(ValueType)); +} + +} // namespace + +template +void save_mesh_pcd( + std::ostream& output_stream, + const SurfaceMesh& mesh, + const SaveOptions& options) +{ + la_runtime_assert(mesh.get_dimension() == 3, "PCD export requires a 3D mesh."); + + const size_t num_points = static_cast(mesh.get_num_vertices()); + + // Build a pcdio::PcdSpec and let the pcdio library serialize it (header + ascii/binary/ + // binary_compressed LZF encoding). This function only maps Lagrange attributes onto PCD fields. + pcdio::PcdSpec spec; + spec.width = num_points; + spec.height = 1; + spec.points = num_points; + // spec.viewpoint keeps its default {0, 0, 0, 1, 0, 0, 0}. + + // Vertex positions: x, y, z. + { + auto pos = vertex_view(mesh); + for (int k = 0; k < 3; ++k) { + pcdio::PcdField field; + field.name = (k == 0 ? "x" : (k == 1 ? "y" : "z")); + field.type = pcd_type_char(); + field.size = static_cast(sizeof(Scalar)); + field.count = 1; + field.data.reserve(num_points * sizeof(Scalar)); + for (size_t i = 0; i < num_points; ++i) { + append_bytes(field.data, static_cast(pos(static_cast(i), k))); + } + spec.fields.push_back(std::move(field)); + } + } + + bool wrote_normal = false; + bool wrote_color = false; + + auto register_attribute = [&](std::string_view name, auto&& attr) { + if (mesh.attr_name_is_reserved(name)) return; + using AttributeType = std::decay_t; + using ValueType = typename AttributeType::ValueType; + const size_t num_channels = static_cast(attr.get_num_channels()); + const AttributeUsage usage = attr.get_usage(); + + if (usage == AttributeUsage::Normal && num_channels >= 3 && !wrote_normal) { + wrote_normal = true; + const char* suffixes[3] = {"normal_x", "normal_y", "normal_z"}; + for (int k = 0; k < 3; ++k) { + pcdio::PcdField field; + field.name = suffixes[k]; + field.type = pcd_type_char(); + field.size = static_cast(sizeof(ValueType)); + field.count = 1; + field.data.reserve(num_points * sizeof(ValueType)); + for (size_t i = 0; i < num_points; ++i) { + append_bytes(field.data, attr.get(i, k)); + } + spec.fields.push_back(std::move(field)); + } + return; + } + + if (usage == AttributeUsage::Color && (num_channels == 3 || num_channels == 4) && + !wrote_color) { + wrote_color = true; + const bool has_alpha = (num_channels == 4); + pcdio::PcdField field; + field.name = has_alpha ? "rgba" : "rgb"; + field.type = has_alpha ? 'U' : 'F'; // PCL packs rgb as a float, rgba as a uint32. + field.size = 4; + field.count = 1; + field.data.reserve(num_points * 4); + for (size_t i = 0; i < num_points; ++i) { + uint32_t r = to_u8(attr.get(i, 0)); + uint32_t g = to_u8(attr.get(i, 1)); + uint32_t b = to_u8(attr.get(i, 2)); + uint32_t a = has_alpha ? to_u8(attr.get(i, 3)) : 0u; + uint32_t packed = (a << 24) | (r << 16) | (g << 8) | b; + append_bytes(field.data, packed); + } + spec.fields.push_back(std::move(field)); + return; + } + + // Skip attributes whose name collides with a PCD-owned field or cannot be represented as a + // whitespace-delimited FIELDS token; emitting them would produce an ambiguous or unreadable + // file. + static constexpr std::string_view reserved_names[] = { + "x", + "y", + "z", + "normal_x", + "normal_y", + "normal_z", + "nx", + "ny", + "nz", + "rgb", + "rgba", + "_"}; + bool reserved = name.empty() || name.find_first_of(" \t\r\n") != std::string_view::npos; + for (auto r : reserved_names) reserved = reserved || (name == r); + if (reserved) { + if (!options.quiet) { + logger().warn( + "Skipping vertex attribute '{}' when saving PCD: its name is empty, contains " + "whitespace, or collides with a reserved PCD field.", + name); + } + return; + } + + // Generic attribute: one field carrying all channels. + pcdio::PcdField field; + field.name = std::string(name); + field.type = pcd_type_char(); + field.size = static_cast(sizeof(ValueType)); + field.count = static_cast(num_channels); + field.data.reserve(num_points * num_channels * sizeof(ValueType)); + for (size_t i = 0; i < num_points; ++i) { + for (size_t c = 0; c < num_channels; ++c) { + append_bytes(field.data, attr.get(i, c)); + } + } + spec.fields.push_back(std::move(field)); + }; + + if (options.output_attributes == SaveOptions::OutputAttributes::All) { + seq_foreach_named_attribute_read(mesh, register_attribute); + } else if (!options.selected_attributes.empty()) { + details::internal_foreach_named_attribute< + AttributeElement::Vertex, + details::Ordering::Sequential, + details::Access::Read>(mesh, register_attribute, options.selected_attributes); + } + + // Binary output is written as LZF-compressed `binary_compressed`, matching PCL's default and + // keeping files compact. Uncompressed `binary` is still supported on read. + spec.data = (options.encoding == FileEncoding::Ascii) ? "ascii" : "binary_compressed"; + + pcdio::save_pcd(output_stream, spec); +} + +template +void save_mesh_pcd( + const fs::path& filename, + const SurfaceMesh& mesh, + const SaveOptions& options) +{ + fs::path parent_dir = filename.parent_path(); + if (!parent_dir.empty() && !fs::exists(parent_dir)) fs::create_directories(parent_dir); + + fs::ofstream fout( + filename, + (options.encoding == FileEncoding::Ascii ? std::ios::out : std::ios::binary)); + if (!fout) { + throw std::runtime_error( + format("Failed to open PCD file for writing: {}", filename.string())); + } + save_mesh_pcd(fout, mesh, options); +} + +#define LA_X_save_mesh_pcd(_, Scalar, Index) \ + template LA_IO_API void save_mesh_pcd( \ + std::ostream&, \ + const SurfaceMesh& mesh, \ + const SaveOptions& options); \ + template LA_IO_API void save_mesh_pcd( \ + const fs::path& filename, \ + const SurfaceMesh& mesh, \ + const SaveOptions& options); +LA_SURFACE_MESH_X(save_mesh_pcd, 0) + +} // namespace lagrange::io diff --git a/modules/io/src/stitch_mesh.h b/modules/io/src/stitch_mesh.h index 6c9dc22e..a34d7cd6 100644 --- a/modules/io/src/stitch_mesh.h +++ b/modules/io/src/stitch_mesh.h @@ -22,6 +22,9 @@ namespace lagrange::io { template void stitch_mesh(SurfaceMesh& mesh) { + // Point clouds have no corners to stitch. Keep their vertex attributes vertex-based. + if (mesh.get_num_corners() == 0) return; + // Convert vertex attributes to indexed before stitching anything for (auto& id : find_matching_attributes(mesh, AttributeElement::Vertex)) { id = map_attribute_in_place(mesh, id, AttributeElement::Indexed); diff --git a/modules/io/tests/test_load_gltf.cpp b/modules/io/tests/test_load_gltf.cpp index ebe623b5..2a40ce9e 100644 --- a/modules/io/tests/test_load_gltf.cpp +++ b/modules/io/tests/test_load_gltf.cpp @@ -149,14 +149,28 @@ TEST_CASE("load_gltf_point_cloud", "[io][gltf]") lagrange::SurfaceMesh mesh; mesh.add_vertex({0, 0, 0}); + Scalar normal_data[] = {0, 0, 1}; + mesh.create_attribute( + AttributeName::normal, + AttributeElement::Vertex, + AttributeUsage::Normal, + 3, + {normal_data, 3}); std::stringstream ss; auto scene = lagrange::scene::mesh_to_simple_scene(mesh); REQUIRE(scene.get_num_meshes() == 1); lagrange::io::save_simple_scene_gltf(ss, scene); - auto scene2 = - lagrange::io::load_simple_scene_gltf>(ss); + + lagrange::io::LoadOptions options; + options.stitch_vertices = true; + auto scene2 = lagrange::io::load_simple_scene_gltf>( + ss, + options); REQUIRE(scene2.get_num_meshes() == 1); + REQUIRE(scene2.get_mesh(0).get_num_vertices() == 1); + REQUIRE(scene2.get_mesh(0).get_num_facets() == 0); + REQUIRE(scene2.get_mesh(0).has_attribute(AttributeName::normal)); } TEST_CASE("load_gltf_non_triangle_simple", "[io][gltf]" LA_CORP_FLAG) diff --git a/modules/io/tests/test_msh.cpp b/modules/io/tests/test_msh.cpp index f244b8b7..800dec41 100644 --- a/modules/io/tests/test_msh.cpp +++ b/modules/io/tests/test_msh.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,36 @@ #include #include +TEST_CASE("load_msh_point_cloud", "[mesh][io][msh]") +{ +#if !LAGRANGE_TARGET_OS(WASM) + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + Scalar normal_data[] = {0, 0, 1}; + mesh.create_attribute( + "normal", + AttributeElement::Vertex, + AttributeUsage::Normal, + 3, + {normal_data, 3}); + + std::stringstream data; + io::SaveOptions save_options; + save_options.encoding = io::FileEncoding::Ascii; + REQUIRE_NOTHROW(io::save_mesh_msh(data, mesh, save_options)); + + io::LoadOptions load_options; + load_options.stitch_vertices = true; + auto mesh2 = io::load_mesh_msh>(data, load_options); + REQUIRE(mesh2.get_num_vertices() == 1); + REQUIRE(mesh2.get_num_facets() == 0); + REQUIRE(find_matching_attribute(mesh2, AttributeUsage::Normal).has_value()); +#endif +} TEST_CASE("io/msh", "[mesh][io][msh]") { diff --git a/modules/io/tests/test_obj.cpp b/modules/io/tests/test_obj.cpp index 603726c5..0f2b0115 100644 --- a/modules/io/tests/test_obj.cpp +++ b/modules/io/tests/test_obj.cpp @@ -72,6 +72,33 @@ TEST_CASE("io/obj empty", "[io][obj]") testing::ensure_approx_equivalent_mesh(mesh, mesh2); } +TEST_CASE("load_obj_point_cloud", "[io][obj]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + Scalar normal_data[] = {0, 0, 1}; + mesh.create_attribute( + AttributeName::normal, + AttributeElement::Vertex, + AttributeUsage::Normal, + 3, + {normal_data, 3}); + + std::stringstream data; + REQUIRE_NOTHROW(io::save_mesh_obj(data, mesh)); + + io::LoadOptions options; + options.stitch_vertices = true; + auto mesh2 = io::load_mesh_obj>(data, options); + REQUIRE(mesh2.get_num_vertices() == 1); + REQUIRE(mesh2.get_num_facets() == 0); + REQUIRE(mesh2.has_attribute(AttributeName::normal)); +} + TEST_CASE("io/obj simple_scene", "[io][obj]") { using namespace lagrange; diff --git a/modules/io/tests/test_pcd.cpp b/modules/io/tests/test_pcd.cpp new file mode 100644 index 00000000..f6acd9e6 --- /dev/null +++ b/modules/io/tests/test_pcd.cpp @@ -0,0 +1,371 @@ +/* + * 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 + +namespace { + +template +lagrange::SurfaceMesh make_point_cloud() +{ + using namespace lagrange; + SurfaceMesh mesh; + const Index n = 6; + mesh.add_vertices(n, [&](Index v, span p) { + p[0] = static_cast(v) * static_cast(1.5); + p[1] = static_cast(v) * static_cast(-2.25); + p[2] = static_cast(v) + static_cast(0.5); + }); + + std::vector normals(static_cast(n) * 3); + for (Index v = 0; v < n; ++v) { + normals[v * 3 + 0] = 1.0f; + normals[v * 3 + 1] = 0.0f; + normals[v * 3 + 2] = static_cast(v) * 0.1f; + } + mesh.template create_attribute( + AttributeName::normal, + AttributeElement::Vertex, + AttributeUsage::Normal, + 3, + {normals.data(), normals.size()}); + + std::vector colors(static_cast(n) * 4); + for (Index v = 0; v < n; ++v) { + colors[v * 4 + 0] = static_cast(10 * v); + colors[v * 4 + 1] = static_cast(20 * v); + colors[v * 4 + 2] = static_cast(30 * v); + colors[v * 4 + 3] = 255; + } + mesh.template create_attribute( + AttributeName::color, + AttributeElement::Vertex, + AttributeUsage::Color, + 4, + {colors.data(), colors.size()}); + + std::vector intensity(static_cast(n)); + for (Index v = 0; v < n; ++v) intensity[v] = static_cast(v) * 0.5f; + mesh.template create_attribute( + "intensity", + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1, + {intensity.data(), intensity.size()}); + + return mesh; +} + +template +void check_roundtrip(lagrange::io::FileEncoding encoding) +{ + using namespace lagrange; + auto mesh = make_point_cloud(); + + io::SaveOptions save_options; + save_options.encoding = encoding; + + std::stringstream data; + REQUIRE_NOTHROW(io::save_mesh_pcd(data, mesh, save_options)); + + auto mesh2 = io::load_mesh_pcd>(data); + + REQUIRE(mesh2.get_num_vertices() == mesh.get_num_vertices()); + REQUIRE(mesh2.get_num_facets() == 0); + + // Positions. + auto pos = vertex_view(mesh); + auto pos2 = vertex_view(mesh2); + for (Index v = 0; v < mesh.get_num_vertices(); ++v) { + for (int k = 0; k < 3; ++k) { + REQUIRE(pos2(v, k) == Catch::Approx(pos(v, k))); + } + } + + // Normals. + REQUIRE(mesh2.has_attribute(AttributeName::normal)); + const auto& nrm = mesh.template get_attribute(AttributeName::normal); + const auto& nrm2 = mesh2.template get_attribute(AttributeName::normal); + REQUIRE(nrm2.get_num_channels() == 3); + for (Index v = 0; v < mesh.get_num_vertices(); ++v) { + for (Index c = 0; c < 3; ++c) { + REQUIRE(nrm2.get(v, c) == Catch::Approx(nrm.get(v, c))); + } + } + + // Colors (packed rgba -> uint8 4 channels, exact). + REQUIRE(mesh2.has_attribute(AttributeName::color)); + const auto& col = mesh.template get_attribute(AttributeName::color); + const auto& col2 = mesh2.template get_attribute(AttributeName::color); + REQUIRE(col2.get_num_channels() == 4); + for (Index v = 0; v < mesh.get_num_vertices(); ++v) { + for (Index c = 0; c < 4; ++c) { + REQUIRE(col2.get(v, c) == col.get(v, c)); + } + } + + // Scalar attribute. + REQUIRE(mesh2.has_attribute("intensity")); + const auto& intensity = mesh.template get_attribute("intensity"); + const auto& intensity2 = mesh2.template get_attribute("intensity"); + REQUIRE(intensity2.get_num_channels() == 1); + for (Index v = 0; v < mesh.get_num_vertices(); ++v) { + REQUIRE(intensity2.get(v, 0) == Catch::Approx(intensity.get(v, 0))); + } +} + +} // namespace + +TEST_CASE("io/pcd detection after comments", "[io][pcd]") +{ + std::istringstream stream( + "# generated by tool\r\n \t\r\n\t# metadata\nVERSION 0.7\nFIELDS x y z\n"); + REQUIRE(lagrange::io::internal::detect_file_format(stream) == lagrange::io::FileFormat::Pcd); + REQUIRE(stream.peek() == '#'); + + std::istringstream obj("# mesh\nv 0 0 0\n"); + REQUIRE(lagrange::io::internal::detect_file_format(obj) == lagrange::io::FileFormat::Obj); +} + +TEST_CASE("io/pcd roundtrip", "[io][pcd]") +{ + // Binary encoding is written as LZF-compressed `binary_compressed`. + SECTION("Ascii double") + { + check_roundtrip(lagrange::io::FileEncoding::Ascii); + } + SECTION("Binary double") + { + check_roundtrip(lagrange::io::FileEncoding::Binary); + } + SECTION("Ascii float") + { + check_roundtrip(lagrange::io::FileEncoding::Ascii); + } + SECTION("Binary float") + { + check_roundtrip(lagrange::io::FileEncoding::Binary); + } +} + +TEST_CASE("io/pcd ascii fixture", "[io][pcd]") +{ + using namespace lagrange; + // Minimal PCD ascii payload with x y z and an intensity field. + const std::string payload = "# .PCD v0.7 - Point Cloud Data file format\n" + "VERSION 0.7\n" + "FIELDS x y z intensity\n" + "SIZE 4 4 4 4\n" + "TYPE F F F F\n" + "COUNT 1 1 1 1\n" + "WIDTH 3\n" + "HEIGHT 1\n" + "VIEWPOINT 0 0 0 1 0 0 0\n" + "POINTS 3\n" + "DATA ascii\n" + "0 0 0 1\n" + "1 2 3 2\n" + "-1 -2 -3 3\n"; + + std::stringstream ss(payload); + auto mesh = io::load_mesh_pcd(ss); + REQUIRE(mesh.get_num_vertices() == 3); + REQUIRE(mesh.get_num_facets() == 0); + REQUIRE(mesh.has_attribute("intensity")); + + auto pos = vertex_view(mesh); + REQUIRE(pos(1, 0) == Catch::Approx(1.0f)); + REQUIRE(pos(1, 1) == Catch::Approx(2.0f)); + REQUIRE(pos(2, 2) == Catch::Approx(-3.0f)); + + const auto& intensity = mesh.get_attribute("intensity"); + REQUIRE(intensity.get(2, 0) == Catch::Approx(3.0f)); +} + +TEST_CASE("io/pcd uncompressed binary fixture", "[io][pcd]") +{ + using namespace lagrange; + // We always write `binary_compressed`, so build an uncompressed `binary` payload by hand to + // exercise the uncompressed-binary read path (e.g. files produced by other tools). + std::string payload = "# .PCD v0.7 - Point Cloud Data file format\n" + "VERSION 0.7\n" + "FIELDS x y z\n" + "SIZE 4 4 4\n" + "TYPE F F F\n" + "COUNT 1 1 1\n" + "WIDTH 2\n" + "HEIGHT 1\n" + "VIEWPOINT 0 0 0 1 0 0 0\n" + "POINTS 2\n" + "DATA binary\n"; + const float data[6] = {0.f, 1.f, 2.f, 10.f, 11.f, 12.f}; + payload.append(reinterpret_cast(data), sizeof(data)); + + std::stringstream ss(payload); + auto mesh = io::load_mesh_pcd(ss); + REQUIRE(mesh.get_num_vertices() == 2); + REQUIRE(mesh.get_num_facets() == 0); + + auto pos = vertex_view(mesh); + REQUIRE(pos(0, 0) == Catch::Approx(0.f)); + REQUIRE(pos(0, 2) == Catch::Approx(2.f)); + REQUIRE(pos(1, 0) == Catch::Approx(10.f)); + REQUIRE(pos(1, 2) == Catch::Approx(12.f)); +} + +TEST_CASE("io/pcd uint64 attribute round-trip", "[io][pcd]") +{ + using namespace lagrange; + SurfaceMesh mesh; + mesh.add_vertices(2, [](uint32_t v, span p) { + p[0] = v; + p[1] = v; + p[2] = v; + }); + // Value above INT64_MAX must survive the ascii round-trip. + const uint64_t big = static_cast(std::numeric_limits::max()) + 100u; + std::vector ids = {big, big - 1u}; + mesh.create_attribute( + "id", + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1, + {ids.data(), ids.size()}); + + io::SaveOptions opts; + opts.encoding = io::FileEncoding::Ascii; + std::stringstream data; + io::save_mesh_pcd(data, mesh, opts); + + auto mesh2 = io::load_mesh_pcd>(data); + REQUIRE(mesh2.has_attribute("id")); + const auto& id2 = mesh2.get_attribute("id"); + REQUIRE(id2.get(0, 0) == big); + REQUIRE(id2.get(1, 0) == big - 1u); +} + +TEST_CASE("io/pcd reserved attribute name is skipped", "[io][pcd]") +{ + using namespace lagrange; + SurfaceMesh mesh; + mesh.add_vertices(2, [](uint32_t v, span p) { + p[0] = v; + p[1] = v + 1; + p[2] = v + 2; + }); + // An attribute literally named "x" collides with the position field and must not corrupt + // output. + std::vector bogus = {7.0, 8.0}; + mesh.create_attribute( + "x", + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1, + {bogus.data(), bogus.size()}); + + io::SaveOptions opts; + opts.encoding = io::FileEncoding::Ascii; + opts.quiet = true; + std::stringstream data; + io::save_mesh_pcd(data, mesh, opts); + + auto mesh2 = io::load_mesh_pcd>(data); + REQUIRE(mesh2.get_num_vertices() == 2); + auto pos = vertex_view(mesh2); + REQUIRE(pos(1, 0) == Catch::Approx(1.0)); // position, not the bogus "x" value + REQUIRE(pos(1, 1) == Catch::Approx(2.0)); +} + +TEST_CASE("io/pcd rejects malformed header", "[io][pcd]") +{ + using namespace lagrange; + const std::string payload = "VERSION 0.7\n" + "FIELDS x y z\n" + "SIZE 4 4 -4\n" // negative size must be rejected + "TYPE F F F\n" + "COUNT 1 1 1\n" + "WIDTH 1\n" + "HEIGHT 1\n" + "POINTS 1\n" + "DATA ascii\n" + "0 0 0\n"; + std::stringstream ss(payload); + LA_REQUIRE_THROWS(io::load_mesh_pcd(ss)); +} + +TEST_CASE("io/pcd binary_compressed liblzf interop", "[io][pcd]") +{ + using namespace lagrange; + // This payload's compressed body was produced by the *reference* liblzf 3.6 `lzf_compress` + // (the codec PCL uses for `binary_compressed` PCD). Loading it through load_mesh_pcd (which + // delegates decompression to the pcdio dependency) verifies real liblzf/PCL interoperability + // of the whole lagrange -> pcdio read path against externally produced bytes. + // + // The 16-point cloud has fields x y z intensity (all float32). Column-major (SoA) source: + // x = 0,1,...,15 y = 0 z = 2 intensity = 100 + // The repeated y/z/intensity columns force liblzf to emit back-references, so the payload + // exercises the decoder's copy path in addition to literal runs. + static const unsigned char kBlob[] = { + 0x01, 0x00, 0x00, 0x40, 0x00, 0x01, 0x80, 0x3f, 0x20, 0x05, 0x00, 0x40, 0x20, 0x02, + 0x20, 0x03, 0x00, 0x80, 0x20, 0x03, 0x00, 0xa0, 0x20, 0x03, 0x00, 0xc0, 0x20, 0x03, + 0x00, 0xe0, 0x20, 0x03, 0x04, 0x00, 0x41, 0x00, 0x00, 0x10, 0x20, 0x03, 0x00, 0x20, + 0x20, 0x03, 0x00, 0x30, 0x20, 0x03, 0x00, 0x40, 0x20, 0x03, 0x00, 0x50, 0x20, 0x03, + 0x00, 0x60, 0x20, 0x03, 0x00, 0x70, 0x20, 0x03, 0xe0, 0x38, 0x00, 0x40, 0x63, 0xe0, + 0x32, 0x03, 0x01, 0xc8, 0x42, 0xe0, 0x31, 0x03, 0x01, 0xc8, 0x42, + }; + const uint32_t compressed_size = static_cast(sizeof(kBlob)); // 81 + const uint32_t uncompressed_size = 16u * 4u * sizeof(float); // 256 + + std::string payload = "# .PCD v0.7 - Point Cloud Data file format\n" + "VERSION 0.7\n" + "FIELDS x y z intensity\n" + "SIZE 4 4 4 4\n" + "TYPE F F F F\n" + "COUNT 1 1 1 1\n" + "WIDTH 16\n" + "HEIGHT 1\n" + "VIEWPOINT 0 0 0 1 0 0 0\n" + "POINTS 16\n" + "DATA binary_compressed\n"; + payload.append(reinterpret_cast(&compressed_size), sizeof(compressed_size)); + payload.append(reinterpret_cast(&uncompressed_size), sizeof(uncompressed_size)); + payload.append(reinterpret_cast(kBlob), sizeof(kBlob)); + + std::stringstream ss(payload); + auto mesh = io::load_mesh_pcd(ss); + REQUIRE(mesh.get_num_vertices() == 16); + REQUIRE(mesh.get_num_facets() == 0); + REQUIRE(mesh.has_attribute("intensity")); + + auto pos = vertex_view(mesh); + const auto& intensity = mesh.get_attribute("intensity"); + for (int v = 0; v < 16; ++v) { + REQUIRE(pos(v, 0) == Catch::Approx(static_cast(v))); + REQUIRE(pos(v, 1) == Catch::Approx(0.0f)); + REQUIRE(pos(v, 2) == Catch::Approx(2.0f)); + REQUIRE(intensity.get(v, 0) == Catch::Approx(100.0f)); + } +} diff --git a/modules/io/tests/test_ply.cpp b/modules/io/tests/test_ply.cpp index 33050642..e733e888 100644 --- a/modules/io/tests/test_ply.cpp +++ b/modules/io/tests/test_ply.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 @@ -91,6 +92,33 @@ TEST_CASE("io/ply empty", "[io][ply]") } } +TEST_CASE("load_ply_point_cloud", "[io][ply]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + Scalar normal_data[] = {0, 0, 1}; + mesh.create_attribute( + "normal", + AttributeElement::Vertex, + AttributeUsage::Normal, + 3, + {normal_data, 3}); + + std::stringstream data; + REQUIRE_NOTHROW(io::save_mesh_ply(data, mesh)); + + io::LoadOptions options; + options.stitch_vertices = true; + auto mesh2 = io::load_mesh_ply>(data, options); + REQUIRE(mesh2.get_num_vertices() == 1); + REQUIRE(mesh2.get_num_facets() == 0); + REQUIRE(find_matching_attribute(mesh2, AttributeUsage::Normal).has_value()); +} + TEST_CASE("io/ply multiple special attributes", "[io][ply]") { using namespace lagrange; diff --git a/modules/python/CMakeLists.txt b/modules/python/CMakeLists.txt index 6861c4d9..3c495036 100644 --- a/modules/python/CMakeLists.txt +++ b/modules/python/CMakeLists.txt @@ -183,6 +183,7 @@ function(lagrange_generate_init_file) import sys from .lagrange.core import * +from .lagrange.core import _ndebug from ._logging import logger from ._version import * @@ -219,6 +220,7 @@ function(lagrange_generate_python_binding_module) # Explicit re-export (PEP 484) so type checkers treat `logger` as public. file(APPEND ${init_pyi_file} "from ._logging import logger as logger\n") file(APPEND ${init_pyi_file} "from .core import *\n") + file(APPEND ${init_pyi_file} "_ndebug: bool\n") file(APPEND ${init_pyi_file} "\n# Package variant identifier\nvariant: str\n") # Generate stubs for python binding within the install location. diff --git a/modules/raycasting/examples/remove_occluded.cpp b/modules/raycasting/examples/remove_occluded.cpp index 3dbb533d..1c59c250 100644 --- a/modules/raycasting/examples/remove_occluded.cpp +++ b/modules/raycasting/examples/remove_occluded.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -33,26 +34,29 @@ #include #include #include +#include #include // clang-format on using Scalar = float; using Index = uint32_t; using SceneType = lagrange::scene::SimpleScene; -using InstanceSampler = lagrange::raycasting::OccludedInstanceSampler; -using FacetSampler = lagrange::raycasting::OccludedFacetSampler; +using InstanceSampler = lagrange::raycasting::internal::OccludedInstanceSampler; +using FacetSampler = lagrange::raycasting::internal::OccludedFacetSampler; enum class Mode { Meshes, Facets, FacetsBruteforce }; struct Args { - static constexpr auto default_options = lagrange::raycasting::RemoveOccludedFacetsOptions{}; - std::string input; std::string output; uint64_t num_rays = 0LLU; - uint64_t batch_size = 5000000; - uint64_t num_adaptive_per_normal = default_options.estimate_options.num_adaptive_per_normal; + uint64_t batch_size = lagrange::raycasting::OccludedFacetEstimateOptions{}.batch_size; + size_t num_adaptive_per_cosine = + lagrange::raycasting::AdaptiveOptions{}.num_adaptive_per_cosine; + double vmf_kappa = lagrange::raycasting::AdaptiveOptions{}.vmf_kappa; + double max_threshold = lagrange::raycasting::OccludedFacetSamplerOptions{}.threshold; + double size_influence = lagrange::raycasting::OccludedInstanceSamplerOptions{}.size_influence; int log_level = 2; bool vis = false; bool until_converged = false; @@ -66,61 +70,113 @@ extern "C" void signal_handler(int) g_cancel.store(true); } -/// Green -> yellow -> red colormap for t in [0, 1]. -std::array green_red_colormap(float t) +template +[[nodiscard]] constexpr std::array green_red_colormap(T t) { - float r = (t < 0.5f) ? (2.f * t) : 1.f; - float g = (t < 0.5f) ? 1.f : (2.f * (1.f - t)); - return {r, g, 0.1f}; + return {std::min(2 * t, T(1)), std::min((2 * (1 - t)), T(1)), T(0.1)}; } // --------------------------------------------------------------------------- // Meshes (instance-level) mode // --------------------------------------------------------------------------- -void visualize_instances(const SceneType& scene, const InstanceSampler& sampler) +/// A registered instance plus its frozen visibility measure. +struct RegisteredInstance +{ + ::polyscope::SurfaceMesh* ps; + double visibility_measure; + /// whether the instance is kept even at the strictest slider threshold (max_threshold == threshold) + bool never_occluded; +}; + +/// Recolor occluded instances at given threshold. The never-occluded group is left alone. +void update_instances( + const std::vector& meshes, + double threshold, + double max_measure) +{ + for (const auto& rm : meshes) { + if (rm.never_occluded) continue; + if (rm.visibility_measure >= threshold) { + const double denom = max_measure - threshold; + const double u = + (denom > 0.0) + ? std::clamp(1.0 - (rm.visibility_measure - threshold) / denom, 0.0, 1.0) + : 0.0; + const auto c = green_red_colormap(static_cast(u)); + rm.ps->setSurfaceColor({c[0], c[1], c[2]}); + rm.ps->setTransparency(1.f); + } else { + rm.ps->setSurfaceColor({0.5, 0.5, 0.5}); + rm.ps->setTransparency(0.5f); + } + } +} + +/// Register every instance and launch polyscope with a live slider for the threshold, set to max_threshold. User threshold value is returned. +double visualize_instances( + const SceneType& scene, + const InstanceSampler& sampler, + const double max_threshold) { polyscope::init(); - auto* visible_group = polyscope::createGroup("Visible"); - auto* occluded_group = polyscope::createGroup("Occluded"); + double threshold = max_threshold; + auto* visible_group = polyscope::createGroup("Visible (all thresholds)"); + auto* occluded_group = polyscope::createGroup("Occluded (some thresholds)"); - uint64_t min_rays = std::numeric_limits::max(); - uint64_t max_rays = 0; - for (auto i : lagrange::range(sampler.num_instances())) { - if (sampler.is_visible(i)) { - min_rays = std::min(min_rays, sampler.num_rays_cast(i)); - max_rays = std::max(max_rays, sampler.num_rays_cast(i)); + double max_measure = std::numeric_limits::epsilon(); + for (auto mi : lagrange::range(scene.get_num_meshes())) { + for (auto ii : lagrange::range(scene.get_num_instances(mi))) { + max_measure = std::max(max_measure, sampler.visibility_measure(mi, ii)); } } - Index global = 0; + std::vector meshes; for (auto mi : lagrange::range(scene.get_num_meshes())) { const auto& mesh = scene.get_mesh(mi); for (auto ii : lagrange::range(scene.get_num_instances(mi))) { const auto& instance = scene.get_instance(mi, ii); - std::string name = "mesh_" + std::to_string(mi) + "_" + std::to_string(ii); + const std::string name = "mesh_" + std::to_string(mi) + "_" + std::to_string(ii); auto* ps_mesh = lagrange::polyscope::register_mesh(name, mesh); lagrange::polyscope::set_transform(*ps_mesh, instance.transform); ps_mesh->setBackFacePolicy(::polyscope::BackFacePolicy::Identical); - if (!sampler.is_visible(global)) { - ps_mesh->setSurfaceColor({0.5, 0.5, 0.5}); - ps_mesh->setTransparency(0.5); - ps_mesh->addToGroup(*occluded_group); - } else { - float log_val = - std::log1p(static_cast(sampler.num_rays_cast(global) - min_rays)); - float log_max = std::log1p(static_cast(max_rays - min_rays)); - float t = (log_max > 0.f) ? (log_val / log_max) : 0.f; - auto color = green_red_colormap(t); - ps_mesh->setSurfaceColor({color[0], color[1], color[2]}); + + RegisteredInstance rm{ps_mesh, sampler.visibility_measure(mi, ii), false}; + rm.never_occluded = rm.visibility_measure >= max_threshold; + if (rm.never_occluded) { + const double u = std::clamp(1.0 - rm.visibility_measure / max_measure, 0.0, 1.0); + const auto c = green_red_colormap(static_cast(u)); + ps_mesh->setSurfaceColor({c[0], c[1], c[2]}); ps_mesh->addToGroup(*visible_group); + } else { + ps_mesh->addToGroup(*occluded_group); } - ++global; + meshes.push_back(rm); } } + update_instances(meshes, threshold, max_measure); + + // Drive the slider on sqrt(threshold) so it feels perceptually uniform. + const auto max_threshold_root = static_cast(std::sqrt(max_threshold)); + auto threshold_slider = max_threshold_root; + ::polyscope::state::userCallback = [&, max_threshold_root, max_measure]() { + if (ImGui::SliderFloat( + "threshold (sqrt scale)", + &threshold_slider, + 0.0f, + max_threshold_root, + "%.4f")) { + threshold = + static_cast(threshold_slider) * static_cast(threshold_slider); + update_instances(meshes, threshold, max_measure); + } + ImGui::Text("instance keep threshold = %.6f", threshold); + }; polyscope::show(); + ::polyscope::state::userCallback = nullptr; + return threshold; } int run_meshes_mode(const Args& args) @@ -128,44 +184,55 @@ int run_meshes_mode(const Args& args) lagrange::logger().info("Loading input scene: {}", args.input); auto scene = lagrange::io::load_simple_scene(args.input); - InstanceSampler sampler(scene); + lagrange::raycasting::OccludedInstanceSamplerOptions sampler_opts; + sampler_opts.threshold = args.max_threshold; + sampler_opts.size_influence = args.size_influence; + InstanceSampler sampler(scene, sampler_opts); lagrange::raycasting::OccludedInstanceEstimateOptions opts; opts.num_rays = args.num_rays; opts.batch_size = args.batch_size; opts.until_converged = args.until_converged; - lagrange::ProgressCallback progress; std::signal(SIGINT, signal_handler); if (args.num_rays == 0) lagrange::logger().info("Progressive mode (Ctrl+C to stop)"); - lagrange::raycasting::estimate_occluded_instances(sampler, opts, progress, &g_cancel); + // Drive the sampler to completion; the shared loop lives in the internal library driver. + uint64_t prev_total_rays = 0; + Index prev_retired = 0; + bool has_previous_batch = false; + while (true) { + sampler.run_batch(opts.batch_size); + const uint64_t total_rays = sampler.progress().second; + const Index num_retired = sampler.num_retired(); + if (num_retired == sampler.num_instances()) break; + if (total_rays == prev_total_rays) break; + if (opts.until_converged && has_previous_batch && num_retired == prev_retired) break; + prev_retired = num_retired; + prev_total_rays = total_rays; + has_previous_batch = true; + if (g_cancel.load()) break; + if (opts.num_rays > 0 && total_rays >= opts.num_rays) break; + } std::signal(SIGINT, SIG_DFL); - Index num_visible = 0; - for (auto i : lagrange::range(sampler.num_instances())) { - if (sampler.is_visible(i)) ++num_visible; - } - const Index num_occluded = sampler.num_instances() - num_visible; + const Index num_visible = sampler.progress().first; lagrange::logger().info( "Found {} occluded instances out of {}", - num_occluded, + sampler.num_instances() - num_visible, sampler.num_instances()); + // Threshold applied to the saved result — tunable live in the viewer, defaults to + // max_threshold. + double threshold = args.max_threshold; if (args.vis) { - visualize_instances(scene, sampler); + threshold = visualize_instances(scene, sampler, args.max_threshold); } + lagrange::logger().info("Using threshold = {} for output", threshold); const std::string output = args.output.empty() ? "output.obj" : args.output; lagrange::logger().info("Removing occluded instances and saving: {}", output); auto result = lagrange::scene::filter_instances(scene, [&](Index mi, Index ii) { - const auto r = lagrange::range(mi); - const Index global = std::accumulate( - r.begin(), - r.end(), - Index{0}, - [&](Index s, Index m) { return s + scene.get_num_instances(m); }) + - ii; - return sampler.is_visible(global); + return sampler.visibility_measure(mi, ii) >= threshold; }); lagrange::io::save_simple_scene(output, result); @@ -173,95 +240,208 @@ int run_meshes_mode(const Args& args) } // --------------------------------------------------------------------------- -// Facets (facet-level) mode — either normal+adaptive cycles or brute-force +// Facets (facet-level) mode — either adaptive or plain-cosine sampling // --------------------------------------------------------------------------- -void visualize_facets(const SceneType& scene, const FacetSampler& sampler) +/// Example of driving the public sampler progressively, with reporting and cancellation between +/// batches. Applications can instead inspect or visualize the sampler after every run_batch(). +void run_facet_sampler( + FacetSampler& sampler, + const lagrange::raycasting::OccludedFacetEstimateOptions& options, + lagrange::ProgressCallback& progress, + const std::atomic_bool* cancel) +{ + la_runtime_assert(options.batch_size > 0); + la_runtime_assert(options.num_rays > 0 || cancel != nullptr); + + const uint64_t total_facets = sampler.num_facets(); + lagrange::logger().info("Searching for occluded facets"); + progress.set_section("Searching for occluded facets"); + + uint64_t prev_total_rays = 0; + while (true) { + sampler.run_batch(options.batch_size); + + uint64_t num_visible = 0; + uint64_t total_rays = 0; + for (uint64_t f = 0; f < total_facets; ++f) { + if (sampler.is_visible(f)) ++num_visible; + total_rays += sampler.num_rays_cast(f); + } + const uint64_t num_retired = sampler.num_retired(); + lagrange::logger().info( + "{}/{} facets visible, {} confidently retired ({} rays so far)", + num_visible, + total_facets, + num_retired, + total_rays); + const float fraction = + options.num_rays > 0 + ? std::min( + 1.f, + static_cast(total_rays) / static_cast(options.num_rays)) + : (total_facets > 0 + ? static_cast(num_retired) / static_cast(total_facets) + : 1.f); + progress.update(fraction); + + if (num_retired == total_facets) { + lagrange::logger().info("All facets confidently visible, stopping early"); + break; + } + if (total_rays == prev_total_rays) { + lagrange::logger().info("No facet rays could be cast, stopping"); + break; + } + prev_total_rays = total_rays; + + if (cancel != nullptr && cancel->load()) { + lagrange::logger().info("Cancelled, using results so far"); + break; + } + if (options.num_rays > 0 && total_rays >= options.num_rays) break; + } +} + +/// A registered polyscope mesh + its facet range in the sampler's flat arrays. +struct RegisteredFacetMesh +{ + ::polyscope::SurfaceMesh* ps; + uint64_t facet_offset; + Index num_facets; +}; + +/// Recolor faces at threshold: gray below, heat-colored above (green = far above, red = near). +void update_facets( + const FacetSampler& sampler, + const std::vector& meshes, + double threshold, + double max_measure) +{ + for (const auto& rm : meshes) { + std::vector> colors(rm.num_facets); + for (auto lf : lagrange::range(rm.num_facets)) { + const double m = sampler.visibility_measure(rm.facet_offset + lf); + if (m < threshold) { + colors[lf] = {0.5, 0.5, 0.5}; + } else { + const double denom = max_measure - threshold; + const double u = + (denom > 0.0) ? std::clamp(1.0 - (m - threshold) / denom, 0.0, 1.0) : 0.0; + colors[lf] = green_red_colormap(u); + } + } + rm.ps->addFaceColorQuantity("visibility", colors)->setEnabled(true); + } +} + +/// Register every instance and launch polyscope with a live slider for the facet threshold, set to +/// max_threshold. User threshold value is returned. +double +visualize_facets(const SceneType& scene, const FacetSampler& sampler, const double max_threshold) { polyscope::init(); + double threshold = max_threshold; - // Heat scale: normalize across all visible facets. - uint64_t min_rays = std::numeric_limits::max(); - uint64_t max_rays = 0; + // Heat reference: the largest per-face measure across the scene. + double max_measure = 0.0; for (const auto& info : sampler.instances()) { for (auto lf : lagrange::range(info.num_facets)) { - const uint64_t gf = info.facet_offset + lf; - if (sampler.is_visible(gf)) { - min_rays = std::min(min_rays, sampler.num_rays_cast(gf)); - max_rays = std::max(max_rays, sampler.num_rays_cast(gf)); - } + max_measure = std::max(max_measure, sampler.visibility_measure(info.facet_offset + lf)); } } - const float log_max = std::log1p(static_cast(max_rays - min_rays)); + if (max_measure <= 0.0) max_measure = 1.0; + std::vector meshes; for (const auto& info : sampler.instances()) { const auto& mesh = scene.get_mesh(info.mesh_index); const auto& instance = scene.get_instance(info.mesh_index, info.instance_index); - std::string name = + const std::string name = "mesh_" + std::to_string(info.mesh_index) + "_" + std::to_string(info.instance_index); auto* ps_mesh = lagrange::polyscope::register_mesh(name, mesh); lagrange::polyscope::set_transform(*ps_mesh, instance.transform); ps_mesh->setBackFacePolicy(::polyscope::BackFacePolicy::Identical); - - std::vector> colors(info.num_facets); - for (auto lf : lagrange::range(info.num_facets)) { - const uint64_t gf = info.facet_offset + lf; - if (!sampler.is_visible(gf)) { - colors[lf] = {0.5, 0.5, 0.5}; - } else { - const float log_val = - std::log1p(static_cast(sampler.num_rays_cast(gf) - min_rays)); - const float t = (log_max > 0.f) ? (log_val / log_max) : 0.f; - const auto c = green_red_colormap(t); - colors[lf] = {c[0], c[1], c[2]}; - } - } - ps_mesh->addFaceColorQuantity("visibility", colors)->setEnabled(true); + meshes.push_back({ps_mesh, info.facet_offset, info.num_facets}); } + update_facets(sampler, meshes, threshold, max_measure); + + // Slider on sqrt(threshold) for perceptual uniformity; bounded above by max_threshold. + const auto max_threshold_root = static_cast(std::sqrt(max_threshold)); + auto threshold_slider = max_threshold_root; + ::polyscope::state::userCallback = [&, max_threshold_root, max_measure]() { + if (ImGui::SliderFloat( + "threshold (sqrt scale)", + &threshold_slider, + 0.0f, + max_threshold_root, + "%.4f")) { + threshold = + static_cast(threshold_slider) * static_cast(threshold_slider); + update_facets(sampler, meshes, threshold, max_measure); + } + ImGui::Text("facet keep threshold = %.6f", threshold); + }; polyscope::show(); + ::polyscope::state::userCallback = nullptr; + return threshold; } int run_facets_mode(const Args& args) { + if (args.until_converged) { + lagrange::logger().warn("--until-converged is ignored in facet modes"); + } lagrange::logger().info("Loading input scene: {}", args.input); auto scene = lagrange::io::load_simple_scene(args.input); - FacetSampler sampler(scene); + lagrange::raycasting::OccludedFacetSamplerOptions sampler_opts; + sampler_opts.threshold = args.max_threshold; + sampler_opts.size_influence = args.size_influence; + if (args.mode == Mode::Facets) { + sampler_opts.adaptive = + lagrange::raycasting::AdaptiveOptions{args.num_adaptive_per_cosine, args.vmf_kappa}; + } else { + sampler_opts.adaptive.reset(); + } + FacetSampler sampler(scene, sampler_opts); const uint64_t total_facets = sampler.num_facets(); lagrange::raycasting::OccludedFacetEstimateOptions opts; opts.num_rays = args.num_rays; opts.batch_size = args.batch_size; - opts.num_adaptive_per_normal = args.num_adaptive_per_normal; - opts.brute_force = args.mode == Mode::FacetsBruteforce; - opts.until_converged = args.until_converged; lagrange::ProgressCallback progress; std::signal(SIGINT, signal_handler); if (args.num_rays == 0) lagrange::logger().info("Progressive mode (Ctrl+C to stop)"); - lagrange::raycasting::estimate_occluded_facets(sampler, opts, progress, &g_cancel); + run_facet_sampler(sampler, opts, progress, &g_cancel); std::signal(SIGINT, SIG_DFL); uint64_t num_visible = 0; for (auto f : lagrange::range(total_facets)) { if (sampler.is_visible(f)) ++num_visible; } - const uint64_t num_occluded = total_facets - num_visible; - lagrange::logger().info("Found {} occluded facets out of {}", num_occluded, total_facets); + lagrange::logger().info( + "Found {} occluded facets out of {}", + total_facets - num_visible, + total_facets); + // Threshold is tuned in the viewer and applied to the saved result. + double threshold = args.max_threshold; if (args.vis) { - visualize_facets(scene, sampler); + threshold = visualize_facets(scene, sampler, args.max_threshold); } + lagrange::logger().info("Using threshold = {} for output", threshold); - // One output mesh per input instance — see remove_occluded_facets(). + // Facet decisions are instance-specific, so the output contains one mesh per input instance. const std::string output = args.output.empty() ? "output.obj" : args.output; lagrange::logger().info("Removing occluded facets and saving: {}", output); SceneType result; for (const auto& info : sampler.instances()) { auto filtered = scene.get_mesh(info.mesh_index); - filtered.remove_facets( - [&](Index local_f) { return !sampler.is_visible(info.facet_offset + local_f); }); + filtered.remove_facets([&](Index local_f) { + return sampler.visibility_measure(info.facet_offset + local_f) < threshold; + }); if (filtered.get_num_facets() == 0) continue; auto instance = scene.get_instance(info.mesh_index, info.instance_index); result.add_mesh(std::move(filtered)); @@ -294,15 +474,29 @@ int main(int argc, char** argv) app.add_option("--batch-size,-b", args.batch_size, "Rays per batch."); app.add_option( "--num-adaptive,-a", - args.num_adaptive_per_normal, - "Adaptive batches per normal batch (facets mode only)."); + args.num_adaptive_per_cosine, + "Adaptive mode: seed-guided rays per cosine ray (1:K mixture ratio, K >= 1)."); + app.add_option( + "--kappa,-k", + args.vmf_kappa, + "Adaptive mode: vMF proposal concentration (> 0; lobe width ~ 1/sqrt(kappa))."); + app.add_option( + "--max-threshold,-s", + args.max_threshold, + "Max keep-threshold in [0, 0.5] (and the slider's upper bound). " + "Meshes and facets: on their respective visibility measures."); + app.add_option( + "--size-influence", + args.size_influence, + "Exponent on the area ratio. Meshes: area/scene-AABB-area, facets: area_f/mean-face-area " + "(0 = mean visibility, 1 = area-weighted visibility)."); app.add_option("--log-level,-l", args.log_level, "Log level."); app.add_flag("--visualize,-v", args.vis, "Launch polyscope visualization."); app.add_flag( "--until-converged,-u", args.until_converged, - "Stop early when a batch (meshes) or cycle (facets) finds no new visibilities. " - "Composes with --num-rays as a soft early-exit."); + "Meshes only: after the initial batch, stop when a batch confidently retires no new " + "instances. Composes with --num-rays as a soft early-exit."); std::string mode_str = "meshes"; app.add_option("--mode,-m", mode_str, "Granularity: meshes | facets | facets_bruteforce.") diff --git a/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h b/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h index 4a7da84a..d15d84de 100644 --- a/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h +++ b/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h @@ -19,6 +19,9 @@ #include #include +#include +#include +#include namespace lagrange::raycasting { @@ -27,31 +30,53 @@ namespace lagrange::raycasting { /// @{ /// +/// +/// Options for adaptive mode of OccludedFacetSampler. +/// +struct AdaptiveOptions +{ + /// Seed-guided rays per cosine ray in the adaptive mixture (1:K ratio, K >= 1). + size_t num_adaptive_per_cosine = 6; + + /// von Mises-Fisher (vMF) lobe concentration on each seed direction + /// (angular width ~ 1/sqrt(kappa)). + double vmf_kappa = 128.0; +}; + /// /// Options for OccludedFacetSampler. /// struct OccludedFacetSamplerOptions { - /// Standard deviation of the Gaussian jitter applied to seed directions in adaptive batches. - double jitter_sigma = 0.025; - - /// Number of independent escapes a facet must accumulate before being marked visible. K=1 - /// is the original "first escape wins" behavior; K=2-3 dampens single-ray noise (a lucky - /// ray sneaking through a hairline gap no longer flips a facet on its own). Counts - /// saturate at 255. - uint8_t visibility_threshold = 3; + /// Adaptive multiple importance sampling (MIS) with a cosine + seed-guided mixture is enabled + /// by default. Set to `std::nullopt` for plain cosine-weighted hemisphere sampling. + std::optional adaptive = AdaptiveOptions{}; + + /// How much a facet size counts toward keeping it. `0` = size-independent, `1` = area-weighted. + double size_influence = 0.5; + + /// Keep-threshold for + /// `M = mean_visibility * (area_f / mean_face_area)^size_influence`. + /// A facet is kept when `M >= threshold`. + double threshold = 5e-06; + + /// Per-facet confidence for anytime-valid early-keep retirement. Under the betting-test + /// assumptions, the chance of incorrectly retiring a facet is at most `1 - confidence` over + /// any number of batches. Higher values retire later. This does not control final removals or + /// joint confidence across the scene. + double confidence = 0.9; }; +namespace internal { + /// -/// Stateful algorithm for finding occluded facets across every instance of a scene. Each -/// instance is gated independently with its own world-space facets. +/// Stateful algorithm for finding occluded facets across every instance of a scene. Each call to +/// @ref run_batch distributes rays over facets not yet decided, progressively improving the result. +/// Per-facet results are addressed by a global facet index (mapped back via @ref instances). /// -/// Combines two sampling strategies that can be alternated progressively: -/// - @ref run_normal_batch : cosine-weighted hemisphere sampling. The first escape direction -/// is cached against the facet that escaped. -/// - @ref run_adaptive_batch : for each still-occluded facet, casts jittered rays along -/// cached escape directions of its edge-adjacent visible neighbors. Exploits topological -/// coherence — an escape from a neighbor often also escapes from here. +/// The sampling strategy is fixed at construction by OccludedFacetSamplerOptions::adaptive: plain +/// cosine-weighted hemisphere sampling, or adaptive sampling that reuses escape directions to find +/// small holes faster. /// /// @note Only 3D scenes are supported. Meshes must be triangle meshes. /// @@ -95,106 +120,116 @@ class LA_RAYCASTING_API OccludedFacetSampler OccludedFacetSampler(OccludedFacetSampler&&) noexcept; OccludedFacetSampler& operator=(OccludedFacetSampler&&) noexcept; - /// Cosine-weighted hemisphere batch. Caches each facet's first-discovered escape direction - /// for later adaptive batches. - void run_normal_batch(uint64_t num_rays); - - /// Adaptive batch using cached escape directions of 1-ring visible neighbors. Skips facets - /// with no visible neighbor. - void run_adaptive_batch(uint64_t num_rays); + /// Trace one batch of `num_rays` rays, distributed across facets not yet decided. + void run_batch(uint64_t num_rays); - /// Cosine-weighted hemisphere batch with no escape caching — baseline for benchmarking - /// the adaptive mode against pure sampling. - void run_brute_force_batch(uint64_t num_rays); + /// Whether the facet is currently kept, i.e. `visibility_measure >= threshold`. + [[nodiscard]] bool is_visible(uint64_t global_facet_index) const; - /// Whether the facet has been marked visible so far. - bool is_visible(uint64_t global_facet_index) const; + /// The facet's visibility measure + /// `mean_visibility * (area_f / mean_face_area)^size_influence`, where `mean_visibility` is + /// the plain or MIS estimate of the cosine-weighted escaped fraction. + [[nodiscard]] double visibility_measure(uint64_t global_facet_index) const; /// Rays cast so far for the facet. - uint64_t num_rays_cast(uint64_t global_facet_index) const; + [[nodiscard]] uint64_t num_rays_cast(uint64_t global_facet_index) const; /// Total number of facets across all instances. Multi-instance meshes are counted once /// per instance. - uint64_t num_facets() const; + [[nodiscard]] uint64_t num_facets() const; + + /// Number of facets confidently above the keep threshold and retired from sampling. This is + /// useful to progressive callers for reporting and detecting that all facets have retired. + [[nodiscard]] uint64_t num_retired() const; /// Per-instance metadata for mapping global facet indices to (mesh, instance, local facet). - span instances() const; + [[nodiscard]] span instances() const; private: struct Impl; value_ptr m_impl; }; +} // namespace internal + /// -/// Loop options for @ref estimate_occluded_facets() and @ref remove_occluded_facets(). +/// Ray-budget options for @ref estimate_occluded_facet_measures() and +/// @ref remove_occluded_facets(). /// struct OccludedFacetEstimateOptions { - /// Total ray budget. If 0, the loop runs until cancelled or converged — at least one of - /// `num_rays>0`, @ref until_converged, or a non-null cancel flag is required. + /// Total ray budget. If 0, a non-null cancellation flag is required. uint64_t num_rays = 1600000000ULL; - /// Rays per batch. Cancellation, progress, and convergence are checked at cycle boundaries - /// (one batch per cycle in brute-force, one normal + @ref num_adaptive_per_normal adaptive - /// otherwise). - uint64_t batch_size = 20000000ULL; - - /// Adaptive batches per normal batch. 0 reduces to pure cosine sampling. Ignored when - /// @ref brute_force is true. - uint64_t num_adaptive_per_normal = 6; - - /// Run brute-force batches only — baseline for benchmarking against the adaptive mode. - bool brute_force = false; - - /// Stop early when a cycle finds no new visible facets. - bool until_converged = false; + /// Rays per batch. Cancellation and progress are checked at batch boundaries. + uint64_t batch_size = 50000000ULL; }; /// -/// Drive an @ref OccludedFacetSampler progressively until the budget is exhausted, the search -/// converges, or cancellation is requested. Logs per-cycle progress via @c lagrange::logger() -/// and reports a normalized [0, 1] fraction to @p progress. -/// -/// The caller can inspect @p sampler after the call returns to retrieve per-element stats, -/// build an output scene, etc. -/// -/// @param[in,out] sampler Sampler to drive. -/// @param[in] options Estimate options. -/// @param[in,out] progress Progress callback (default-constructed = silent). -/// @param[in] cancel Optional cancellation flag, polled at cycle boundaries. +/// How a facet's measure is reconciled across the instances of its source mesh. /// -/// @tparam Scalar Mesh scalar type. -/// @tparam Index Mesh index type. -/// -template -LA_RAYCASTING_API void estimate_occluded_facets( - OccludedFacetSampler& sampler, - const OccludedFacetEstimateOptions& options, - ProgressCallback& progress, - const std::atomic_bool* cancel = nullptr); +enum class InstancingPolicy { + /// One output mesh per input instance; instancing is lost. + FlattenInstances, + /// Preserve instancing; measure is the max (most visible) over its instances. + Max, + /// Preserve instancing; measure is the mean over its instances. + Average, +}; /// /// Options for remove_occluded_facets(). /// struct RemoveOccludedFacetsOptions { - /// Estimate-loop options forwarded to @ref estimate_occluded_facets(). + /// Options forwarded to the underlying @ref internal::OccludedFacetSampler. + OccludedFacetSamplerOptions sampler_options = {}; + + /// Ray-budget and batch-size options. OccludedFacetEstimateOptions estimate_options = {}; - /// Options forwarded to the underlying @ref OccludedFacetSampler. - OccludedFacetSamplerOptions sampler_options = {}; + /// How to reconcile a facet's measure across the instances of its source mesh. + InstancingPolicy instancing = InstancingPolicy::Max; }; +/// +/// Estimate each facet's visibility measure and write it to a named per-facet Scalar attribute. +/// +/// Instances are reconciled per @p options.instancing (see @ref InstancingPolicy). The sampler runs +/// at `options.sampler_options.threshold`, so a caller can re-threshold the measures without +/// re-tracing. +/// +/// @param[in] scene Input scene. Every referenced mesh must be a triangle mesh. +/// @param[in] attribute_name Name of the per-facet Scalar attribute to create on each output mesh. +/// @param[in] options Options. +/// @param[in,out] progress Progress callback, updated at batch boundaries. +/// @param[in] is_occluder Forwarded to the underlying @ref internal::OccludedFacetSampler ctor. +/// @param[in] cancel Optional cancellation flag. +/// +/// @return The scene with per-facet visibility measures. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_RAYCASTING_API scene::SimpleScene estimate_occluded_facet_measures( + const scene::SimpleScene& scene, + std::string_view attribute_name, + const RemoveOccludedFacetsOptions& options, + ProgressCallback& progress, + function_ref is_occluder = + [](Index, Index) { return true; }, + const std::atomic_bool* cancel = nullptr); + /// /// Build a new scene with facets not visible from the outside removed. /// -/// The output contains one unique mesh per input instance: instances of the same source mesh -/// can end up with different facets culled, so the input's instancing cannot be preserved. +/// Instances are reconciled per @p options.instancing (see @ref InstancingPolicy). /// /// @param[in] scene Input scene. /// @param[in] options Options. -/// @param[in,out] progress Progress callback (see @ref estimate_occluded_facets). -/// @param[in] is_occluder Forwarded to the underlying @ref OccludedFacetSampler ctor. +/// @param[in,out] progress Progress callback, updated at batch boundaries. +/// @param[in] is_occluder Forwarded to the underlying @ref internal::OccludedFacetSampler ctor. /// @param[in] cancel Optional cancellation flag. /// /// @return The filtered scene. diff --git a/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h b/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h index 2fd5a16f..11cec105 100644 --- a/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h +++ b/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h @@ -18,6 +18,8 @@ #include #include +#include +#include namespace lagrange::raycasting { @@ -26,9 +28,31 @@ namespace lagrange::raycasting { /// @{ /// +/// +/// Options for OccludedInstanceSampler. +/// +struct OccludedInstanceSamplerOptions +{ + /// Keep-threshold for `M = mean_visibility * (area / R)^size_influence`, where + /// `mean_visibility` is the cosine-weighted escaped fraction and R is the scene AABB area. + double threshold = 1e-06; + + /// Exponent on `area / R` in the measure above. `0` = size-independent visibility; + /// `1` = area-weighted visibility. + double size_influence = 0.5; + + /// Per-instance confidence used by each batch-end early-keep test. Higher values require more + /// evidence before sampling stops. This does not control final removals or joint confidence + /// across the scene. + double confidence = 0.9; +}; + +namespace internal { + /// /// Stateful algorithm for finding occluded instances in a scene. Each call to @ref run_batch -/// distributes rays over instances not yet marked visible, progressively improving the result. +/// distributes rays over instances not yet decided, progressively improving the result. Per-instance +/// results are addressed by `(mesh_index, instance_index)`. /// /// @note Only 3D scenes are supported. /// @@ -43,12 +67,14 @@ class LA_RAYCASTING_API OccludedInstanceSampler /// Build the ray caster and per-instance precomputation. /// /// @param[in] scene Scene to process. + /// @param[in] options Sampler options (keep-threshold, size influence, confidence). /// @param[in] is_occluder Returns whether `(mesh_index, instance_index)` should block /// rays. Non-occluder instances are still tested for visibility /// but do not contribute to the ray-caster scene. /// explicit OccludedInstanceSampler( const scene::SimpleScene& scene, + const OccludedInstanceSamplerOptions& options = {}, function_ref is_occluder = [](Index, Index) { return true; }); @@ -57,107 +83,144 @@ class LA_RAYCASTING_API OccludedInstanceSampler OccludedInstanceSampler(OccludedInstanceSampler&&) noexcept; OccludedInstanceSampler& operator=(OccludedInstanceSampler&&) noexcept; - /// Run a batch distributing @p num_rays across instances not yet marked visible. + /// Run a batch distributing @p num_rays across instances not yet decided. void run_batch(uint64_t num_rays); - /// Whether the instance has been marked visible so far. - bool is_visible(Index global_index) const; + /// Whether the instance is currently kept: `visibility_measure(mesh, instance) >= threshold`. + [[nodiscard]] bool is_visible(Index mesh_index, Index instance_index) const; + + /// The instance's visibility measure + /// `mean_visibility * (area / R)^size_influence`, where `mean_visibility` is the estimated + /// cosine-weighted escaped fraction in [0, 1]. + [[nodiscard]] double visibility_measure(Index mesh_index, Index instance_index) const; /// Rays cast so far for the instance. - uint64_t num_rays_cast(Index global_index) const; + [[nodiscard]] uint64_t num_rays_cast(Index mesh_index, Index instance_index) const; + + /// Total number of instances across all meshes. + [[nodiscard]] Index num_instances() const; + + /// Number of instances confidently above the keep threshold and retired from sampling. This + /// is useful to progressive callers for reporting and detecting that all instances retired. + [[nodiscard]] Index num_retired() const; - /// Total number of instances. - Index num_instances() const; + /// Aggregate `(number of currently-visible instances, total rays cast)` for progress reporting + /// without rescanning the scene's `(mesh_index, instance_index)` address space. + [[nodiscard]] std::pair progress() const; private: struct Impl; value_ptr m_impl; }; +} // namespace internal + /// /// Loop options for @ref estimate_occluded_instances() and @ref remove_occluded_instances(). /// struct OccludedInstanceEstimateOptions { - /// Total ray budget. If 0, the loop runs until cancelled or converged — at least one of - /// `num_rays>0`, @ref until_converged, or a non-null cancel flag is required. + /// Total ray budget. If 0, the loop runs until cancelled or convergence is requested — at + /// least one of `num_rays>0`, @ref until_converged, or a non-null cancel flag is required. uint64_t num_rays = 1600000000ULL; /// Rays per batch. Cancellation, progress, and convergence are checked at batch boundaries. - uint64_t batch_size = 20000000ULL; + uint64_t batch_size = 50000000ULL; - /// Stop early when a batch finds no new visible instances. + /// After the initial batch, stop when a batch confidently retires no new instances. + /// This is a batch-size-dependent heuristic; use @ref num_rays for fixed-budget estimates. bool until_converged = false; }; /// -/// Drive an @ref OccludedInstanceSampler progressively. See @ref estimate_occluded_facets for the -/// shared semantics; the only difference here is per-batch (rather than per-cycle) granularity. -/// -/// @param[in,out] sampler Sampler to drive. -/// @param[in] options Estimate options. -/// @param[in,out] progress Progress callback (default-constructed = silent). -/// @param[in] cancel Optional cancellation flag, polled at batch boundaries. -/// -/// @tparam Scalar Mesh scalar type. -/// @tparam Index Mesh index type. +/// Options for remove_occluded_instances() and estimate_occluded_instance_measures(). /// -template -LA_RAYCASTING_API void estimate_occluded_instances( - OccludedInstanceSampler& sampler, - const OccludedInstanceEstimateOptions& options, - ProgressCallback& progress, - const std::atomic_bool* cancel = nullptr); +struct RemoveOccludedInstancesOptions +{ + /// Sampler options (keep-threshold, size influence, confidence). + OccludedInstanceSamplerOptions sampler_options = {}; + + /// Estimate-loop options. + OccludedInstanceEstimateOptions estimate_options = {}; +}; /// -/// @overload -/// -/// Convenience wrapper that builds an @ref OccludedInstanceSampler internally and reports each -/// occluded instance via @p callback. +/// Estimate the per-instance visibility measure for a scene. The sampler runs at +/// `options.sampler_options.threshold`. The returned measures let a caller keep instances at any +/// smaller threshold without re-tracing: keep `(mesh_index, instance_index)` iff +/// `measures[mesh_index][instance_index] >= threshold`. /// /// @note Only 3D scenes are supported. /// /// @param[in] scene Scene to process. -/// @param[in] callback Called as `callback(mesh_index, instance_index)`. /// @param[in] options Options. /// @param[in,out] progress Progress callback. -/// @param[in] is_occluder Forwarded to @ref OccludedInstanceSampler ctor: returns -/// whether `(mesh_index, instance_index)` should block rays. -/// Non-occluder instances are still tested for visibility but -/// don't contribute to the ray-caster scene. +/// @param[in] is_occluder Forwarded to @ref internal::OccludedInstanceSampler ctor. /// @param[in] cancel Optional cancellation flag. /// +/// @return Per-instance measures indexed `[mesh_index][instance_index]`. +/// /// @tparam Scalar Mesh scalar type. /// @tparam Index Mesh index type. /// template -LA_RAYCASTING_API void estimate_occluded_instances( +LA_RAYCASTING_API std::vector> estimate_occluded_instance_measures( const scene::SimpleScene& scene, - function_ref callback, - const OccludedInstanceEstimateOptions& options, + const RemoveOccludedInstancesOptions& options, ProgressCallback& progress, function_ref is_occluder = [](Index, Index) { return true; }, const std::atomic_bool* cancel = nullptr); /// -/// Remove fully-occluded mesh instances. Convenience wrapper around -/// @ref estimate_occluded_instances + `lagrange::scene::filter_instances`. +/// Remove instances whose visibility measure is below `options.sampler_options.threshold`. +/// Convenience wrapper around @ref internal::OccludedInstanceSampler and +/// `lagrange::scene::filter_instances`. /// /// @note Only 3D scenes are supported. /// /// @param[in] scene Scene to process. /// @param[in] options Options. /// @param[in,out] progress Progress callback. -/// @param[in] is_occluder Forwarded to the underlying sampler — see -/// @ref estimate_occluded_instances. Non-occluders are still -/// candidates for removal but don't block rays from others. +/// @param[in] is_occluder Forwarded to the underlying sampler. /// @param[in] cancel Optional cancellation flag. /// /// @tparam Scalar Mesh scalar type. /// @tparam Index Mesh index type. /// template +LA_RAYCASTING_API scene::SimpleScene remove_occluded_instances( + const scene::SimpleScene& scene, + const RemoveOccludedInstancesOptions& options, + ProgressCallback& progress, + function_ref is_occluder = + [](Index, Index) { return true; }, + const std::atomic_bool* cancel = nullptr); + +/// +/// @deprecated Use @ref estimate_occluded_instance_measures. This overload reports only the +/// occluded instances via @p callback. It uses `std::numeric_limits::min()` as the +/// keep-threshold to preserve the original first-observed-escape behavior. +/// +template +[[deprecated("Use estimate_occluded_instance_measures().")]] +LA_RAYCASTING_API void estimate_occluded_instances( + const scene::SimpleScene& scene, + function_ref callback, + const OccludedInstanceEstimateOptions& options, + ProgressCallback& progress, + function_ref is_occluder = + [](Index, Index) { return true; }, + const std::atomic_bool* cancel = nullptr); + +/// +/// @deprecated Use the @ref RemoveOccludedInstancesOptions overload of +/// @ref remove_occluded_instances, which exposes the keep-threshold. This overload uses +/// `std::numeric_limits::min()` as the keep-threshold to preserve the original +/// first-observed-escape behavior. +/// +template +[[deprecated("Use the remove_occluded_instances overload taking RemoveOccludedInstancesOptions.")]] LA_RAYCASTING_API scene::SimpleScene remove_occluded_instances( const scene::SimpleScene& scene, const OccludedInstanceEstimateOptions& options, diff --git a/modules/raycasting/python/src/raycasting.cpp b/modules/raycasting/python/src/raycasting.cpp index 41787b7d..2df7b6d5 100644 --- a/modules/raycasting/python/src/raycasting.cpp +++ b/modules/raycasting/python/src/raycasting.cpp @@ -51,6 +51,7 @@ namespace { using Scalar = double; using Index = uint32_t; using MeshType = SurfaceMesh; +using IsOccluderFn = std::function; // Python None | float scalar | float numpy array using FloatArray = nb::ndarray; using FloatParam = std::variant; @@ -59,6 +60,51 @@ using DirectionParam = std::variant, nb::c_contig, nb::device::cpu>; +IsOccluderFn resolve_is_occluder(const std::optional& is_occluder) +{ + return is_occluder.value_or([](Index, Index) { return true; }); +} + +raycasting::RemoveOccludedInstancesOptions make_occluded_instance_options( + uint64_t num_rays, + uint64_t batch_size, + bool until_converged, + double threshold, + double size_influence, + double confidence) +{ + raycasting::RemoveOccludedInstancesOptions options; + options.estimate_options = {num_rays, batch_size, until_converged}; + options.sampler_options = {threshold, size_influence, confidence}; + return options; +} + +raycasting::RemoveOccludedFacetsOptions make_occluded_facet_options( + uint64_t num_rays, + uint64_t batch_size, + bool adaptive, + size_t num_adaptive_per_cosine, + double vmf_kappa, + double threshold, + double size_influence, + double confidence, + raycasting::InstancingPolicy instancing) +{ + raycasting::RemoveOccludedFacetsOptions options; + options.estimate_options = {num_rays, batch_size}; + if (adaptive) { + options.sampler_options.adaptive = + raycasting::AdaptiveOptions{num_adaptive_per_cosine, vmf_kappa}; + } else { + options.sampler_options.adaptive.reset(); + } + options.sampler_options.threshold = threshold; + options.sampler_options.size_influence = size_influence; + options.sampler_options.confidence = confidence; + options.instancing = instancing; + return options; +} + /// Parse a 1D (3,) or 2D (N, 3) float array and return the number of points/rays. size_t get_num_points(const FloatArray& arr, const char* name) { @@ -357,6 +403,23 @@ void populate_raycasting_module(nb::module_& m) raycasting::FallbackMode::ClosestPoint, "Interpolate from the closest surface point."); + nb::enum_( + m, + "InstancingPolicy", + "How a facet's measure is reconciled across the instances of its source mesh.") + .value( + "FlattenInstances", + raycasting::InstancingPolicy::FlattenInstances, + "One output mesh per instance; instancing is lost.") + .value( + "Max", + raycasting::InstancingPolicy::Max, + "Preserve instancing; measure is the max (most visible) over its instances.") + .value( + "Average", + raycasting::InstancingPolicy::Average, + "Preserve instancing; measure is the mean over its instances."); + nb::enum_(m, "ProjectMode", "Main projection mode.") .value( "ClosestVertex", @@ -1504,70 +1567,81 @@ The local feature size is stored as a per-vertex attribute on the mesh. // ========================================================================= using SimpleScene3D = scene::SimpleScene; - using IsOccluderFn = std::function; constexpr raycasting::OccludedFacetEstimateOptions facet_defaults{}; constexpr raycasting::OccludedFacetSamplerOptions facet_sampler_defaults{}; + constexpr raycasting::RemoveOccludedFacetsOptions facet_options_defaults{}; + constexpr raycasting::AdaptiveOptions adaptive_defaults{}; constexpr raycasting::OccludedInstanceEstimateOptions instance_defaults{}; + constexpr raycasting::OccludedInstanceSamplerOptions instance_sampler_defaults{}; m.def( "remove_occluded_facets", [](const SimpleScene3D& scene, uint64_t num_rays, uint64_t batch_size, - uint64_t num_adaptive_per_normal, - bool brute_force, - bool until_converged, - double jitter_sigma, - int visibility_threshold, + bool adaptive, + size_t num_adaptive_per_cosine, + double vmf_kappa, + double threshold, + double size_influence, + double confidence, + raycasting::InstancingPolicy instancing, std::optional is_occluder) { - la_runtime_assert( - visibility_threshold >= 1 && visibility_threshold <= 255, - "visibility_threshold must be in [1, 255]"); - raycasting::RemoveOccludedFacetsOptions options; - options.estimate_options.num_rays = num_rays; - options.estimate_options.batch_size = batch_size; - options.estimate_options.num_adaptive_per_normal = num_adaptive_per_normal; - options.estimate_options.brute_force = brute_force; - options.estimate_options.until_converged = until_converged; - options.sampler_options.jitter_sigma = jitter_sigma; - options.sampler_options.visibility_threshold = - static_cast(visibility_threshold); + const auto options = make_occluded_facet_options( + num_rays, + batch_size, + adaptive, + num_adaptive_per_cosine, + vmf_kappa, + threshold, + size_influence, + confidence, + instancing); ProgressCallback progress; - if (is_occluder) { - return raycasting::remove_occluded_facets( - scene, - options, - progress, - *is_occluder); - } - return raycasting::remove_occluded_facets(scene, options, progress); + const auto occluder = resolve_is_occluder(is_occluder); + return raycasting::remove_occluded_facets( + scene, + options, + progress, + occluder); }, "scene"_a, nb::kw_only(), "num_rays"_a = facet_defaults.num_rays, "batch_size"_a = facet_defaults.batch_size, - "num_adaptive_per_normal"_a = facet_defaults.num_adaptive_per_normal, - "brute_force"_a = facet_defaults.brute_force, - "until_converged"_a = facet_defaults.until_converged, - "jitter_sigma"_a = facet_sampler_defaults.jitter_sigma, - "visibility_threshold"_a = facet_sampler_defaults.visibility_threshold, + "adaptive"_a = facet_sampler_defaults.adaptive.has_value(), + "num_adaptive_per_cosine"_a = adaptive_defaults.num_adaptive_per_cosine, + "vmf_kappa"_a = adaptive_defaults.vmf_kappa, + "threshold"_a = facet_sampler_defaults.threshold, + "size_influence"_a = facet_sampler_defaults.size_influence, + "confidence"_a = facet_sampler_defaults.confidence, + "instancing"_a = facet_options_defaults.instancing, "is_occluder"_a = nb::none(), R"(Build a new scene with facets not visible from the outside removed. -The output contains one unique mesh per input instance: instances of the same source mesh can -end up with different facets culled, so the input's instancing cannot be preserved. +A facet is kept when its visibility measure +``mean_visibility * (area_f / mean_face_area) ** size_influence`` is at least ``threshold``, where +``mean_visibility`` is the cosine-weighted escaped fraction. :param scene: Input scene. -:param num_rays: Total ray budget. Must be > 0 unless ``until_converged`` is True. +:param num_rays: Total ray budget. Must be > 0. :param batch_size: Rays per batch. -:param num_adaptive_per_normal: Adaptive batches per normal batch (0 = pure cosine sampling). - Ignored when ``brute_force`` is True. -:param brute_force: Run brute-force batches only — baseline for benchmarking. -:param until_converged: Stop early when a cycle finds no new visible facets. -:param jitter_sigma: Std-dev of Gaussian jitter applied to adaptive seed directions. -:param visibility_threshold: Number of independent escapes required to mark a facet visible - (>= 1). 1 = first-escape-wins (original); 2-3 dampens hairline- - gap shrapnel. +:param adaptive: Enable adaptive sampling (seed reuse to find small holes faster). + True by default; False uses plain cosine sampling. +:param num_adaptive_per_cosine: Seed-guided rays per cosine ray in the adaptive mixture (>= 1). + Ignored when ``adaptive`` is False. +:param vmf_kappa: Concentration of the adaptive vMF proposal lobe (> 0; angular + width ~ 1/sqrt(kappa)). Ignored when ``adaptive`` is False. +:param threshold: Keep-threshold in [0, 0.5] on the per-face visibility measure. +:param size_influence: Exponent on area_f / mean_face_area. 0 = keep any exposed facet + (pure occlusion culling); > 0 also culls facets small relative to + their mesh (visibility-weighted decimation). +:param confidence: Per-facet confidence in (0, 1) for anytime-valid early keeping. + Higher values retire later. It does not control final removals or + joint confidence across the scene. +:param instancing: How to reconcile a facet's measure across instances of its source + mesh: ``Max`` (default, most visible) or ``Average`` (both preserve + instancing), or ``FlattenInstances`` (one mesh per instance). :param is_occluder: Optional callable ``(mesh_index, instance_index) -> bool`` returning whether an instance should block rays. Non-occluders are still tested for visibility but do not contribute to the @@ -1581,35 +1655,57 @@ end up with different facets culled, so the input's instancing cannot be preserv uint64_t num_rays, uint64_t batch_size, bool until_converged, + double threshold, + double size_influence, + double confidence, std::optional is_occluder) { - raycasting::OccludedInstanceEstimateOptions options; - options.num_rays = num_rays; - options.batch_size = batch_size; - options.until_converged = until_converged; + const auto options = make_occluded_instance_options( + num_rays, + batch_size, + until_converged, + threshold, + size_influence, + confidence); ProgressCallback progress; // Explicit template args bypass deduction — function_ref is constructed from // std::function via the implicit conversion only after deduction is settled. - if (is_occluder) { - return raycasting::remove_occluded_instances( - scene, - options, - progress, - *is_occluder); - } - return raycasting::remove_occluded_instances(scene, options, progress); + const auto occluder = resolve_is_occluder(is_occluder); + return raycasting::remove_occluded_instances( + scene, + options, + progress, + occluder); }, "scene"_a, nb::kw_only(), "num_rays"_a = instance_defaults.num_rays, "batch_size"_a = instance_defaults.batch_size, "until_converged"_a = instance_defaults.until_converged, + "threshold"_a = instance_sampler_defaults.threshold, + "size_influence"_a = instance_sampler_defaults.size_influence, + "confidence"_a = instance_sampler_defaults.confidence, "is_occluder"_a = nb::none(), - R"(Remove fully-occluded mesh instances from a scene. + R"(Remove instances whose exterior visibility measure is below ``threshold``. + +Keeps an instance when its visibility measure—mean visibility weighted by relative surface +area—is at least ``threshold``. Unlike a per-point test, this can grow with mesh size: a large mesh +seen through a small hole survives, while a small or barely visible one is culled. :param scene: Input scene. :param num_rays: Total ray budget. Must be > 0 unless ``until_converged`` is True. :param batch_size: Rays per batch. -:param until_converged: Stop early when a batch finds no new visible instances. +:param until_converged: After the initial batch, stop when a batch confidently retires no new + instances. This is a batch-size-dependent heuristic. +:param threshold: Keep-threshold in [0, 0.5]: keep when mean_visibility * (area/scene-AABB-area) + ** size_influence >= threshold. Defaults to ``1e-6`` (matching the C++ API); + larger values enable aggressive culling, and a near-zero value reproduces the + original first-observed-escape behavior. +:param size_influence: How much mesh size counts (exponent on area/scene-AABB-area). 0 = size- + independent (keeps small exterior details like emblems); 1 = area-weighted + visibility; 0.5 = weighted by linear size. +:param confidence: Per-instance confidence in (0, 1) used at each batch-end early-keep test. + Higher values retire later; it does not control final removals or joint + confidence across the scene. :param is_occluder: Optional callable ``(mesh_index, instance_index) -> bool`` returning whether an instance should block rays. Non-occluders are still tested for visibility but do not contribute to the ray-caster scene. Defaults @@ -1623,27 +1719,29 @@ end up with different facets culled, so the input's instancing cannot be preserv uint64_t num_rays, uint64_t batch_size, bool until_converged, + double threshold, + double size_influence, + double confidence, std::optional is_occluder) { - raycasting::OccludedInstanceEstimateOptions options; - options.num_rays = num_rays; - options.batch_size = batch_size; - options.until_converged = until_converged; + const auto options = make_occluded_instance_options( + num_rays, + batch_size, + until_converged, + threshold, + size_influence, + confidence); ProgressCallback progress; + const auto occluder = resolve_is_occluder(is_occluder); + const auto measures = raycasting::estimate_occluded_instance_measures( + scene, + options, + progress, + occluder); std::vector> occluded; - auto callback = [&](Index mi, Index ii) { occluded.emplace_back(mi, ii); }; - if (is_occluder) { - raycasting::estimate_occluded_instances( - scene, - callback, - options, - progress, - *is_occluder); - } else { - raycasting::estimate_occluded_instances( - scene, - callback, - options, - progress); + for (Index mi = 0; mi < scene.get_num_meshes(); ++mi) { + for (Index ii = 0; ii < scene.get_num_instances(mi); ++ii) { + if (measures[mi][ii] < threshold) occluded.emplace_back(mi, ii); + } } return occluded; }, @@ -1652,17 +1750,163 @@ end up with different facets culled, so the input's instancing cannot be preserv "num_rays"_a = instance_defaults.num_rays, "batch_size"_a = instance_defaults.batch_size, "until_converged"_a = instance_defaults.until_converged, + "threshold"_a = instance_sampler_defaults.threshold, + "size_influence"_a = instance_sampler_defaults.size_influence, + "confidence"_a = instance_sampler_defaults.confidence, "is_occluder"_a = nb::none(), - R"(Find mesh instances that are fully occluded by other geometry. + R"(Find mesh instances whose visibility measure falls below the threshold. + +Deprecated: use :py:func:`estimate_occluded_instance_measures` and apply the desired threshold to +the returned measures. :param scene: Input scene. :param num_rays: Total ray budget. Must be > 0 unless ``until_converged`` is True. :param batch_size: Rays per batch. -:param until_converged: Stop early when a batch finds no new visible instances. -:param is_occluder: Optional callable ``(mesh_index, instance_index) -> bool``. See +:param until_converged: After the initial batch, stop when a batch confidently retires no new + instances. This is a batch-size-dependent heuristic. +:param threshold: Keep-threshold in [0, 0.5]. Defaults to ``1e-6`` (matching the C++ API); a + near-zero value reproduces the original first-observed-escape behavior. See :py:func:`remove_occluded_instances`. +:param size_influence: Exponent on area/scene-AABB-area (0 = size-independent, 1 = area-weighted). + See :py:func:`remove_occluded_instances`. +:param confidence: Per-instance confidence in (0, 1) used at each batch-end early-keep test. + Higher values retire later; it does not control final removals or joint + confidence across the scene. +:param is_occluder: Optional callable ``(mesh_index, instance_index) -> bool``. :return: List of ``(mesh_index, instance_index)`` pairs for occluded instances.)"); + + m.def( + "estimate_occluded_instance_measures", + [](const SimpleScene3D& scene, + uint64_t num_rays, + uint64_t batch_size, + bool until_converged, + double threshold, + double size_influence, + double confidence, + std::optional is_occluder) { + const auto options = make_occluded_instance_options( + num_rays, + batch_size, + until_converged, + threshold, + size_influence, + confidence); + ProgressCallback progress; + const auto occluder = resolve_is_occluder(is_occluder); + return raycasting::estimate_occluded_instance_measures( + scene, + options, + progress, + occluder); + }, + "scene"_a, + nb::kw_only(), + "num_rays"_a = instance_defaults.num_rays, + "batch_size"_a = instance_defaults.batch_size, + "until_converged"_a = instance_defaults.until_converged, + "threshold"_a = instance_sampler_defaults.threshold, + "size_influence"_a = instance_sampler_defaults.size_influence, + "confidence"_a = instance_sampler_defaults.confidence, + "is_occluder"_a = nb::none(), + R"(Estimate every instance's visibility measure, indexed ``[mesh_index][instance_index]``. + +The sampler runs at ``threshold`` (the strictest threshold you intend to use); the returned +measures let you keep instances at any ``t <= threshold`` without re-tracing — keep +``(mesh_index, instance_index)`` iff ``measures[mesh_index][instance_index] >= t``. + +All instance functions default ``threshold`` to ``1e-6`` (matching the C++ API); pass a near-zero +value to reproduce the original first-observed-escape behavior. + +:param scene: Input scene. +:param num_rays: Total ray budget. Must be > 0 unless ``until_converged`` is True. +:param batch_size: Rays per batch. +:param until_converged: After the initial batch, stop when a batch confidently retires no new + instances. This is a batch-size-dependent heuristic. +:param threshold: Strictest keep-threshold in [0, 0.5] to sample for. See + :py:func:`remove_occluded_instances`. +:param size_influence: Exponent on area/scene-AABB-area (0 = size-independent, 1 = area-weighted). +:param confidence: Per-instance confidence in (0, 1) used at each batch-end early-keep test. + Higher values retire later; it does not control final removals or joint + confidence across the scene. +:param is_occluder: Optional callable ``(mesh_index, instance_index) -> bool``. + +:return: Per-instance measures as ``measures[mesh_index][instance_index]``.)"); + + m.def( + "estimate_occluded_facet_measures", + [](const SimpleScene3D& scene, + std::string attribute_name, + uint64_t num_rays, + uint64_t batch_size, + bool adaptive, + size_t num_adaptive_per_cosine, + double vmf_kappa, + double threshold, + double size_influence, + double confidence, + raycasting::InstancingPolicy instancing, + std::optional is_occluder) { + const auto options = make_occluded_facet_options( + num_rays, + batch_size, + adaptive, + num_adaptive_per_cosine, + vmf_kappa, + threshold, + size_influence, + confidence, + instancing); + ProgressCallback progress; + const auto occluder = resolve_is_occluder(is_occluder); + return raycasting::estimate_occluded_facet_measures( + scene, + attribute_name, + options, + progress, + occluder); + }, + "scene"_a, + nb::kw_only(), + "attribute_name"_a = "visibility_measure", + "num_rays"_a = facet_defaults.num_rays, + "batch_size"_a = facet_defaults.batch_size, + "adaptive"_a = facet_sampler_defaults.adaptive.has_value(), + "num_adaptive_per_cosine"_a = adaptive_defaults.num_adaptive_per_cosine, + "vmf_kappa"_a = adaptive_defaults.vmf_kappa, + "threshold"_a = facet_sampler_defaults.threshold, + "size_influence"_a = facet_sampler_defaults.size_influence, + "confidence"_a = facet_sampler_defaults.confidence, + "instancing"_a = facet_options_defaults.instancing, + "is_occluder"_a = nb::none(), + R"(Estimate each facet's visibility measure and write it to a named per-facet attribute. + +Each output mesh carries a per-facet Scalar attribute ``attribute_name`` equal to the facet's +(possibly aggregated) visibility measure +``mean_visibility * (area_f / mean_face_area) ** size_influence``. The sampler runs at ``threshold``; +remove facets whose attribute is below any ``t <= threshold`` to filter without re-tracing. + +:param scene: Input scene. +:param attribute_name: Name of the per-facet Scalar attribute to create on each mesh. +:param num_rays: Total ray budget. Must be > 0. +:param batch_size: Rays per batch. +:param adaptive: Enable adaptive sampling (seed reuse to find small holes faster). + True by default; False uses plain cosine sampling. +:param num_adaptive_per_cosine: Seed-guided rays per cosine ray (>= 1). Ignored when not adaptive. +:param vmf_kappa: Concentration of the adaptive vMF proposal lobe (> 0). Ignored + when not adaptive. +:param threshold: Strictest keep-threshold in [0, 0.5] to sample for. +:param size_influence: Exponent on area_f / mean_face_area (0 = pure occlusion culling). +:param confidence: Per-facet confidence in (0, 1) for anytime-valid early keeping. + Higher values retire later. It does not control final removals or + joint confidence across the scene. +:param instancing: How to reconcile a facet's measure across instances of its source + mesh: ``Max`` (default, most visible) or ``Average`` (both preserve + instancing), or ``FlattenInstances`` (one mesh per instance). +:param is_occluder: Optional callable ``(mesh_index, instance_index) -> bool``. + +:return: Scene whose meshes carry the per-facet visibility measure attribute, per ``instancing``.)"); } } // namespace lagrange::python diff --git a/modules/raycasting/python/tests/conftest.py b/modules/raycasting/python/tests/conftest.py index fac0fad1..96186adb 100644 --- a/modules/raycasting/python/tests/conftest.py +++ b/modules/raycasting/python/tests/conftest.py @@ -65,3 +65,105 @@ def nested_cubes_scene(request): instance.mesh_index = mi scene.add_instance(instance) return scene + + +@pytest.fixture +def shared_mesh_occlusion_scene(): + """One small-cube mesh shared by two instances: one enclosed by an outer cube (occluded) and one + far away in the open (visible). Exercises the facet instancing policies.""" + scene = lagrange.scene.SimpleScene3D() + outer_id = scene.add_mesh(_centered_cube(2.0)) + small_id = scene.add_mesh(_centered_cube(0.5)) + + outer = lagrange.scene.MeshInstance3D() + outer.mesh_index = outer_id + scene.add_instance(outer) + + enclosed = lagrange.scene.MeshInstance3D() + enclosed.mesh_index = small_id # at origin, inside the outer cube -> fully occluded + scene.add_instance(enclosed) + + exposed = lagrange.scene.MeshInstance3D() + exposed.mesh_index = small_id + exposed.transform = np.array( + [[1, 0, 0, 10], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float + ) + scene.add_instance(exposed) + return scene + + +@pytest.fixture +def far_apart_triangles_scene(): + """Two open triangles whose area is tiny relative to the combined scene AABB.""" + mesh = lagrange.SurfaceMesh() + mesh.vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=float) + mesh.facets = np.array([[0, 1, 2]], dtype=np.uint32) + + scene = lagrange.scene.SimpleScene3D() + mesh_id = scene.add_mesh(mesh) + for x in (0.0, 1_000_000.0): + instance = lagrange.scene.MeshInstance3D() + instance.mesh_index = mesh_id + instance.transform = np.array( + [[1, 0, 0, x], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float + ) + scene.add_instance(instance) + return scene + + +@pytest.fixture +def mixed_scale_triangles_scene(): + """One open mesh with a unit triangle and a much smaller, disjoint triangle.""" + eps = 1e-4 + mesh = lagrange.SurfaceMesh() + mesh.vertices = np.array( + [[0, 0, 0], [1, 0, 0], [0, 1, 0], [2, 0, 0], [2 + eps, 0, 0], [2, eps, 0]], + dtype=float, + ) + mesh.facets = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.uint32) + return lagrange.scene.mesh_to_simple_scene(mesh) + + +@pytest.fixture +def degenerate_triangle_scene(): + """A scene with one zero-area triangle, for no-progress termination tests.""" + mesh = lagrange.SurfaceMesh() + mesh.vertices = np.array([[0, 0, 0], [1, 0, 0], [2, 0, 0]], dtype=float) + mesh.facets = np.array([[0, 1, 2]], dtype=np.uint32) + return lagrange.scene.mesh_to_simple_scene(mesh) + + +def _cube_with_corner_normals(size): + """Centered cube carrying flat per-facet normals as an indexed "normal" attribute.""" + mesh = _centered_cube(size) + v = mesh.vertices + f = mesh.facets + n = np.cross(v[f[:, 1]] - v[f[:, 0]], v[f[:, 2]] - v[f[:, 0]]) + n /= np.linalg.norm(n, axis=1, keepdims=True) + mesh.create_attribute( + "normal", + element=lagrange.AttributeElement.Indexed, + usage=lagrange.AttributeUsage.Normal, + initial_values=n.astype(np.float64), + initial_indices=np.repeat(np.arange(f.shape[0], dtype=np.uint32), 3).reshape(-1, 3), + ) + return mesh + + +@pytest.fixture +def instanced_normals_scene(): + """A few well-separated instances of one normal-bearing cube (none occlude each other).""" + scene = lagrange.scene.SimpleScene3D() + mesh_id = scene.add_mesh(_cube_with_corner_normals(1.0)) + rot_z = np.array([[0, -1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float) + transforms = [np.identity(4)] + for translation in ((10.0, 0.0, 0.0), (0.0, 10.0, 0.0)): + m = rot_z.copy() + m[:3, 3] = translation + transforms.append(m) + for m in transforms: + instance = lagrange.scene.MeshInstance3D() + instance.mesh_index = mesh_id + instance.transform = m + scene.add_instance(instance) + return scene, transforms diff --git a/modules/raycasting/python/tests/test_remove_occluded.py b/modules/raycasting/python/tests/test_remove_occluded.py index a5172edd..2e3e5c6f 100644 --- a/modules/raycasting/python/tests/test_remove_occluded.py +++ b/modules/raycasting/python/tests/test_remove_occluded.py @@ -9,11 +9,18 @@ # OF ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. # +from typing import TypedDict + import lagrange import numpy as np import pytest +class _RayBudget(TypedDict): + num_rays: int + batch_size: int + + class TestRemoveOccluded: def test_remove_occluded_facets_single_cube(self, cube_triangular): """A single cube has no occluded facets — all 12 should remain.""" @@ -22,12 +29,10 @@ def test_remove_occluded_facets_single_cube(self, cube_triangular): assert result.num_meshes == 1 assert result.get_mesh(0).num_facets == 12 - def test_remove_occluded_facets_brute_force(self, cube_triangular): - """Brute-force mode produces the same result on a non-occluded cube.""" + def test_remove_occluded_facets_plain_cosine(self, cube_triangular): + """Explicit plain-cosine mode produces the same result on a non-occluded cube.""" scene = lagrange.scene.mesh_to_simple_scene(cube_triangular) - result = lagrange.raycasting.remove_occluded_facets( - scene, num_rays=100000, brute_force=True - ) + result = lagrange.raycasting.remove_occluded_facets(scene, num_rays=100000, adaptive=False) assert result.get_mesh(0).num_facets == 12 def test_remove_occluded_instances_single_cube(self, cube_triangular): @@ -50,6 +55,25 @@ def test_keyword_only_arguments(self, cube_triangular): with pytest.raises(TypeError): lagrange.raycasting.remove_occluded_facets(scene, 10000) # ty: ignore[too-many-positional-arguments] + def test_facet_until_converged_removed(self, cube_triangular): + scene = lagrange.scene.mesh_to_simple_scene(cube_triangular) + with pytest.raises(TypeError): + lagrange.raycasting.remove_occluded_facets( + scene, + num_rays=10_000, + until_converged=True, # ty: ignore[unknown-argument] + ) + + def test_degenerate_geometry_stops_without_rays(self, degenerate_triangle_scene): + facets = lagrange.raycasting.remove_occluded_facets( + degenerate_triangle_scene, num_rays=10_000, batch_size=1_000 + ) + instances = lagrange.raycasting.remove_occluded_instances( + degenerate_triangle_scene, num_rays=10_000, batch_size=1_000 + ) + assert facets.total_num_instances == 0 + assert instances.total_num_instances == 0 + # --------------------------------------------------------------------------- # Nested cubes — minimal occlusion sanity test. @@ -59,6 +83,17 @@ def test_keyword_only_arguments(self, cube_triangular): class TestNestedCubes: + def test_instance_until_converged_keeps_legacy_api(self, nested_cubes_scene): + """The production instance API keeps convergence mode, now based on retirements.""" + result = lagrange.raycasting.remove_occluded_instances( + nested_cubes_scene, + num_rays=0, + batch_size=20_000, + until_converged=True, + ) + assert result.num_meshes == 1 + assert result.total_num_instances == 1 + def test_estimate_occluded_instances(self, nested_cubes_scene): """Inner cube has zero escape directions → must be reported as occluded.""" occluded = lagrange.raycasting.estimate_occluded_instances( @@ -91,6 +126,69 @@ def test_remove_occluded_facets(self, nested_cubes_scene): assert kept.num_facets == 12 np.testing.assert_allclose(np.abs(kept.vertices).max(), 1.0) + def test_remove_occluded_facets_plain_cosine(self, nested_cubes_scene): + """The adaptive opt-out reaches the same decision using plain cosine sampling.""" + result = lagrange.raycasting.remove_occluded_facets( + nested_cubes_scene, num_rays=2_000_000, batch_size=200_000, adaptive=False + ) + assert result.num_meshes == 1 + kept = result.get_mesh(0) + assert kept.num_facets == 12 + np.testing.assert_allclose(np.abs(kept.vertices).max(), 1.0) + + def test_remove_occluded_facets_adaptive_options(self, nested_cubes_scene): + """Non-default adaptive knobs (num_adaptive_per_cosine, vmf_kappa) are accepted and + still reach the correct decision.""" + result = lagrange.raycasting.remove_occluded_facets( + nested_cubes_scene, + num_rays=2_000_000, + batch_size=200_000, + adaptive=True, + num_adaptive_per_cosine=2, + vmf_kappa=8.0, + ) + assert result.num_meshes == 1 + assert result.get_mesh(0).num_facets == 12 + + +# --------------------------------------------------------------------------- +# Output fidelity — remove_occluded_instances must return the input geometry +# untouched: instance transforms preserved (not baked into vertices) and user +# normals carried through verbatim. +# --------------------------------------------------------------------------- + + +class TestInstanceOutputFidelity: + def test_normals_and_matrices_preserved(self, instanced_normals_scene): + """Well-separated instances are all kept; their transforms and the mesh's + indexed "normal" attribute must round-trip unchanged.""" + scene, transforms = instanced_normals_scene + source = scene.get_mesh(0) + expected_vertices = np.array(source.vertices) + source_normal = source.indexed_attribute("normal") + expected_values = np.array(source_normal.values.data) + expected_indices = np.array(source_normal.indices.data) + + # size_influence=0 keeps every exposed instance regardless of relative size. + result = lagrange.raycasting.remove_occluded_instances( + scene, num_rays=200_000, batch_size=20_000, size_influence=0.0 + ) + + # Instancing preserved: one shared mesh, all instances kept, in order. + assert result.num_meshes == 1 + assert result.total_num_instances == len(transforms) + for k, expected in enumerate(transforms): + np.testing.assert_allclose(result.get_instance(0, k).transform, expected) + + # Geometry not baked and normals carried through verbatim. + kept = result.get_mesh(0) + np.testing.assert_allclose(kept.vertices, expected_vertices) + assert kept.has_attribute("normal") + kept_normal = kept.indexed_attribute("normal") + assert kept_normal.usage == lagrange.AttributeUsage.Normal + np.testing.assert_allclose(kept_normal.values.data, expected_values) + np.testing.assert_array_equal(kept_normal.indices.data, expected_indices) + # --------------------------------------------------------------------------- # Non-occluder instance flag — same nested-cube setup, but mark the OUTER cube @@ -141,3 +239,175 @@ def predicate(mi: int, ii: int) -> bool: nested_cubes_scene, num_rays=10_000, batch_size=5_000, is_occluder=predicate ) assert sorted(calls) == [(0, 0), (1, 0)] + + +# --------------------------------------------------------------------------- +# Estimate-then-filter workflow — the new measure-returning functions let a +# caller re-decide at any threshold <= the sampled one without re-tracing. +# --------------------------------------------------------------------------- + + +def _kept_instances(measures, threshold): + return { + (mi, ii) for mi, row in enumerate(measures) for ii, m in enumerate(row) if m >= threshold + } + + +class TestEstimateMeasures: + def test_instance_remove_matches_measure_threshold(self, far_apart_triangles_scene): + """A derived mean-visibility threshold above one must not be relaxed internally.""" + threshold = 1e-3 + measures = lagrange.raycasting.estimate_occluded_instance_measures( + far_apart_triangles_scene, + num_rays=10_000, + batch_size=10_000, + threshold=threshold, + size_influence=0.5, + ) + assert all(m < threshold for row in measures for m in row) + + removed = lagrange.raycasting.remove_occluded_instances( + far_apart_triangles_scene, + num_rays=10_000, + batch_size=10_000, + threshold=threshold, + size_influence=0.5, + ) + assert removed.total_num_instances == 0 + + def test_facet_remove_matches_measure_threshold(self, mixed_scale_triangles_scene): + """A small visible facet stays removable when its mean-visibility threshold exceeds one.""" + threshold = 1e-3 + estimated = lagrange.raycasting.estimate_occluded_facet_measures( + mixed_scale_triangles_scene, + attribute_name="vis", + num_rays=10_000, + batch_size=10_000, + threshold=threshold, + size_influence=0.5, + ) + measures = np.asarray(estimated.get_mesh(0).attribute("vis").data).ravel() + assert measures[0] >= threshold + assert measures[1] < threshold + + removed = lagrange.raycasting.remove_occluded_facets( + mixed_scale_triangles_scene, + num_rays=10_000, + batch_size=10_000, + threshold=threshold, + size_influence=0.5, + ) + assert removed.get_mesh(0).num_facets == int((measures >= threshold).sum()) == 1 + + def test_instance_measures_shape_and_ordering(self, nested_cubes_scene): + """measures[mesh][instance]: inner cube's measure is strictly below the outer's.""" + measures = lagrange.raycasting.estimate_occluded_instance_measures( + nested_cubes_scene, num_rays=200_000, batch_size=20_000 + ) + assert [len(row) for row in measures] == [1, 1] # one instance per mesh + outer = measures[0][0] + inner = measures[1][0] + assert inner < outer + + def test_instance_measures_monotone_refilter(self, nested_cubes_scene): + """Re-filtering the measures at a higher threshold yields a nested (subset) kept-set; + a threshold between inner and outer culls only the inner instance.""" + measures = lagrange.raycasting.estimate_occluded_instance_measures( + nested_cubes_scene, num_rays=200_000, batch_size=20_000 + ) + outer = measures[0][0] + inner = measures[1][0] + kept_low = _kept_instances(measures, 0.0) + kept_high = _kept_instances(measures, 0.5 * (inner + outer)) + assert kept_high <= kept_low + assert (1, 0) in kept_low + assert (1, 0) not in kept_high + assert (0, 0) in kept_high + + def test_facet_estimate_writes_attribute(self, nested_cubes_scene): + """estimate_occluded_facet_measures returns a de-instanced scene whose meshes carry the named + per-facet measure attribute; the outer facets are all above threshold, the inner below.""" + est = lagrange.raycasting.estimate_occluded_facet_measures( + nested_cubes_scene, + attribute_name="vis", + num_rays=2_000_000, + batch_size=200_000, + ) + assert est.num_meshes == 2 + # Outer mesh (extent 2 → coords reach ±1) vs inner mesh, identified by vertex extent. + threshold = 5e-6 + survivors = 0 + for i in range(est.num_meshes): + mesh = est.get_mesh(i) + assert mesh.has_attribute("vis") + vals = np.asarray(mesh.attribute("vis").data).ravel() + assert len(vals) == mesh.num_facets + survivors += int((vals >= threshold).sum()) + assert survivors == 12 # only the outer cube's 12 facets clear the threshold + + def test_facet_estimate_matches_remove(self, nested_cubes_scene): + """Removing facets below threshold on the estimate reproduces remove_occluded_facets.""" + est = lagrange.raycasting.estimate_occluded_facet_measures( + nested_cubes_scene, + attribute_name="vis", + num_rays=2_000_000, + batch_size=200_000, + ) + removed = lagrange.raycasting.remove_occluded_facets( + nested_cubes_scene, num_rays=2_000_000, batch_size=200_000 + ) + survivors = sum( + int((np.asarray(est.get_mesh(i).attribute("vis").data) >= 5e-6).sum()) + for i in range(est.num_meshes) + ) + removed_total = sum(removed.get_mesh(i).num_facets for i in range(removed.num_meshes)) + assert survivors == removed_total + + +class TestFacetInstancingPolicy: + """Facet de-instancing over a mesh shared by an occluded and a visible instance.""" + + _RAYS: _RayBudget = {"num_rays": 2_000_000, "batch_size": 200_000} + + def test_flatten_splits_shared_mesh(self, shared_mesh_occlusion_scene): + """FlattenInstances splits the shared mesh: exposed survives, enclosed is dropped.""" + policy = lagrange.raycasting.InstancingPolicy.FlattenInstances + result = lagrange.raycasting.remove_occluded_facets( + shared_mesh_occlusion_scene, instancing=policy, **self._RAYS + ) + assert result.num_meshes == 2 + assert result.total_num_instances == 2 + + def test_max_keeps_when_any_instance_visible(self, shared_mesh_occlusion_scene): + """Max keeps the shared mesh: a facet visible in the exposed instance survives for both.""" + policy = lagrange.raycasting.InstancingPolicy.Max + result = lagrange.raycasting.remove_occluded_facets( + shared_mesh_occlusion_scene, instancing=policy, **self._RAYS + ) + assert result.num_meshes == 2 + assert result.total_num_instances == 3 + assert result.get_mesh(1).num_facets == 12 + + def test_average_preserves_instancing(self, shared_mesh_occlusion_scene): + """Average keeps the shared mesh above threshold and preserves both of its instances.""" + policy = lagrange.raycasting.InstancingPolicy.Average + result = lagrange.raycasting.remove_occluded_facets( + shared_mesh_occlusion_scene, instancing=policy, **self._RAYS + ) + assert result.num_meshes == 2 + assert result.total_num_instances == 3 + assert result.get_mesh(1).num_facets == 12 + + def test_estimate_average_writes_shared_attribute(self, shared_mesh_occlusion_scene): + """Average estimate keeps instancing; writes the aggregated measure on the shared mesh.""" + policy = lagrange.raycasting.InstancingPolicy.Average + est = lagrange.raycasting.estimate_occluded_facet_measures( + shared_mesh_occlusion_scene, attribute_name="vis", instancing=policy, **self._RAYS + ) + assert est.num_meshes == 2 + assert est.total_num_instances == 3 + small = est.get_mesh(1) + assert small.has_attribute("vis") + vals = np.asarray(small.attribute("vis").data).ravel() + assert len(vals) == small.num_facets + assert (vals > 0).all() diff --git a/modules/raycasting/src/occluded_sampler_common.h b/modules/raycasting/src/occluded_sampler_common.h index 7202abb0..5a8c908a 100644 --- a/modules/raycasting/src/occluded_sampler_common.h +++ b/modules/raycasting/src/occluded_sampler_common.h @@ -15,7 +15,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -23,26 +25,91 @@ #include #include +#include +#include #include #include #include #include #include +#include #include #include #include namespace lagrange::raycasting::detail { -enum class SamplingMode { - /// Cosine-weighted hemisphere; records each facet's escape direction. - Normal, - /// Casts jittered rays along escape directions of edge-adjacent visible neighbors. Skips - /// facets that have none. - Adaptive, - /// Cosine-weighted hemisphere with no escape recording — baseline for benchmarking. - BruteForce, -}; +/// Compute the upper-tail binomial p-value P(X >= n) for X ~ Binomial(num_trials, p). +/// +/// @param[in] n Number of observed successes. +/// @param[in] num_trials Number of Bernoulli trials. +/// @param[in] p Null-hypothesis success probability in [0, 1). +/// @return The probability of observing at least @p n successes under the null hypothesis. +/// +/// The complement-of-lower-terms implementation is cheap when @p n is small, but is not intended +/// for tiny p-values (~1e-12) where cancellation in `1 - cdf` loses precision. +inline double binomial_upper_tail(uint64_t n, uint64_t num_trials, double p) +{ + if (n == 0) return 1.0; + if (n > num_trials) return 0.0; + const double q = 1.0 - p; + double pmf = std::pow(q, static_cast(num_trials)); // pmf(0) + double cdf = pmf; // P(X <= 0) + for (uint64_t k = 1; k < n; ++k) { + pmf *= static_cast(num_trials - k + 1) / static_cast(k) * (p / q); + cdf += pmf; + } + return 1.0 - cdf; // P(X >= n) = 1 - P(X <= n-1) +} + +/// One-sided test "is the true escape mean confidently above `threshold`?" from `n` escapes in +/// `num_trials` rays at significance `alpha`. O(1) amortized: a point-estimate pre-gate rejects +/// the bulk, exact binomial for small `num_trials`, normal approximation (erfc, no pow underflow) +/// for large `num_trials`. +inline bool is_confidently_visible(uint64_t n, uint64_t num_trials, double threshold, double alpha) +{ + if (n == 0 || num_trials == 0) return false; + if (threshold <= 0.0) return true; // any escape clears a zero threshold + if (threshold >= 1.0) return false; // a bounded Bernoulli mean cannot be strictly above 1 + if (static_cast(n) <= static_cast(num_trials) * threshold) return false; + + constexpr uint64_t exact_cap = 1000; + if (num_trials <= exact_cap) return binomial_upper_tail(n, num_trials, threshold) <= alpha; + + const double variance = static_cast(num_trials) * threshold * (1.0 - threshold); + const double z = (static_cast(n) - 0.5 - static_cast(num_trials) * threshold) / + std::sqrt(variance); + return 0.5 * std::erfc(z / std::sqrt(2.0)) <= alpha; +} + +/// Anytime-valid betting test helpers for a bounded mean, following Waudby-Smith & Ramdas (2024), +/// Section 4 and Supplement B: https://doi.org/10.1093/jrsssb/qkad009. +namespace betting { + +// Choose a predictable betting fraction and clamp it so every wealth factor remains positive. +[[nodiscard]] constexpr double compute_lambda(double running_mean, double running_var, double y0) +{ + const double lambda = (running_mean - y0) / std::max(running_var, 1e-9); + return std::clamp(lambda, 0.0, 0.5 / std::max(y0, 1e-9)); +} + +// Return the log-wealth increment from one observation; blocked rays pass `y = 0`. +[[nodiscard]] inline double logwealth_step(double lambda, double y, double y0) +{ + return std::log1p(lambda * (y - y0)); +} + +} // namespace betting + +template +double scene_aabb_surface_area(const scene::SimpleScene& scene) +{ + const auto bbox = scene::simple_scene_bbox(scene); + if (bbox.isEmpty()) return 0.0; + const Eigen::Vector3 d = bbox.sizes().cwiseMax(Scalar(0)); + return 2.0 * (static_cast(d.x()) * d.y() + static_cast(d.y()) * d.z() + + static_cast(d.x()) * d.z()); +} // See https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/ template @@ -52,11 +119,10 @@ Eigen::Array4 generate_vec4(const size_t i) constexpr double phi_2 = phi_1 * phi_1; constexpr double phi_3 = phi_2 * phi_1; constexpr double phi_4 = phi_2 * phi_2; + const Eigen::Array4d phi(phi_1, phi_2, phi_3, phi_4); // +0.5 avoids the degenerate (0,0,0,0) sample at i=0. - const Eigen::Array4 shifted = - (static_cast(i) / Eigen::Array4d(phi_1, phi_2, phi_3, phi_4)).cast() + - Scalar(0.5); - return shifted - shifted.floor(); + const Eigen::Array4d shifted = static_cast(i) / phi + 0.5; + return (shifted - shifted.floor()).template cast(); } template @@ -93,7 +159,7 @@ struct RaySampler /// Bi-hemisphere sample via Malley's method: when bary reflection fires (u+v > 1), the /// direction is also negated, giving symmetric upper/lower coverage from one 4D draw. - Sample operator()() + Sample sample_cosine() { const auto vec4 = generate_vec4(m_sample_index.fetch_add(1, std::memory_order_relaxed)); @@ -114,19 +180,28 @@ struct RaySampler return result; } - Sample jitter(Scalar jitter_sigma) + /// Sample a direction from a von Mises-Fisher lobe (mean `axis`, concentration `kappa`) on the + /// full sphere, plus a folded barycentric point. + Sample sample_vmf(const Eigen::RowVector3& axis, Scalar kappa) { - auto vec4 = generate_vec4(m_sample_index.fetch_add(1, std::memory_order_relaxed)); - // Box-Muller via (1 - u1) so that u1 == 0 (at index 0) maps to r == 0, not log(0). - const Scalar r = jitter_sigma * std::sqrt(-2 * std::log(1 - vec4[0])); + const auto vec4 = + generate_vec4(m_sample_index.fetch_add(1, std::memory_order_relaxed)); + const double kappa_d = static_cast(kappa); + const double u = static_cast(vec4[0]); + const double arg = u + (1.0 - u) * std::exp(-2.0 * kappa_d); + const Scalar w = static_cast(1.0 + std::log(arg) / kappa_d); + const Scalar s = std::sqrt(std::max(Scalar(0), Scalar(1) - w * w)); const Scalar phi = 2 * static_cast(lagrange::internal::pi) * vec4[1]; + Eigen::Vector3 e1, e2; + lagrange::orthogonal_frame(Eigen::Vector3(axis.transpose()), e1, e2); Sample result; - result.direction = r * (std::cos(phi) * m_x_axis + std::sin(phi) * m_y_axis); + result.direction = + w * axis + s * (std::cos(phi) * e1.transpose() + std::sin(phi) * e2.transpose()); result.bary = vec4.template tail<2>(); if (result.bary[0] + result.bary[1] > 1) { - // Plain bary fold — direction is a relative offset here, no hemisphere to flip. result.bary = Eigen::RowVector2::Ones() - result.bary; } + la_debug_assert(result.direction.allFinite(), "sample_vmf: non-finite direction"); return result; } @@ -137,19 +212,23 @@ struct RaySampler std::atomic m_sample_index{0}; }; -/// World-space position at barycentric (u, v) of facet `f`; third weight is `1 - u - v`. -/// `vertices` is column-major (3 × num_vertices). -template -Eigen::RowVector3 barycentric_position( - const Eigen::RowVector2& bary, - const Vertices& vertices, - const Facets& facets, - Index f) +/// Precomputed world-space facet triangle for O(1) ray origins: `base = V2`, `edge0 = V0 - V2`, +/// `edge1 = V1 - V2`, so a barycentric `(b0, b1)` maps to `base + b0*edge0 + b1*edge1`. +template +struct FacetTriangle +{ + Eigen::Vector3 base; + Eigen::Vector3 edge0; + Eigen::Vector3 edge1; +}; + +/// World-space ray origin at barycentric `bary` from a precomputed triangle (no vertex gather). +template +Eigen::RowVector3 triangle_position( + const FacetTriangle& t, + const Eigen::RowVector2& bary) { - const Scalar b2 = Scalar(1) - bary(0) - bary(1); - return (bary(0) * vertices.col(facets(f, 0)) + bary(1) * vertices.col(facets(f, 1)) + - b2 * vertices.col(facets(f, 2))) - .transpose(); + return (t.base + bary(0) * t.edge0 + bary(1) * t.edge1).transpose(); } /// 16-ray SIMD packet for `RayCaster::occluded16`. `push` widens to float on entry. @@ -185,50 +264,122 @@ struct RayPacket16 }; /// Per-instance precomputation shared by both samplers. -template +template struct InstanceData { - Index mesh_index; - Eigen::Transform transform; std::vector facet_areas; // world-space std::vector> ray_samplers; // world-space normals + std::vector> facet_triangles; // world-space, for ray origins +}; + +/// Fill the world-space geometry shared by both samplers. `world_vertices` is caller-owned scratch +/// so scenes with many instances reuse its allocation. Returns the instance's total surface area. +template +double initialize_instance_geometry( + InstanceData& instance, + const Eigen::Transform& transform, + const SurfaceMesh& mesh, + std::vector>& world_vertices) +{ + const Index num_facets = mesh.get_num_facets(); + instance.facet_areas.resize(num_facets); + instance.facet_triangles.resize(num_facets); + instance.ray_samplers.clear(); + instance.ray_samplers.reserve(num_facets); + + const auto vertices = vertex_view(mesh); + const auto facets = facet_view(mesh); + world_vertices.resize(mesh.get_num_vertices()); + for (auto v : lagrange::range(mesh.get_num_vertices())) { + world_vertices[v] = transform * Eigen::Vector3(vertices.row(v).transpose()); + } + + double total_area = 0.0; + for (auto f : lagrange::range(num_facets)) { + const auto& v0 = world_vertices[facets(f, 0)]; + const auto& v1 = world_vertices[facets(f, 1)]; + const auto& v2 = world_vertices[facets(f, 2)]; + const Eigen::Vector3 edge0 = v0 - v2; + const Eigen::Vector3 edge1 = v1 - v2; + instance.facet_triangles[f] = {v2, edge0, edge1}; + + const Eigen::Vector3 area_vector = Scalar(0.5) * edge0.cross(edge1); + const Scalar area = area_vector.norm(); + instance.facet_areas[f] = area; + total_area += static_cast(area); + // A placeholder normal keeps all per-facet arrays aligned for degenerate geometry. + instance.ray_samplers.emplace_back( + area > 0 ? Eigen::RowVector3(area_vector.transpose() / area) + : Eigen::RowVector3::UnitZ()); + } + return total_area; +} + +/// Bounded set of escape-direction "seeds" for a facet, feeding the adaptive vMF-mixture proposal. +/// Each seed is a running-mean unit direction with an integer hit-count weight. `add()` merges a +/// new escape into the nearest existing seed (cosine >= `merge_cos`), else fills a free slot, else +/// evicts the lowest-weight slot — so a few dominant holes survive while noise churns through. +template +struct SeedReservoir +{ + static constexpr int capacity = 4; + std::array, capacity> dir{}; + std::array weight{}; // 0 => empty slot + int size = 0; + + void add(const Eigen::Vector3& d, Scalar merge_cos) + { + int best = -1; + Scalar best_cos = merge_cos; + for (int i = 0; i < size; ++i) { + const Scalar c = dir[i].dot(d); + if (c >= best_cos) { + best_cos = c; + best = i; + } + } + if (best >= 0) { + weight[best] += 1; + dir[best] = (dir[best] + (d - dir[best]) / weight[best]).normalized(); // running mean + } else if (size < capacity) { + dir[size] = d; + weight[size] = 1; + ++size; + } else { + int lo = 0; + for (int i = 1; i < capacity; ++i) + if (weight[i] < weight[lo]) lo = i; + dir[lo] = d; + weight[lo] = 1; + } + } }; /// Per-instance data for OccludedFacetSampler; gates each facet individually. template -struct FacetInstanceData : InstanceData +struct FacetInstanceData : InstanceData { - /// Per-facet ray-distribution weight = cbrt(area). cbrt softens per-facet because each - /// facet is its own Bernoulli trial. + /// Per-facet ray-allocation weight ∝ 1/tau_f = (area / mean_face_area)^size_influence. Rays + /// track statistical difficulty: low-tau (large) facets need the most to resolve their test. std::vector facet_weights; std::vector active_local_facets; - /// 1-ring edge-neighbors (mesh dual graph). std::optional because AdjacencyList isn't - /// default-constructible. std::optional> facet_neighbors; - /// World-space unit-length escape direction recorded the first time each facet becomes - /// visible; std::nullopt means "not yet visible". Adaptive reads from - /// `facet_escape_snapshot` instead so live writes here don't race. - std::vector>> facet_escape_directions; - /// Pre-batch snapshot of `facet_escape_directions`. Read by Adaptive; refreshed at the - /// start of each `process_instance`. Kept as a member to reuse the allocation. - std::vector>> facet_escape_snapshot; + /// Per-facet escape-direction seeds, only updated in flush() from snapshot (to avoid race condition). + std::vector> facet_seeds; + std::vector> facet_seeds_snapshot; }; -template +template struct ImplBase { - scene::SimpleScene m_scene; RayCaster m_ray_caster; Derived& derived() { return static_cast(*this); } const Derived& derived() const { return static_cast(*this); } size_t num_instances() const { return derived().m_instances.size(); } - Index mesh_index(size_t i) const { return derived().m_instances[i].mesh_index; } - const auto& instance_transform(size_t i) const { return derived().m_instances[i].transform; } - Scalar compute_total_active_weight() const { const auto r = lagrange::range(num_instances()); @@ -252,13 +403,7 @@ struct ImplBase std::round(static_cast(num_rays) * inst_weight / total)); if (inst_rays == 0) continue; - const auto& mesh = m_scene.get_mesh(mesh_index(i)); - const auto vertices = - (instance_transform(i) * vertex_view(mesh).transpose().template topRows<3>()) - .eval(); - const auto facets = facet_view(mesh); - - derived().process_instance(i, inst_rays, inst_weight, vertices, facets); + derived().process_instance(i, inst_rays, inst_weight); } derived().end_batch(); diff --git a/modules/raycasting/src/remove_occluded_facets.cpp b/modules/raycasting/src/remove_occluded_facets.cpp index 266348e1..8e79c591 100644 --- a/modules/raycasting/src/remove_occluded_facets.cpp +++ b/modules/raycasting/src/remove_occluded_facets.cpp @@ -14,14 +14,13 @@ #include "occluded_sampler_common.h" +#include #include #include -#include #include #include #include #include -#include #include #include @@ -29,81 +28,377 @@ #include #include #include +#include #include +#include +#include namespace lagrange::raycasting { using namespace detail; +namespace { + +// 1/phi: additive low-discrepancy sequence for stratified seed selection (no needed). +constexpr double k_golden = 0.6180339887498949; + +} // namespace + +namespace internal { + template struct OccludedFacetSampler::Impl - : ImplBase::Impl, Scalar, Index> + : ImplBase::Impl, Scalar> { using InstanceInfo = typename OccludedFacetSampler::InstanceInfo; - /// Seed for an Adaptive ray cast: a visible neighbor and its cached escape direction. - /// Only the direction is borrowed — rays still originate on the facet under test so the - /// K confirmations actually attest to its visibility. - struct AdaptiveCandidate + /// Per-facet Monte-Carlo statistics. + struct FacetStats { - Index neighbor; - Eigen::Vector3 direction; + double sum_x = 0.0; + double log_wealth = 0.0; + + /// Fold one visibility contribution and its betting log-wealth increment. + void record(double x, double log_wealth_step) + { + sum_x += x; + log_wealth += log_wealth_step; + } + + /// Roll a batch's `staging` stats into the cumulative total, resetting them. + void merge(FacetStats& staging) + { + sum_x += staging.sum_x; + log_wealth += staging.log_wealth; + staging.sum_x = 0.0; + staging.log_wealth = 0.0; + } }; - explicit Impl(uint64_t total_facets) - : m_num_escaped_rays_per_facet(total_facets) - , m_staging_escaped_rays_per_facet(total_facets) + /// Anytime-valid retire-KEEP process shared by plain cosine and adaptive MIS sampling. + struct BettingPolicy + { + double neg_log_alpha = -std::log(0.01); + /// Predictable betting fraction, frozen from pre-batch statistics. + std::vector facet_lambda; + + BettingPolicy() = default; + BettingPolicy(double alpha, uint64_t total_facets) + : neg_log_alpha(-std::log(alpha)) + , facet_lambda(total_facets, 0.0) + {} + + void freeze(uint64_t global_f, uint64_t num_rays, double mean_y, double var_y, double y0) + { + if (y0 <= 0.0 || y0 >= 1.0) { + facet_lambda[global_f] = 0.0; + return; + } + // A non-zero predictable prior lets clearly visible facets retire in the first batch. + facet_lambda[global_f] = + num_rays == 0 ? 0.5 : betting::compute_lambda(mean_y, var_y, y0); + } + + double log_step(uint64_t global_f, double y, double y0) const + { + return betting::logwealth_step(facet_lambda[global_f], y, y0); + } + + std::array bernoulli_log_steps(uint64_t global_f, double y0) const + { + return {log_step(global_f, 0.0, y0), log_step(global_f, 1.0, y0)}; + } + + bool retired(const FacetStats& stat, const FacetStats& staging) const + { + return stat.log_wealth + staging.log_wealth >= neg_log_alpha; + } + }; + + /// Adaptive-only sampling, MIS, seed, and second-moment state. Betting is shared separately. + struct AdaptiveSamplingPolicy + { + /// One component of the vMF-mixture proposal: a seed mean direction + its hit weight. + struct Seed + { + Eigen::Vector3 dir; + Scalar weight; + }; + + size_t k = 4; // seed-guided rays per cosine ray (K) + double kappa = 16.0; // vMF proposal concentration + double vmf_ratio_norm = 0.0; // vMF/cosine pdf-ratio norm, the 2pi cancels + double merge_cos = 1.0; // seed-merge threshold + std::vector sum_x2; + std::vector staging_sum_x2; + + AdaptiveSamplingPolicy() = default; + + /// Derive adaptive proposal quantities and allocate adaptive-only second moments. + AdaptiveSamplingPolicy(const AdaptiveOptions& options, uint64_t total_facets) + : k(options.num_adaptive_per_cosine) + , kappa(options.vmf_kappa) + , vmf_ratio_norm(kappa / (1.0 - std::exp(-2.0 * kappa))) + , merge_cos(std::cos(1.0 / std::sqrt(kappa))) + , sum_x2(total_facets, 0.0) + , staging_sum_x2(total_facets, 0.0) + {} + + double contribution_bound() const { return static_cast(k + 1); } + + /// Collect facet's own and edge-neighbours' snapshot seeds into `out`. + void gather_seeds( + const FacetInstanceData& inst, + Index lf, + std::vector& out) const + { + out.clear(); + auto append = [&](Index f) { + const auto& res = inst.facet_seeds_snapshot[f]; + for (int i = 0; i < res.size; ++i) out.push_back({res.dir[i], res.weight[i]}); + }; + append(lf); + for (Index nb : inst.facet_neighbors->get_neighbors(lf)) append(nb); + } + + /// Snapshot escape directions before a batch; sampling reads the snapshot while flush() + /// writes the live reservoirs, avoiding races and within-batch proposal adaptation. + void snapshot_seeds(FacetInstanceData& inst) + { + inst.facet_seeds_snapshot = inst.facet_seeds; + } + + void record_second_moment(uint64_t global_f, double x) + { + staging_sum_x2[global_f] += x * x; + } + + void merge_second_moment(uint64_t global_f) + { + sum_x2[global_f] += staging_sum_x2[global_f]; + staging_sum_x2[global_f] = 0.0; + } + + /// One proposal sample: 1:K cosine:seed-guided interleave, phased on the facet's cumulative + /// ray count `ordinal` so tiny per-batch budgets still reach the 1:K ratio over its life. + typename RaySampler::Sample draw( + RaySampler& sampler, + uint64_t ordinal, + const std::vector& seeds, + Scalar seed_weight_total) const + { + if (seed_weight_total > 0 && ordinal % (k + 1) != 0) { + // Pick a component ∝ weight via a golden-ratio scalar, sample its vMF lobe. + double u = static_cast(ordinal) * k_golden; + u -= std::floor(u); + double t = u * static_cast(seed_weight_total); + const Eigen::Vector3* mu = &seeds.back().dir; + for (const auto& sd : seeds) { + t -= static_cast(sd.weight); + if (t < 0.0) { + mu = &sd.dir; + break; + } + } + return sampler.sample_vmf(mu->transpose(), static_cast(kappa)); + } + return sampler.sample_cosine(); + } + + /// MIS contribution X for an escaped ray, evaluated only against the frozen proposal seeds. + double mis_contribution( + const FacetInstanceData& inst, + Index local_f, + const Eigen::RowVector3& dir, + const std::vector& seeds) const + { + const double cos_theta = + std::abs(static_cast(dir.dot(inst.ray_samplers[local_f].get_normal()))); + if (cos_theta <= 0.0) return 0.0; // grazing escape: cosine pdf is 0 + if (seeds.empty()) return 1.0; // pure-cosine draw (no vMF component) + + // vMF-mixture density (1/W) Σ w_s · vmf(dir·dir_s) over own + neighbours' snapshot seeds. + double num = 0.0, total_w = 0.0; + for (const auto& sd : seeds) { + num += static_cast(sd.weight) * + std::exp(kappa * (static_cast(dir.dot(sd.dir)) - 1.0)); + total_w += static_cast(sd.weight); + } + // MIS weight X = B / (1 + K·p2/p1); the shared 1/(2pi) sphere-pdf normalizer cancels. + const double p2_over_p1 = vmf_ratio_norm * num / (total_w * cos_theta); + return contribution_bound() / (1.0 + static_cast(k) * p2_over_p1); + } + + /// Save an escape for proposal construction in the next batch. + void record_escape_direction( + FacetInstanceData& inst, + Index local_f, + const Eigen::RowVector3& dir) const + { + inst.facet_seeds[local_f].add(dir.transpose(), static_cast(merge_cos)); + } + }; + + struct AdaptiveScratch + { + std::vector seeds_draw; + std::vector seeds_flush; + }; + + explicit Impl(uint64_t total_facets, bool adaptive) + : m_stats(total_facets) + , m_staging(total_facets) , m_num_rays_cast(total_facets) + , m_facet_tau(total_facets, 0.0) + , m_facet_size_factor(total_facets, 0.0) + , m_adaptive(adaptive) {} std::vector> m_instances; std::vector m_instance_infos; // parallel to m_instances; for the public API - /// Cumulative escape tally across completed batches; visible iff value >= threshold. - /// Saturates at 255. - std::vector m_num_escaped_rays_per_facet; - /// Per-batch staging. Each chunk owns its facets exclusively so writes don't race. - /// Merged into m_num_escaped_rays_per_facet in end_batch(). - std::vector m_staging_escaped_rays_per_facet; + /// Cumulative per-facet statistics; mean visibility = sum_x / num_rays_cast. + std::vector m_stats; + /// Per-batch staging; each chunk owns its facets exclusively (no write races). Merged in end_batch(). + std::vector m_staging; std::vector m_num_rays_cast; - Scalar m_jitter_sigma; - uint8_t m_visibility_threshold; - SamplingMode m_current_mode = SamplingMode::Normal; + /// Per-facet mean-visibility threshold + /// tau_f = threshold * (mean_face_area / area_f)^size_influence, uncapped. + std::vector m_facet_tau; + /// Per-facet (area_f / mean_face_area)^size_influence, uncapped; the size term in + /// visibility_measure(). + std::vector m_facet_size_factor; + /// Public keep threshold applied to visibility_measure(). + double m_threshold = 0.0; + /// Adaptive: MIS cosine + seed-guided mixture. Off: plain cosine. Both use betting retirement. + bool m_adaptive = false; + BettingPolicy m_betting_policy; + /// Adaptive-only proposal, seed, MIS, and second-moment state. + AdaptiveSamplingPolicy m_adaptive_policy; + /// Per-worker adaptive scratch; vector capacity is retained across chunks and batches. + tbb::enumerable_thread_specific m_adaptive_scratch; /// Sum of `facet_weights[lf]` over each instance's active facets; refreshed in end_batch(). std::vector m_instance_active_weights; - /// Per-thread scratch for Adaptive candidates; reused across batches to avoid realloc. - tbb::enumerable_thread_specific> m_tls_candidates; + /// Reused ray-allocation scratch; process_instance() is called sequentially across instances. + std::vector m_ray_budgets; + std::vector m_chunk_ends; + uint64_t m_num_degenerate_facets = 0; Scalar instance_active_weight(size_t i) const { return m_instance_active_weights[i]; } - template - void process_instance( - size_t i, - uint64_t instance_rays, - Scalar instance_weight, - const Vertices& vertices, - const Facets& facets) + uint64_t num_retired() const + { + const uint64_t num_active = std::accumulate( + m_instances.begin(), + m_instances.end(), + uint64_t{0}, + [](uint64_t count, const auto& inst) { + return count + inst.active_local_facets.size(); + }); + return m_stats.size() - num_active - m_num_degenerate_facets; + } + + template + double betting_bound() const + { + if constexpr (Adaptive) + return m_adaptive_policy.contribution_bound(); + else + return 1.0; + } + + /// Freeze each active facet's predictable betting fraction from completed prior batches. + template + void freeze_betting(const FacetInstanceData& inst, const InstanceInfo& info) + { + const double B = betting_bound(); + const double inv_B = 1.0 / B; + for (Index lf : inst.active_local_facets) { + const uint64_t global_f = info.facet_offset + lf; + const uint64_t n = m_num_rays_cast[global_f]; + const double mean_x = n > 0 ? m_stats[global_f].sum_x / static_cast(n) : 0.0; + double var_x = mean_x * (1.0 - mean_x); + if constexpr (Adaptive) { + if (n > 0) { + var_x = std::max( + 0.0, + m_adaptive_policy.sum_x2[global_f] / static_cast(n) - + mean_x * mean_x); + } + } + m_betting_policy.freeze( + global_f, + n, + mean_x * inv_B, + var_x * inv_B * inv_B, + m_facet_tau[global_f] * inv_B); + } + } + + /// Anytime-valid retire-KEEP test. The point-estimate gate keeps retirement consistent with + /// the public keep predicate; queued but unflushed rays count as zero (conservative). + template + bool facet_retired(uint64_t global_f, uint64_t extra_rays) const + { + const double B = betting_bound(); + const double sum_x = m_stats[global_f].sum_x + m_staging[global_f].sum_x; + if (m_facet_tau[global_f] <= 0.0) return true; // public keep predicate is measure >= 0 + if (m_facet_tau[global_f] >= B) return false; + const uint64_t num_rays = m_num_rays_cast[global_f] + extra_rays; + if (sum_x <= static_cast(num_rays) * m_facet_tau[global_f]) return false; + return m_betting_policy.retired(m_stats[global_f], m_staging[global_f]); + } + + /// Plain or MIS estimate of the facet's cosine-weighted escaped fraction; 0 before sampling. + double mean_visibility(uint64_t global_f) const + { + const uint64_t cast = m_num_rays_cast[global_f]; + if (cast == 0) return 0.0; + return m_stats[global_f].sum_x / static_cast(cast); + } + + /// Size-weighted visibility measure: mean_visibility * (area_f / mean_face_area)^size_influence. + double visibility_measure(uint64_t global_f) const + { + return mean_visibility(global_f) * m_facet_size_factor[global_f]; + } + + /// Point-estimate keep decision on the public visibility measure. + bool kept(uint64_t global_f) const { return visibility_measure(global_f) >= m_threshold; } + + // Resolve the runtime mode to a template arg so the per-ray loop carries no mode branch. + void process_instance(size_t i, uint64_t rays, Scalar weight) + { + if (m_adaptive) + process_instance(i, rays, weight); + else + process_instance(i, rays, weight); + } + + template + void process_instance(size_t i, uint64_t instance_rays, Scalar instance_weight) { auto& inst = m_instances[i]; const auto& info = m_instance_infos[i]; const auto& active = inst.active_local_facets; - const size_t N = active.size(); - if (N == 0 || instance_weight <= 0 || instance_rays == 0) return; - - // Adaptive skips facets with no visible neighbor; widen the chunk target to keep - // packets full. - const uint64_t target_rays_per_chunk = m_current_mode == SamplingMode::Adaptive ? 64 : 32; - - // Cumulative-sum integer ray allocation with a 1-ray-per-facet baseline. Chunks (the - // TBB parallel unit) group consecutive facets so no two threads share a facet. - std::vector budgets(N); - std::vector chunk_ends; + const size_t num_active_facets = active.size(); + if (num_active_facets == 0 || instance_weight <= 0 || instance_rays == 0) return; + + // Cumulative-sum integer ray allocation, 1-ray-per-facet baseline. Chunks (the TBB unit) + // group consecutive facets so no two threads share one. + constexpr uint64_t target_rays_per_chunk = 32; + auto& budgets = m_ray_budgets; + auto& chunk_ends = m_chunk_ends; + budgets.resize(num_active_facets); + chunk_ends.clear(); + chunk_ends.reserve(num_active_facets); { - const uint64_t extras = instance_rays > N ? instance_rays - N : 0; + const uint64_t extras = + instance_rays > num_active_facets ? instance_rays - num_active_facets : 0; const double rays_per_weight = static_cast(extras) / instance_weight; double cumsum = 0; uint64_t allocated = 0; uint64_t chunk_rays = 0; - for (auto k : lagrange::range(N)) { + for (auto k : lagrange::range(num_active_facets)) { cumsum += inst.facet_weights[active[k]] * rays_per_weight; const uint64_t target = static_cast(std::llround(cumsum)); budgets[k] = 1 + (target - allocated); @@ -114,13 +409,10 @@ struct OccludedFacetSampler::Impl chunk_rays = 0; } } - if (chunk_ends.empty() || chunk_ends.back() < N) chunk_ends.push_back(N); + if (chunk_ends.empty() || chunk_ends.back() < num_active_facets) + chunk_ends.push_back(num_active_facets); } - // Adaptive reads from the snapshot while flush() writes to the live array — no race. - // operator= reuses the existing allocation (no realloc after the first batch). - inst.facet_escape_snapshot = inst.facet_escape_directions; - la_debug_assert( [&] { std::vector sorted(active.begin(), active.end()); @@ -129,79 +421,103 @@ struct OccludedFacetSampler::Impl }(), "active_local_facets must contain unique indices"); + freeze_betting(inst, info); + if constexpr (Adaptive) m_adaptive_policy.snapshot_seeds(inst); + tbb::parallel_for(size_t(0), chunk_ends.size(), [&](size_t chunk_i) { const size_t chunk_begin = chunk_i == 0 ? 0 : chunk_ends[chunk_i - 1]; const size_t chunk_end = chunk_ends[chunk_i]; RayPacket16 packet; std::array slot_k; // chunk-local facet per slot + [[maybe_unused]] std::array, RayPacket16::capacity> + slot_betting_steps; + [[maybe_unused]] AdaptiveScratch* scratch = nullptr; + if constexpr (Adaptive) scratch = &m_adaptive_scratch.local(); + [[maybe_unused]] size_t seeds_flush_k = std::numeric_limits::max(); + [[maybe_unused]] double inv_contribution_bound = 1.0; + if constexpr (Adaptive) + inv_contribution_bound = 1.0 / m_adaptive_policy.contribution_bound(); auto flush = [&]() { if (packet.empty()) return; const uint32_t mask = packet.cast(this->m_ray_caster); for (auto j : lagrange::range(packet.count)) { - if ((mask & (1u << j)) != 0) continue; + const bool escaped = (mask & (1u << j)) == 0; const Index local_f = active[slot_k[j]]; const uint64_t global_f = info.facet_offset + local_f; - const int prev = m_num_escaped_rays_per_facet[global_f] + - m_staging_escaped_rays_per_facet[global_f]; - if (prev >= 255) continue; // saturated - ++m_staging_escaped_rays_per_facet[global_f]; - if (prev == 0 && m_current_mode != SamplingMode::BruteForce) { - // First escape: cache direction for neighbors' adaptive batches. - inst.facet_escape_directions[local_f] = - packet.directions.row(j).template cast().transpose(); + if constexpr (Adaptive) { + auto& ap = m_adaptive_policy; + if (escaped && seeds_flush_k != slot_k[j]) { + ap.gather_seeds(inst, local_f, scratch->seeds_flush); + seeds_flush_k = slot_k[j]; + } + // Evaluate against the frozen proposal, then save the escape for the next + // batch. Blocked rays contribute zero and do not update the reservoir. + double x = 0.0; + if (escaped) { + const Eigen::RowVector3 direction = + packet.directions.row(j).template cast(); + x = ap.mis_contribution(inst, local_f, direction, scratch->seeds_flush); + ap.record_escape_direction(inst, local_f, direction); + } + // Betting update on Y = X/B (blocked rays pass Y = 0). Single writer/facet. + m_staging[global_f].record( + x, + m_betting_policy.log_step( + global_f, + x * inv_contribution_bound, + m_facet_tau[global_f] * inv_contribution_bound)); + ap.record_second_moment(global_f, x); + } else { + const double x = escaped ? 1.0 : 0.0; + m_staging[global_f].record(x, slot_betting_steps[j][escaped]); } } packet.clear(); }; - auto& candidates = m_tls_candidates.local(); - for (auto k : lagrange::range(chunk_begin, chunk_end)) { const Index local_f = active[k]; const uint64_t global_f = info.facet_offset + local_f; + uint64_t rays_done = 0; - candidates.clear(); - if (m_current_mode == SamplingMode::Adaptive) { - for (Index neighbor : inst.facet_neighbors->get_neighbors(local_f)) { - if (const auto& d = inst.facet_escape_snapshot[neighbor]) { - candidates.push_back({neighbor, *d}); - } - } - if (candidates.empty()) continue; + // Plain Bernoulli betting has only two possible increments. Compute them once per + // facet visit and copy them into each packet slot, avoiding a logarithm per ray. + [[maybe_unused]] std::array betting_steps; + if constexpr (!Adaptive) { + betting_steps = + m_betting_policy.bernoulli_log_steps(global_f, m_facet_tau[global_f]); } - la_debug_assert(m_current_mode == SamplingMode::Adaptive || candidates.empty()); - size_t ray_counter = 0; - uint64_t rays_done = 0; - for ([[maybe_unused]] auto r : lagrange::range(budgets[k])) { - // Stop once the facet crossed K — catches both intra-budget flushes and - // flushes from later iterations that drained leftover rays of this facet. - if (m_num_escaped_rays_per_facet[global_f] + - m_staging_escaped_rays_per_facet[global_f] >= - m_visibility_threshold) { + // Proposal seeds for this facet (own + neighbours), stable for the whole batch. + [[maybe_unused]] Scalar seed_weight_total = 0; + if constexpr (Adaptive) { + m_adaptive_policy.gather_seeds(inst, local_f, scratch->seeds_draw); + for (const auto& sd : scratch->seeds_draw) seed_weight_total += sd.weight; + } + + for (auto r : lagrange::range(budgets[k])) { + // Check once on entry and after flush(); wealth cannot change between them. + if ((r == 0 || packet.empty()) && + facet_retired(global_f, rays_done)) { break; } - Eigen::RowVector2 bary; - Eigen::RowVector3 dir; - if (!candidates.empty()) { - const auto& candidate = candidates[ray_counter++ % candidates.size()]; - const auto s = inst.ray_samplers[local_f].jitter(m_jitter_sigma); - bary = s.bary; - dir = (candidate.direction.transpose() + s.direction).normalized(); - // Reject rays heading into back hemisphere — happens at sharp dihedrals. - // The rejected attempt still counts against the budget; we don't retry. - if (dir.dot(inst.ray_samplers[local_f].get_normal()) < 0) continue; - } else { - const auto s = inst.ray_samplers[local_f](); - bary = s.bary; - dir = s.direction; - } - const auto origin = barycentric_position(bary, vertices, facets, local_f); + const auto s = [&] { + if constexpr (Adaptive) { + return m_adaptive_policy.draw( + inst.ray_samplers[local_f], + m_num_rays_cast[global_f] + rays_done, + scratch->seeds_draw, + seed_weight_total); + } + return inst.ray_samplers[local_f].sample_cosine(); + }(); + const auto origin = triangle_position(inst.facet_triangles[local_f], s.bary); slot_k[packet.count] = k; // count escapes against the facet under test - packet.push(origin, dir); + if constexpr (!Adaptive) slot_betting_steps[packet.count] = betting_steps; + packet.push(origin, s.direction); ++rays_done; if (packet.full()) flush(); @@ -212,28 +528,28 @@ struct OccludedFacetSampler::Impl }); } - void end_batch() + template + void end_batch_impl() { for (auto i : lagrange::range(m_instances.size())) { auto& inst = m_instances[i]; const auto& info = m_instance_infos[i]; - // Merge this batch's staging into the cumulative tally (saturating at 255). for (Index lf : inst.active_local_facets) { const uint64_t global_f = info.facet_offset + lf; - const int total = m_num_escaped_rays_per_facet[global_f] + - m_staging_escaped_rays_per_facet[global_f]; - m_num_escaped_rays_per_facet[global_f] = static_cast(std::min(total, 255)); - m_staging_escaped_rays_per_facet[global_f] = 0; + m_stats[global_f].merge(m_staging[global_f]); + if constexpr (Adaptive) m_adaptive_policy.merge_second_moment(global_f); } + // Retire-KEEP facets confirmed above their threshold tau_f at the configured + // confidence. inst.active_local_facets.erase( std::remove_if( inst.active_local_facets.begin(), inst.active_local_facets.end(), [&](Index lf) { - return m_num_escaped_rays_per_facet[info.facet_offset + lf] >= - m_visibility_threshold; + const uint64_t global_f = info.facet_offset + lf; + return facet_retired(global_f, 0); }), inst.active_local_facets.end()); m_instance_active_weights[i] = std::accumulate( @@ -243,6 +559,14 @@ struct OccludedFacetSampler::Impl [&](Scalar s, Index lf) { return s + inst.facet_weights[lf]; }); } } + + void end_batch() + { + if (m_adaptive) + end_batch_impl(); + else + end_batch_impl(); + } }; /// @cond LA_INTERNAL_DOCS @@ -253,11 +577,22 @@ OccludedFacetSampler::OccludedFacetSampler( function_ref is_occluder) { la_runtime_assert( - options.visibility_threshold >= 1, - "OccludedFacetSamplerOptions::visibility_threshold must be >= 1"); + options.threshold >= 0 && options.threshold <= 0.5, + "OccludedFacetSamplerOptions::threshold must be in [0, 0.5]"); la_runtime_assert( - options.jitter_sigma >= 0, - "OccludedFacetSamplerOptions::jitter_sigma must be non-negative"); + options.size_influence >= 0, + "OccludedFacetSamplerOptions::size_influence must be non-negative"); + la_runtime_assert( + options.confidence > 0 && options.confidence < 1, + "OccludedFacetSamplerOptions::confidence must be in (0, 1)"); + if (options.adaptive) { + la_runtime_assert( + options.adaptive->num_adaptive_per_cosine >= 1, + "AdaptiveOptions::num_adaptive_per_cosine must be >= 1"); + la_runtime_assert( + options.adaptive->vmf_kappa > 0, + "AdaptiveOptions::vmf_kappa must be positive"); + } la_runtime_assert(scene.compute_num_instances() > 0, "scene has no instances"); std::vector infos; @@ -274,54 +609,61 @@ OccludedFacetSampler::OccludedFacetSampler( } la_runtime_assert(total_facets > 0, "scene contains no facets"); - m_impl = make_value_ptr(total_facets); - m_impl->m_scene = scene; + m_impl = make_value_ptr(total_facets, options.adaptive.has_value()); m_impl->m_instance_infos = std::move(infos); - m_impl->m_jitter_sigma = options.jitter_sigma; - m_impl->m_visibility_threshold = options.visibility_threshold; + m_impl->m_threshold = options.threshold; + const double alpha = 1.0 - options.confidence; + m_impl->m_betting_policy = {alpha, total_facets}; + + // Adaptive proposal parameters and storage, allocated only when adaptive sampling is enabled. + if (options.adaptive) m_impl->m_adaptive_policy = {*options.adaptive, total_facets}; m_impl->m_instances.resize(m_impl->m_instance_infos.size()); m_impl->m_instance_active_weights.resize(m_impl->m_instance_infos.size()); + std::vector> world_vertices; for (auto i : lagrange::range(m_impl->m_instance_infos.size())) { const auto& info = m_impl->m_instance_infos[i]; auto& inst = m_impl->m_instances[i]; const auto& scene_instance = scene.get_instance(info.mesh_index, info.instance_index); - inst.mesh_index = info.mesh_index; - inst.transform = scene_instance.transform; - const auto& mesh = scene.get_mesh(info.mesh_index); const Index nf = info.num_facets; - - auto shallow = mesh; - const auto area_id = compute_facet_vector_area(shallow, inst.transform); - const auto area_view = attribute_matrix_view(shallow, area_id); - - inst.facet_areas.resize(nf); + const double total_area = + initialize_instance_geometry(inst, scene_instance.transform, mesh, world_vertices); inst.facet_weights.resize(nf); - inst.ray_samplers.reserve(nf); inst.active_local_facets.reserve(nf); - inst.facet_escape_directions.assign(nf, std::nullopt); - inst.facet_escape_snapshot.resize(nf); // pre-allocated; refilled before each batch - inst.facet_neighbors = compute_facet_facet_adjacency(shallow); + // Seed reservoirs + adjacency are adaptive-only; skip their allocation for plain cosine. + if (options.adaptive) { + inst.facet_seeds.assign(nf, {}); + inst.facet_seeds_snapshot.resize(nf); // pre-allocated; refilled before each batch + auto shallow = mesh; + inst.facet_neighbors = compute_facet_facet_adjacency(shallow); + } - Scalar total_weight = 0; for (auto f : lagrange::range(nf)) { - const auto area_vec = area_view.row(f); - const Scalar area_norm = area_vec.norm(); - inst.facet_areas[f] = area_norm; - inst.facet_weights[f] = std::cbrt(area_norm); - total_weight += inst.facet_weights[f]; - if (area_norm > 0) { + if (inst.facet_areas[f] > 0) { inst.active_local_facets.push_back(f); - inst.ray_samplers.emplace_back(area_vec / area_norm); } else { - // Degenerate facet: kept out of active_local_facets; sampler vector - // keeps an index-aligned placeholder. - inst.ray_samplers.emplace_back(Eigen::RowVector3::UnitZ()); + ++m_impl->m_num_degenerate_facets; } } + + const double mean_face_area = nf > 0 ? total_area / static_cast(nf) : 0.0; + Scalar total_weight = 0; + for (auto f : lagrange::range(nf)) { + const double a = static_cast(inst.facet_areas[f]); + const double size_factor = (a > 0 && mean_face_area > 0) + ? std::pow(a / mean_face_area, options.size_influence) + : 0.0; + m_impl->m_facet_size_factor[info.facet_offset + f] = size_factor; + m_impl->m_facet_tau[info.facet_offset + f] = + size_factor > 0 ? options.threshold / size_factor + : std::numeric_limits::infinity(); + // Allocate rays by statistical difficulty 1/tau_f ∝ size_factor; tiny facets ride the floor. + inst.facet_weights[f] = static_cast(size_factor); + total_weight += inst.facet_weights[f]; + } m_impl->m_instance_active_weights[i] = total_weight; } @@ -345,32 +687,23 @@ OccludedFacetSampler& OccludedFacetSampler::operat OccludedFacetSampler&&) noexcept = default; template -void OccludedFacetSampler::run_normal_batch(uint64_t num_rays) +void OccludedFacetSampler::run_batch(uint64_t num_rays) { - m_impl->m_current_mode = SamplingMode::Normal; m_impl->run_batch(num_rays); } template -void OccludedFacetSampler::run_adaptive_batch(uint64_t num_rays) -{ - m_impl->m_current_mode = SamplingMode::Adaptive; - m_impl->run_batch(num_rays); -} - -template -void OccludedFacetSampler::run_brute_force_batch(uint64_t num_rays) +bool OccludedFacetSampler::is_visible(uint64_t global_facet_index) const { - m_impl->m_current_mode = SamplingMode::BruteForce; - m_impl->run_batch(num_rays); + la_runtime_assert(global_facet_index < m_impl->m_stats.size()); + return m_impl->kept(global_facet_index); } template -bool OccludedFacetSampler::is_visible(uint64_t global_facet_index) const +double OccludedFacetSampler::visibility_measure(uint64_t global_facet_index) const { - la_runtime_assert(global_facet_index < m_impl->m_num_escaped_rays_per_facet.size()); - return m_impl->m_num_escaped_rays_per_facet[global_facet_index] >= - m_impl->m_visibility_threshold; + la_runtime_assert(global_facet_index < m_impl->m_facet_size_factor.size()); + return m_impl->visibility_measure(global_facet_index); } template @@ -383,11 +716,13 @@ uint64_t OccludedFacetSampler::num_rays_cast(uint64_t global_face template uint64_t OccludedFacetSampler::num_facets() const { - return std::accumulate( - m_impl->m_instance_infos.begin(), - m_impl->m_instance_infos.end(), - uint64_t{0}, - [](uint64_t s, const InstanceInfo& info) { return s + info.num_facets; }); + return m_impl->m_stats.size(); +} + +template +uint64_t OccludedFacetSampler::num_retired() const +{ + return m_impl->num_retired(); } template @@ -397,63 +732,60 @@ OccludedFacetSampler::instances() const return {m_impl->m_instance_infos.data(), m_impl->m_instance_infos.size()}; } +} // namespace internal + +namespace { + template -void estimate_occluded_facets( - OccludedFacetSampler& sampler, +void run_sampler( + internal::OccludedFacetSampler& sampler, const OccludedFacetEstimateOptions& options, ProgressCallback& progress, const std::atomic_bool* cancel) { la_runtime_assert(options.batch_size > 0); la_runtime_assert( - options.num_rays > 0 || cancel != nullptr || options.until_converged, - "estimate_occluded_facets needs at least one termination condition: num_rays>0, " - "until_converged=true, or a non-null cancel flag"); + options.num_rays > 0 || cancel != nullptr, + "Facet estimation needs at least one termination condition: num_rays>0 or a " + "non-null cancel flag"); const uint64_t total_facets = sampler.num_facets(); - const char* mode_label = options.brute_force ? "brute force" : "normal + adaptive"; - lagrange::logger().info("Searching for occluded facets ({} mode)", mode_label); + lagrange::logger().info("Searching for occluded facets"); progress.set_section("Searching for occluded facets"); - auto run_cycle = [&] { - if (options.brute_force) { - sampler.run_brute_force_batch(options.batch_size); - } else { - sampler.run_normal_batch(options.batch_size); - for ([[maybe_unused]] auto k : lagrange::range(options.num_adaptive_per_normal)) { - sampler.run_adaptive_batch(options.batch_size); - } - } - }; - - uint64_t prev_visible = 0; + uint64_t prev_total_rays = 0; while (true) { - run_cycle(); + sampler.run_batch(options.batch_size); - // `total_rays` is the actual count from the sampler, not the budgeted batch size — - // adaptive batches skip neighborless facets and trace far fewer rays than allocated. + // This is the actual count: retired facets receive no rays, and weighted allocation may + // differ slightly from the requested batch size. const auto [num_visible, total_rays] = sum_progress(sampler, total_facets); - lagrange::logger() - .info("{}/{} facets visible ({} rays so far)", num_visible, total_facets, total_rays); + const uint64_t num_retired = sampler.num_retired(); + lagrange::logger().info( + "{}/{} facets visible, {} confidently retired ({} rays so far)", + num_visible, + total_facets, + num_retired, + total_rays); const float fraction = options.num_rays > 0 ? std::min( 1.f, static_cast(total_rays) / static_cast(options.num_rays)) : (total_facets > 0 - ? static_cast(num_visible) / static_cast(total_facets) + ? static_cast(num_retired) / static_cast(total_facets) : 1.f); progress.update(fraction); - if (num_visible == total_facets) { - lagrange::logger().info("All facets visible, stopping early"); + if (num_retired == total_facets) { + lagrange::logger().info("All facets confidently visible, stopping early"); break; } - if (options.until_converged && num_visible == prev_visible) { - lagrange::logger().info("Converged: no new visible facets in last cycle, stopping"); + if (total_rays == prev_total_rays) { + lagrange::logger().info("No facet rays could be cast, stopping"); break; } - prev_visible = num_visible; + prev_total_rays = total_rays; if (cancel != nullptr && cancel->load()) { lagrange::logger().info("Cancelled, using results so far"); @@ -463,43 +795,186 @@ void estimate_occluded_facets( } } +// Turn a copy of the source mesh into an output mesh: with an empty `attribute_name`, remove facets +// whose measure is below `threshold`; otherwise write every facet's measure to that Scalar +// attribute. +template +void finalize_output_mesh( + Mesh& mesh, + const std::vector& measures, + double threshold, + std::string_view attribute_name) +{ + if (attribute_name.empty()) { + mesh.remove_facets([&](auto local_f) { return measures[local_f] < threshold; }); + return; + } + std::vector attribute(measures.size()); + for (auto f : lagrange::range(measures.size())) attribute[f] = static_cast(measures[f]); + mesh.template create_attribute( + attribute_name, + AttributeElement::Facet, + 1, + AttributeUsage::Scalar, + {attribute.data(), attribute.size()}); +} + +// One output mesh per input instance: shared meshes may cull differently, so instancing is lost. template -scene::SimpleScene remove_occluded_facets( +scene::SimpleScene assemble_facet_result( const scene::SimpleScene& scene, - const RemoveOccludedFacetsOptions& options, - ProgressCallback& progress, - function_ref is_occluder, - const std::atomic_bool* cancel) + const internal::OccludedFacetSampler& sampler, + double threshold, + std::string_view attribute_name) { - OccludedFacetSampler sampler(scene, options.sampler_options, is_occluder); - estimate_occluded_facets(sampler, options.estimate_options, progress, cancel); - - // One output mesh per input instance: shared source meshes can end up with different - // facets culled, so input instancing cannot be preserved. scene::SimpleScene result; for (const auto& info : sampler.instances()) { - const auto& source_mesh = scene.get_mesh(info.mesh_index); - auto filtered = source_mesh; - filtered.remove_facets( - [&](Index local_f) { return !sampler.is_visible(info.facet_offset + local_f); }); - if (filtered.get_num_facets() == 0) continue; + std::vector measures(info.num_facets); + for (auto local_f : lagrange::range(info.num_facets)) { + measures[local_f] = sampler.visibility_measure(info.facet_offset + local_f); + } + auto mesh = scene.get_mesh(info.mesh_index); + finalize_output_mesh(mesh, measures, threshold, attribute_name); + if (mesh.get_num_facets() == 0) continue; auto scene_instance = scene.get_instance(info.mesh_index, info.instance_index); - result.add_mesh(std::move(filtered)); + result.add_mesh(std::move(mesh)); scene_instance.mesh_index = result.get_num_meshes() - 1; result.add_instance(std::move(scene_instance)); } return result; } +// Preserve instancing: one output mesh per source mesh, shared by its instances. A source facet's +// measure is aggregated over its instances per `Policy`, then culled or annotated. +template +scene::SimpleScene assemble_instanced( + const scene::SimpleScene& scene, + const internal::OccludedFacetSampler& sampler, + double threshold, + std::string_view attribute_name) +{ + using InstanceInfo = typename internal::OccludedFacetSampler::InstanceInfo; + std::vector> by_mesh(scene.get_num_meshes()); + for (const auto& info : sampler.instances()) by_mesh[info.mesh_index].push_back(&info); + + scene::SimpleScene result; + for (Index mesh_index = 0; mesh_index < scene.get_num_meshes(); ++mesh_index) { + const auto& infos = by_mesh[mesh_index]; + if (infos.empty()) continue; + + const Index num_facets = infos.front()->num_facets; + std::vector measures(num_facets, 0.0); + for (const auto* info : infos) { + for (auto local_f : lagrange::range(num_facets)) { + const double m = sampler.visibility_measure(info->facet_offset + local_f); + if constexpr (Policy == InstancingPolicy::Max) { + measures[local_f] = std::max(measures[local_f], m); + } else { + measures[local_f] += m; + } + } + } + if constexpr (Policy == InstancingPolicy::Average) { + for (double& measure : measures) measure /= static_cast(infos.size()); + } + + auto mesh = scene.get_mesh(mesh_index); + finalize_output_mesh(mesh, measures, threshold, attribute_name); + if (mesh.get_num_facets() == 0) continue; + + result.add_mesh(std::move(mesh)); + const Index new_mesh_index = result.get_num_meshes() - 1; + for (const auto* info : infos) { + auto scene_instance = scene.get_instance(info->mesh_index, info->instance_index); + scene_instance.mesh_index = new_mesh_index; + result.add_instance(std::move(scene_instance)); + } + } + return result; +} + +// Build the output scene under `policy`. An empty `attribute_name` removes facets below +// `threshold`; otherwise every facet's measure is written to that attribute. +template +scene::SimpleScene assemble_facets( + const scene::SimpleScene& scene, + const internal::OccludedFacetSampler& sampler, + InstancingPolicy policy, + double threshold, + std::string_view attribute_name) +{ + switch (policy) { + case InstancingPolicy::FlattenInstances: + return assemble_facet_result(scene, sampler, threshold, attribute_name); + case InstancingPolicy::Max: + return assemble_instanced(scene, sampler, threshold, attribute_name); + case InstancingPolicy::Average: + return assemble_instanced( + scene, + sampler, + threshold, + attribute_name); + } + return {}; +} + +} // namespace + +template +scene::SimpleScene estimate_occluded_facet_measures( + const scene::SimpleScene& scene, + std::string_view attribute_name, + const RemoveOccludedFacetsOptions& options, + ProgressCallback& progress, + function_ref is_occluder, + const std::atomic_bool* cancel) +{ + internal::OccludedFacetSampler sampler( + scene, + options.sampler_options, + is_occluder); + run_sampler(sampler, options.estimate_options, progress, cancel); + return assemble_facets( + scene, + sampler, + options.instancing, + options.sampler_options.threshold, + attribute_name); +} + +template +scene::SimpleScene remove_occluded_facets( + const scene::SimpleScene& scene, + const RemoveOccludedFacetsOptions& options, + ProgressCallback& progress, + function_ref is_occluder, + const std::atomic_bool* cancel) +{ + internal::OccludedFacetSampler sampler( + scene, + options.sampler_options, + is_occluder); + run_sampler(sampler, options.estimate_options, progress, cancel); + return assemble_facets( + scene, + sampler, + options.instancing, + options.sampler_options.threshold, + {}); +} + // clang-format off -#define LA_X_estimate_occluded_facets(_, Scalar, Index) \ - template LA_RAYCASTING_API void estimate_occluded_facets( \ - OccludedFacetSampler&, \ - const OccludedFacetEstimateOptions&, \ +#define LA_X_estimate_occluded_facet_measures(_, Scalar, Index) \ + template LA_RAYCASTING_API scene::SimpleScene \ + estimate_occluded_facet_measures( \ + const scene::SimpleScene&, \ + std::string_view, \ + const RemoveOccludedFacetsOptions&, \ ProgressCallback&, \ + function_ref, \ const std::atomic_bool*); -LA_SURFACE_MESH_X(estimate_occluded_facets, 0) +LA_SURFACE_MESH_X(estimate_occluded_facet_measures, 0) #define LA_X_remove_occluded_facets(_, Scalar, Index) \ template LA_RAYCASTING_API scene::SimpleScene remove_occluded_facets( \ @@ -511,7 +986,7 @@ LA_SURFACE_MESH_X(estimate_occluded_facets, 0) LA_SURFACE_MESH_X(remove_occluded_facets, 0) #define LA_X_OccludedFacetSampler(_, Scalar, Index) \ - template class LA_RAYCASTING_API OccludedFacetSampler; + template class LA_RAYCASTING_API internal::OccludedFacetSampler; LA_SURFACE_MESH_X(OccludedFacetSampler, 0) // clang-format on diff --git a/modules/raycasting/src/remove_occluded_instances.cpp b/modules/raycasting/src/remove_occluded_instances.cpp index 9503af20..9f956875 100644 --- a/modules/raycasting/src/remove_occluded_instances.cpp +++ b/modules/raycasting/src/remove_occluded_instances.cpp @@ -16,168 +16,278 @@ #include #include -#include #include #include -#include #include -#include +#include -#include +#include +#include #include #include #include +#include #include -#include +#include +#include namespace lagrange::raycasting { using namespace detail; +namespace { + +OccludedInstanceSamplerOptions legacy_sampler_options() +{ + OccludedInstanceSamplerOptions options; + options.threshold = std::numeric_limits::min(); + return options; +} + +} // namespace + +namespace internal { + template struct OccludedInstanceSampler::Impl - : ImplBase::Impl, Scalar, Index> + : ImplBase::Impl, Scalar> { explicit Impl(Index num_instances) - : m_is_visible(num_instances) - , m_num_rays_cast(num_instances) - { - // Default-constructed atomics have unspecified value in C++17; explicit zeroing. - for (auto& v : m_is_visible) v.store(false, std::memory_order_relaxed); - for (auto& r : m_num_rays_cast) r.store(0, std::memory_order_relaxed); - } + : m_retired(num_instances, 0) + , m_num_escaped_rays(num_instances, 0) + , m_num_rays_cast(num_instances, 0) + {} - std::vector> m_instances; - /// Inter-instance ray-distribution weight = cbrt(total area). cbrt-of-sum, not sum-of-cbrt: - /// an instance is a single shared Bernoulli trial, so softening is applied to the instance - /// total rather than per facet. + std::vector> m_instances; + + /// Inter-instance ray-allocation weight ∝ 1/tau_i = (area / R)^size_influence. Rays track each + /// instance's statistical difficulty: low-tau (large) instances need the most to resolve. std::vector m_instance_weights; - std::vector> m_is_visible; - std::vector> m_num_rays_cast; + + /// Cached `(area / R)^size_influence`, used by both the public measure and ray allocation. + std::vector m_instance_size_factor; + + /// Per-instance escape-fraction threshold `tau = threshold / size_factor`. + std::vector m_instance_tau; + + /// Public keep threshold applied to visibility_measure(). + double m_threshold = 0.0; + + /// Retire-KEEP flag: set once confidently above tau (sampling then stops). Occluded instances + /// never retire; they sample to the ray budget. + std::vector m_retired; + std::vector m_num_escaped_rays; + std::vector m_num_rays_cast; + double m_alpha = 0.01; + /// Prefix sum over meshes: flat instance index = m_mesh_offset[mesh] + instance. + std::vector m_mesh_offset; + /// Reused per-instance ray-allocation scratch; process_instance() is called sequentially. + std::vector m_ray_boundaries; + + Index flat(Index mesh_index, Index instance_index) const + { + const size_t mi = static_cast(mesh_index); + la_runtime_assert(!m_mesh_offset.empty()); + la_runtime_assert(mi < m_mesh_offset.size() - 1, "mesh_index is out of bounds"); + const Index num_mesh_instances = m_mesh_offset[mi + 1] - m_mesh_offset[mi]; + la_runtime_assert( + instance_index < num_mesh_instances, + "instance_index is out of bounds for the selected mesh"); + return m_mesh_offset[mi] + instance_index; + } Scalar instance_active_weight(size_t i) const { - return m_is_visible[i].load(std::memory_order_relaxed) ? Scalar(0) : m_instance_weights[i]; + return m_retired[i] ? Scalar(0) : m_instance_weights[i]; } - template - void process_instance( - size_t i, - uint64_t instance_rays, - Scalar /*instance_weight*/, - const Vertices& vertices, - const Facets& facets) + /// (number of currently-visible instances, total rays cast) across all instances. + std::pair progress() const + { + Index num_visible = 0; + uint64_t total_rays = 0; + for (auto i : lagrange::range(this->num_instances())) { + if (kept(i)) ++num_visible; + total_rays += m_num_rays_cast[i]; + } + return {num_visible, total_rays}; + } + + Index num_retired() const + { + Index count = 0; + for (const auto retired : m_retired) + if (retired) ++count; + return count; + } + + /// Estimated cosine-weighted escaped fraction of the instance, in [0, 1]; 0 before sampling. + double mean_visibility(size_t i) const + { + const uint64_t escaped = m_num_escaped_rays[i]; + const uint64_t cast = m_num_rays_cast[i]; + if (cast == 0) return 0.0; + assert(escaped <= cast); + return static_cast(escaped) / static_cast(cast); + } + + /// Size-weighted visibility measure: mean_visibility * (area / R)^size_influence. + double visibility_measure(size_t i) const + { + return mean_visibility(i) * m_instance_size_factor[i]; + } + + /// Point-estimate keep decision on the public visibility measure. + bool kept(size_t i) const { return visibility_measure(i) >= m_threshold; } + + void process_instance(size_t i, uint64_t instance_rays, Scalar /*instance_weight*/) { auto& inst = m_instances[i]; - const size_t N = inst.facet_areas.size(); - if (N == 0 || instance_rays == 0) return; + const size_t num_facets = inst.facet_areas.size(); + if (num_facets == 0 || instance_rays == 0) return; - // Rays are distributed proportionally to plain facet area — no per-facet softening, - // since the instance is a shared Bernoulli trial. Some facets may receive zero rays. + // Rays ∝ plain facet area (no per-facet softening — the instance is one shared Bernoulli + // trial); some facets may get zero rays. const Scalar area_sum = std::accumulate(inst.facet_areas.begin(), inst.facet_areas.end(), Scalar(0)); if (area_sum <= 0) return; - // boundary[k+1] = cumulative ray count up to and including facet k. - // packet_start_facet[p] = facet that owns the first ray of packet p; the parallel_for - // walks boundary linearly from there (≤ packet capacity steps), no binary search. - std::vector boundary(N + 1, 0); - std::vector packet_start_facet; + // boundary[k+1] = cumulative rays through facet k. The allocation is reused across + // instances and batches; each parallel range finds its first facet once, then walks + // boundary linearly. + m_ray_boundaries.resize(num_facets + 1); + m_ray_boundaries[0] = 0; { const double rays_per_area = static_cast(instance_rays) / area_sum; double cumsum = 0; - size_t next_p = 0; - for (auto k : lagrange::range(N)) { + for (auto k : lagrange::range(num_facets)) { cumsum += inst.facet_areas[k] * rays_per_area; - boundary[k + 1] = static_cast(std::llround(cumsum)); - while (next_p * RayPacket16::capacity < boundary[k + 1]) { - packet_start_facet.push_back(k); - ++next_p; - } + m_ray_boundaries[k + 1] = static_cast(std::llround(cumsum)); } } - const uint64_t total_rays = boundary[N]; - const size_t num_packets = packet_start_facet.size(); + const auto& boundary = m_ray_boundaries; + const uint64_t total_rays = boundary[num_facets]; + const size_t num_packets = + total_rays == 0 ? 0 : static_cast(1 + (total_rays - 1) / RayPacket16::capacity); if (num_packets == 0) return; - // One packet per iteration, drawing rays from up to 16 different facets. Sampler - // thread-safety comes from the atomic index inside RaySampler. - tbb::parallel_for(size_t(0), num_packets, [&](size_t p) { - if (m_is_visible[i].load(std::memory_order_relaxed)) return; - - const uint64_t r0 = p * RayPacket16::capacity; - const size_t count = - static_cast(std::min(RayPacket16::capacity, total_rays - r0)); - - RayPacket16 packet; - size_t f = packet_start_facet[p]; - for (auto slot : lagrange::range(count)) { - while (r0 + slot >= boundary[f + 1]) ++f; - const auto s = inst.ray_samplers[f](); - const auto origin = barycentric_position(s.bary, vertices, facets, f); - packet.push(origin, s.direction); - } + // Reduce packet results locally, then publish once; the instance counters are owned by the + // calling thread. + const uint64_t num_escaped = tbb::parallel_reduce( + tbb::blocked_range(0, num_packets), + uint64_t{0}, + [&](const tbb::blocked_range& range, uint64_t local_escaped) { + const uint64_t first_ray = range.begin() * RayPacket16::capacity; + size_t f = static_cast( + std::upper_bound(boundary.begin(), boundary.end(), first_ray) - + boundary.begin() - 1); + for (size_t p = range.begin(); p != range.end(); ++p) { + const uint64_t r0 = p * RayPacket16::capacity; + const size_t count = static_cast( + std::min(RayPacket16::capacity, total_rays - r0)); + + RayPacket16 packet; + for (auto slot : lagrange::range(count)) { + while (r0 + slot >= boundary[f + 1]) ++f; + const auto s = inst.ray_samplers[f].sample_cosine(); + const auto origin = triangle_position(inst.facet_triangles[f], s.bary); + packet.push(origin, s.direction); + } + + uint32_t escaped = ~packet.cast(this->m_ray_caster) & packet.occupied_mask(); + for (; escaped != 0; escaped &= escaped - 1) ++local_escaped; + } + return local_escaped; + }, + [](uint64_t a, uint64_t b) { return a + b; }); - const uint32_t mask = packet.cast(this->m_ray_caster); - if ((mask & packet.occupied_mask()) != packet.occupied_mask()) { - m_is_visible[i].store(true, std::memory_order_relaxed); - } - m_num_rays_cast[i].fetch_add(packet.count, std::memory_order_relaxed); - }); + m_num_escaped_rays[i] += num_escaped; + m_num_rays_cast[i] += total_rays; } - void end_batch() {} + void end_batch() + { + // Retire-KEEP instances confidently above their mean-visibility threshold tau. + for (auto i : lagrange::range(this->num_instances())) { + if (m_retired[i]) continue; + if (is_confidently_visible( + m_num_escaped_rays[i], + m_num_rays_cast[i], + m_instance_tau[i], + m_alpha)) { + m_retired[i] = true; + } + } + } }; /// @cond LA_INTERNAL_DOCS template OccludedInstanceSampler::OccludedInstanceSampler( const scene::SimpleScene& scene, + const OccludedInstanceSamplerOptions& options, function_ref is_occluder) { + la_runtime_assert( + options.threshold >= 0 && options.threshold <= 0.5, + "OccludedInstanceSamplerOptions::threshold must be in [0, 0.5]"); + la_runtime_assert( + options.confidence > 0 && options.confidence < 1, + "OccludedInstanceSamplerOptions::confidence must be in (0, 1)"); + la_runtime_assert( + options.size_influence >= 0, + "OccludedInstanceSamplerOptions::size_influence must be non-negative"); const Index total = scene.compute_num_instances(); la_runtime_assert(total > 0, "scene has no instances"); m_impl = make_value_ptr(total); - m_impl->m_scene = scene; + m_impl->m_alpha = 1.0 - options.confidence; + m_impl->m_threshold = options.threshold; + + // Per-instance tau_i = threshold * (R / area_i)^size_influence: size_influence 0 -> mean + // visibility, 1 -> area-weighted visibility. Degenerate instances cannot retire early. + const double scene_aabb_area = scene_aabb_surface_area(scene); m_impl->m_instances.resize(total); m_impl->m_instance_weights.resize(total); + m_impl->m_instance_size_factor.resize(total); + m_impl->m_instance_tau.resize(total, 0.0); + + const Index num_meshes = scene.get_num_meshes(); + m_impl->m_mesh_offset.resize(num_meshes + 1); + m_impl->m_mesh_offset[0] = 0; + for (auto mi : lagrange::range(num_meshes)) { + m_impl->m_mesh_offset[mi + 1] = m_impl->m_mesh_offset[mi] + scene.get_num_instances(mi); + } + + std::vector> world_vertices; Index global = 0; - for (auto mi : lagrange::range(m_impl->m_scene.get_num_meshes())) { - for (auto ii : lagrange::range(m_impl->m_scene.get_num_instances(mi))) { - const auto& instance = m_impl->m_scene.get_instance(mi, ii); + for (auto mi : lagrange::range(scene.get_num_meshes())) { + for (auto ii : lagrange::range(scene.get_num_instances(mi))) { + const auto& instance = scene.get_instance(mi, ii); auto& inst = m_impl->m_instances[global]; - inst.mesh_index = instance.mesh_index; - inst.transform = instance.transform; - const auto& mesh = m_impl->m_scene.get_mesh(inst.mesh_index); + const auto& mesh = scene.get_mesh(instance.mesh_index); la_runtime_assert( mesh.is_triangle_mesh(), "OccludedInstanceSampler requires triangle meshes"); - const Index nf = mesh.get_num_facets(); - - auto shallow = mesh; - const auto area_id = compute_facet_vector_area(shallow, inst.transform); - const auto area_view = attribute_matrix_view(shallow, area_id); - inst.facet_areas.resize(nf); - inst.ray_samplers.reserve(nf); - for (auto f : lagrange::range(nf)) { - const auto area_vec = area_view.row(f); - const Scalar area_norm = area_vec.norm(); - inst.facet_areas[f] = area_norm; - // Degenerate facets contribute zero area; emplace a placeholder normal so the - // sampler vector stays index-aligned (no rays will ever be cast from them). - inst.ray_samplers.emplace_back( - area_norm > 0 ? Eigen::RowVector3(area_vec / area_norm) - : Eigen::RowVector3::UnitZ()); - } - m_impl->m_instance_weights[global] = std::cbrt( - std::accumulate(inst.facet_areas.begin(), inst.facet_areas.end(), Scalar(0))); + const double area = + initialize_instance_geometry(inst, instance.transform, mesh, world_vertices); + const double size_factor = + area > 0 && scene_aabb_area > 0 + ? std::pow(area / scene_aabb_area, options.size_influence) + : 0.0; + // Allocate rays by statistical difficulty 1/tau_i proportional to size_factor. + m_impl->m_instance_weights[global] = static_cast(size_factor); + m_impl->m_instance_size_factor[global] = size_factor; + m_impl->m_instance_tau[global] = size_factor > 0 + ? options.threshold / size_factor + : std::numeric_limits::infinity(); ++global; } } + // Ray caster sees occluder-only instances. lagrange::logger().info("Building ray caster"); auto occluder_scene = scene::filter_instances(scene, is_occluder); m_impl->m_ray_caster.add_scene(std::move(occluder_scene)); @@ -203,17 +313,26 @@ void OccludedInstanceSampler::run_batch(uint64_t num_rays) } template -bool OccludedInstanceSampler::is_visible(Index global_index) const +bool OccludedInstanceSampler::is_visible(Index mesh_index, Index instance_index) + const +{ + return m_impl->kept(m_impl->flat(mesh_index, instance_index)); +} + +template +double OccludedInstanceSampler::visibility_measure( + Index mesh_index, + Index instance_index) const { - la_runtime_assert(global_index < m_impl->m_is_visible.size()); - return m_impl->m_is_visible[global_index].load(std::memory_order_relaxed); + return m_impl->visibility_measure(m_impl->flat(mesh_index, instance_index)); } template -uint64_t OccludedInstanceSampler::num_rays_cast(Index global_index) const +uint64_t OccludedInstanceSampler::num_rays_cast( + Index mesh_index, + Index instance_index) const { - la_runtime_assert(global_index < m_impl->m_num_rays_cast.size()); - return m_impl->m_num_rays_cast[global_index].load(std::memory_order_relaxed); + return m_impl->m_num_rays_cast[m_impl->flat(mesh_index, instance_index)]; } template @@ -223,8 +342,24 @@ Index OccludedInstanceSampler::num_instances() const } template -void estimate_occluded_instances( - OccludedInstanceSampler& sampler, +Index OccludedInstanceSampler::num_retired() const +{ + return m_impl->num_retired(); +} + +template +std::pair OccludedInstanceSampler::progress() const +{ + return m_impl->progress(); +} + +} // namespace internal + +namespace { + +template +void run_sampler( + internal::OccludedInstanceSampler& sampler, const OccludedInstanceEstimateOptions& options, ProgressCallback& progress, const std::atomic_bool* cancel) @@ -232,24 +367,28 @@ void estimate_occluded_instances( la_runtime_assert(options.batch_size > 0); la_runtime_assert( options.num_rays > 0 || cancel != nullptr || options.until_converged, - "estimate_occluded_instances needs at least one termination condition: num_rays>0, " + "run_sampler needs at least one termination condition: num_rays>0, " "until_converged=true, or a non-null cancel flag"); const Index total_instances = sampler.num_instances(); lagrange::logger().info("Searching for occluded instances"); progress.set_section("Searching for occluded instances"); - Index prev_visible = 0; + Index prev_retired = 0; + uint64_t prev_total_rays = 0; + bool has_previous_batch = false; while (true) { sampler.run_batch(options.batch_size); - // `total_rays` is the actual count from the sampler, not the budgeted batch size — - // packets are skipped once an instance becomes visible mid-batch. - const auto [num_visible, total_rays] = sum_progress(sampler, total_instances); + // This is the actual count: retired instances receive no rays, and weighted allocation may + // differ slightly from the requested batch size. + const auto [num_visible, total_rays] = sampler.progress(); + const Index num_retired = sampler.num_retired(); lagrange::logger().info( - "{}/{} instances visible ({} rays so far)", + "{}/{} instances visible, {} confidently retired ({} rays so far)", num_visible, total_instances, + num_retired, total_rays); const float fraction = options.num_rays > 0 @@ -257,19 +396,25 @@ void estimate_occluded_instances( 1.f, static_cast(total_rays) / static_cast(options.num_rays)) : (total_instances > 0 - ? static_cast(num_visible) / static_cast(total_instances) + ? static_cast(num_retired) / static_cast(total_instances) : 1.f); progress.update(fraction); - if (static_cast(num_visible) == total_instances) { - lagrange::logger().info("All instances visible, stopping early"); + if (num_retired == total_instances) { + lagrange::logger().info("All instances confidently visible, stopping early"); + break; + } + if (total_rays == prev_total_rays) { + lagrange::logger().info("No instance rays could be cast, stopping"); break; } - if (options.until_converged && static_cast(num_visible) == prev_visible) { - lagrange::logger().info("Converged: no new visible instances in last batch, stopping"); + if (options.until_converged && has_previous_batch && num_retired == prev_retired) { + lagrange::logger().info("Converged: no instances retired in last batch, stopping"); break; } - prev_visible = static_cast(num_visible); + prev_retired = num_retired; + prev_total_rays = total_rays; + has_previous_batch = true; if (cancel != nullptr && cancel->load()) { lagrange::logger().info("Cancelled, using results so far"); @@ -279,49 +424,49 @@ void estimate_occluded_instances( } } +} // namespace + template -void estimate_occluded_instances( +std::vector> estimate_occluded_instance_measures( const scene::SimpleScene& scene, - function_ref callback, - const OccludedInstanceEstimateOptions& options, + const RemoveOccludedInstancesOptions& options, ProgressCallback& progress, function_ref is_occluder, const std::atomic_bool* cancel) { - OccludedInstanceSampler sampler(scene, is_occluder); - estimate_occluded_instances(sampler, options, progress, cancel); + internal::OccludedInstanceSampler sampler( + scene, + options.sampler_options, + is_occluder); + run_sampler(sampler, options.estimate_options, progress, cancel); - Index global = 0; + std::vector> measures(scene.get_num_meshes()); for (auto mi : lagrange::range(scene.get_num_meshes())) { + measures[mi].resize(scene.get_num_instances(mi)); for (auto ii : lagrange::range(scene.get_num_instances(mi))) { - if (!sampler.is_visible(global)) callback(mi, ii); - ++global; + measures[mi][ii] = sampler.visibility_measure(mi, ii); } } + return measures; } template scene::SimpleScene remove_occluded_instances( const scene::SimpleScene& scene, - const OccludedInstanceEstimateOptions& options, + const RemoveOccludedInstancesOptions& options, ProgressCallback& progress, function_ref is_occluder, const std::atomic_bool* cancel) { - std::unordered_set, lagrange::OrderedPairHash>> - occluded; - estimate_occluded_instances( + internal::OccludedInstanceSampler sampler( scene, - [&](Index mi, Index ii) { occluded.emplace(mi, ii); }, - options, - progress, - is_occluder, - cancel); + options.sampler_options, + is_occluder); + run_sampler(sampler, options.estimate_options, progress, cancel); auto result = scene::filter_instances(scene, [&](Index mi, Index ii) { - return occluded.find({mi, ii}) == occluded.end(); + return sampler.is_visible(mi, ii); }); - lagrange::logger().info( "Filtered scene: {} meshes, {} instances", result.get_num_meshes(), @@ -329,20 +474,76 @@ scene::SimpleScene remove_occluded_instances( return result; } +// Deprecated back-compat overloads preserve the old "one escaped ray keeps the instance" +// semantics with the smallest practical positive threshold. Zero would also keep instances with +// no escapes because the public comparison is inclusive. +LA_IGNORE_DEPRECATION_WARNING_BEGIN + +template +void estimate_occluded_instances( + const scene::SimpleScene& scene, + function_ref callback, + const OccludedInstanceEstimateOptions& options, + ProgressCallback& progress, + function_ref is_occluder, + const std::atomic_bool* cancel) +{ + internal::OccludedInstanceSampler sampler( + scene, + legacy_sampler_options(), + is_occluder); + run_sampler(sampler, options, progress, cancel); + for (auto mi : lagrange::range(scene.get_num_meshes())) { + for (auto ii : lagrange::range(scene.get_num_instances(mi))) { + if (!sampler.is_visible(mi, ii)) callback(mi, ii); + } + } +} + +template +scene::SimpleScene remove_occluded_instances( + const scene::SimpleScene& scene, + const OccludedInstanceEstimateOptions& options, + ProgressCallback& progress, + function_ref is_occluder, + const std::atomic_bool* cancel) +{ + return remove_occluded_instances( + scene, + RemoveOccludedInstancesOptions{legacy_sampler_options(), options}, + progress, + is_occluder, + cancel); +} + +LA_IGNORE_DEPRECATION_WARNING_END + // clang-format off #define LA_X_OccludedInstanceSampler(_, Scalar, Index) \ - template class LA_RAYCASTING_API OccludedInstanceSampler; + template class LA_RAYCASTING_API internal::OccludedInstanceSampler; LA_SURFACE_MESH_X(OccludedInstanceSampler, 0) -#define LA_X_estimate_occluded_instances(_, Scalar, Index) \ - template LA_RAYCASTING_API void estimate_occluded_instances( \ - OccludedInstanceSampler&, \ - const OccludedInstanceEstimateOptions&, \ +#define LA_X_estimate_occluded_instance_measures(_, Scalar, Index) \ + template LA_RAYCASTING_API std::vector> \ + estimate_occluded_instance_measures( \ + const scene::SimpleScene&, \ + const RemoveOccludedInstancesOptions&, \ ProgressCallback&, \ + function_ref, \ const std::atomic_bool*); -LA_SURFACE_MESH_X(estimate_occluded_instances, 0) +LA_SURFACE_MESH_X(estimate_occluded_instance_measures, 0) -#define LA_X_estimate_occluded_instances_scene(_, Scalar, Index) \ +#define LA_X_remove_occluded_instances(_, Scalar, Index) \ + template LA_RAYCASTING_API scene::SimpleScene remove_occluded_instances( \ + const scene::SimpleScene&, \ + const RemoveOccludedInstancesOptions&, \ + ProgressCallback&, \ + function_ref, \ + const std::atomic_bool*); +LA_SURFACE_MESH_X(remove_occluded_instances, 0) + +LA_IGNORE_DEPRECATION_WARNING_BEGIN +#define LA_X_estimate_occluded_instances_deprecated(_, Scalar, Index) \ template LA_RAYCASTING_API void estimate_occluded_instances( \ const scene::SimpleScene&, \ function_ref, \ @@ -350,16 +551,17 @@ LA_SURFACE_MESH_X(estimate_occluded_instances, 0) ProgressCallback&, \ function_ref, \ const std::atomic_bool*); -LA_SURFACE_MESH_X(estimate_occluded_instances_scene, 0) +LA_SURFACE_MESH_X(estimate_occluded_instances_deprecated, 0) -#define LA_X_remove_occluded_instances(_, Scalar, Index) \ +#define LA_X_remove_occluded_instances_deprecated(_, Scalar, Index) \ template LA_RAYCASTING_API scene::SimpleScene remove_occluded_instances( \ const scene::SimpleScene&, \ const OccludedInstanceEstimateOptions&, \ ProgressCallback&, \ function_ref, \ const std::atomic_bool*); -LA_SURFACE_MESH_X(remove_occluded_instances, 0) +LA_SURFACE_MESH_X(remove_occluded_instances_deprecated, 0) +LA_IGNORE_DEPRECATION_WARNING_END // clang-format on } // namespace lagrange::raycasting diff --git a/modules/scene/include/lagrange/scene/simple_scene_bbox.h b/modules/scene/include/lagrange/scene/simple_scene_bbox.h new file mode 100644 index 00000000..bc61a744 --- /dev/null +++ b/modules/scene/include/lagrange/scene/simple_scene_bbox.h @@ -0,0 +1,40 @@ +/* + * 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 lagrange::scene { + +/// +/// Compute the axis-aligned bounding box of all instantiated mesh vertices in a simple scene. +/// +/// Each mesh is transformed independently by each of its instance transformations. Meshes without +/// instances do not contribute to the bounding box. If the scene contains no instantiated +/// vertices, the returned bounding box is empty. +/// +/// @param[in] scene Input scene. +/// +/// @tparam Scalar Scene scalar type. +/// @tparam Index Scene index type. +/// @tparam Dimension Spatial dimension of the scene. Must be 2 or 3. +/// +/// @return The axis-aligned bounding box of all transformed instance vertices. +/// +template +[[nodiscard]] +Eigen::AlignedBox(Dimension)> simple_scene_bbox( + const SimpleScene& scene); + +} // namespace lagrange::scene diff --git a/modules/scene/python/src/bind_value.h b/modules/scene/python/src/bind_value.h index 455d1364..65afb7ae 100644 --- a/modules/scene/python/src/bind_value.h +++ b/modules/scene/python/src/bind_value.h @@ -21,7 +21,7 @@ NAMESPACE_BEGIN(detail) template <> struct type_caster { - NB_TYPE_CASTER(lagrange::scene::Value, const_name("int | float | str | list | dict | bool")); + NB_TYPE_CASTER(lagrange::scene::Value, const_name("int | float | str | list | dict | bool")) template bool try_cast(const handle& src, uint32_t flags, cleanup_list* cleanup) diff --git a/modules/scene/src/simple_scene_bbox.cpp b/modules/scene/src/simple_scene_bbox.cpp new file mode 100644 index 00000000..ae57b39b --- /dev/null +++ b/modules/scene/src/simple_scene_bbox.cpp @@ -0,0 +1,43 @@ +/* + * 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 lagrange::scene { + +template +Eigen::AlignedBox(Dimension)> simple_scene_bbox( + const SimpleScene& scene) +{ + Eigen::AlignedBox(Dimension)> bbox; + for (Index mesh_index = 0; mesh_index < scene.get_num_meshes(); ++mesh_index) { + const auto& mesh = scene.get_mesh(mesh_index); + for (Index instance_index = 0; instance_index < scene.get_num_instances(mesh_index); + ++instance_index) { + bbox.extend( + mesh_bbox( + mesh, + scene.get_instance(mesh_index, instance_index).transform)); + } + } + return bbox; +} + +#define LA_X_simple_scene_bbox(_, Scalar, Index, Dimension) \ + template LA_SCENE_API Eigen::AlignedBox(Dimension)> \ + simple_scene_bbox(const SimpleScene&); +LA_SIMPLE_SCENE_X(simple_scene_bbox, 0) + +} // namespace lagrange::scene diff --git a/modules/scene/tests/test_simple_scene_bbox.cpp b/modules/scene/tests/test_simple_scene_bbox.cpp new file mode 100644 index 00000000..5a103775 --- /dev/null +++ b/modules/scene/tests/test_simple_scene_bbox.cpp @@ -0,0 +1,95 @@ +/* + * 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 + +TEST_CASE("simple_scene_bbox 3D", "[scene][simple_scene_bbox]") +{ + using Scalar = double; + using Index = uint32_t; + using SceneType = lagrange::scene::SimpleScene; + using MeshType = lagrange::SurfaceMesh; + + SECTION("empty scene") + { + const SceneType scene; + REQUIRE(lagrange::scene::simple_scene_bbox(scene).isEmpty()); + } + + SECTION("mesh without instances") + { + SceneType scene; + MeshType mesh; + mesh.add_vertex({1, 2, 3}); + scene.add_mesh(std::move(mesh)); + REQUIRE(lagrange::scene::simple_scene_bbox(scene).isEmpty()); + } + + SECTION("empty instantiated mesh") + { + SceneType scene; + const Index mesh_index = scene.add_mesh(MeshType{}); + SceneType::InstanceType instance; + instance.mesh_index = mesh_index; + scene.add_instance(instance); + REQUIRE(lagrange::scene::simple_scene_bbox(scene).isEmpty()); + } + + SECTION("multiple transformed instances") + { + SceneType scene; + MeshType mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 2, 3}); + const Index mesh_index = scene.add_mesh(std::move(mesh)); + + SceneType::InstanceType first; + first.mesh_index = mesh_index; + first.transform = Eigen::Translation3d(-2, 0, 1) * Eigen::Scaling(2.0, 1.0, 1.0); + scene.add_instance(first); + + SceneType::InstanceType second; + second.mesh_index = mesh_index; + second.transform = Eigen::Translation3d(4, -3, -1); + scene.add_instance(second); + + const auto bbox = lagrange::scene::simple_scene_bbox(scene); + REQUIRE(bbox.min() == Eigen::Vector3d(-2, -3, -1)); + REQUIRE(bbox.max() == Eigen::Vector3d(5, 2, 4)); + } +} + +TEST_CASE("simple_scene_bbox 2D", "[scene][simple_scene_bbox]") +{ + using Scalar = float; + using Index = uint64_t; + using SceneType = lagrange::scene::SimpleScene; + using MeshType = lagrange::SurfaceMesh; + + SceneType scene; + MeshType mesh(2); + mesh.add_vertex({-1, 3}); + mesh.add_vertex({4, -2}); + const Index mesh_index = scene.add_mesh(std::move(mesh)); + + SceneType::InstanceType instance; + instance.mesh_index = mesh_index; + instance.transform = Eigen::Translation2f(2, -1) * Eigen::Scaling(2.f, 3.f); + scene.add_instance(instance); + + const auto bbox = lagrange::scene::simple_scene_bbox(scene); + REQUIRE(bbox.min() == Eigen::Vector2f(0, -7)); + REQUIRE(bbox.max() == Eigen::Vector2f(10, 8)); +} diff --git a/pyproject.toml b/pyproject.toml index e0e2773e..4e66d16f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [build-system] build-backend = "scikit_build_core.build" requires = [ - "nanobind-backend>=1.0", # needed at build time for stub generation - "numpy>=1.25", # needed at build time for default dtype args - "scikit-build-core==0.11.6", + "nanobind-backend>=1.0", # needed at build time for stub generation + "numpy>=1.25", # needed at build time for default dtype args + "scikit-build-core==1.1.0", "typing-extensions~=4.1", ] @@ -43,7 +43,7 @@ optional-dependencies.docs = [ ] optional-dependencies.scripts = [ "pillow>=10.0.0", - "pyexr>=0.5.0", + "pyexr>=0.5.0; python_version<'3.14'", "pymcubes==0.1.6", "torch>=2.2.0", ] From efb5395fe2774c17454ed57ef3f14b184f0fa981 Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Mon, 5 Oct 2026 10:47:52 -0400 Subject: [PATCH 2/2] Add lsan.suppressions.ini. --- .github/lsan.suppressions.ini | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/lsan.suppressions.ini diff --git a/.github/lsan.suppressions.ini b/.github/lsan.suppressions.ini new file mode 100644 index 00000000..53847580 --- /dev/null +++ b/.github/lsan.suppressions.ini @@ -0,0 +1,11 @@ +# LSAN suppression for known false positives in third-party system libraries. + +# D-Bus client library — leaked allocations in libdbus-1.so during static init/shutdown. +leak:libdbus-1.so + +# NVIDIA OpenGL driver — leaked allocations from libnvidia-glcore.so during GL context setup. +leak:libnvidia-glcore.so + +# NVIDIA Vulkan ICD — leaked allocations reached through libGLX_nvidia.so during Vulkan context +# setup in SAL. Requires VK_LOADER_DISABLE_DYNAMIC_LIBRARY_UNLOADING=1 so the ICD stays mapped at exit. +leak:libGLX_nvidia.so