diff --git a/.gitignore b/.gitignore index a927af3e..08992044 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,8 @@ func_*.json /.idea /.clangd /.cache +/.intent +/.scout /3rdparty /cmake-build-* /build* diff --git a/cmake/lagrange/lagrangeMklModules.txt b/cmake/lagrange/lagrangeMklModules.txt index f8bbc7c7..f01aafaa 100644 --- a/cmake/lagrange/lagrangeMklModules.txt +++ b/cmake/lagrange/lagrangeMklModules.txt @@ -1 +1 @@ -anorigami;baking;cad_io;contouring;decal;deformers;filtering;meshproc;polyddg;quadrangulation;solver;texproc +anorigami;baking;cad_io;contouring;decal;deformers;filtering;meshproc;polyddg;quadgen;quadrangulation;solver;texproc diff --git a/cmake/lagrange/lagrange_add_test.cmake b/cmake/lagrange/lagrange_add_test.cmake index 5ccc56b8..5e2b0476 100644 --- a/cmake/lagrange/lagrange_add_test.cmake +++ b/cmake/lagrange/lagrange_add_test.cmake @@ -44,9 +44,10 @@ function(lagrange_add_test) include(FetchContent) target_code_coverage(${test_target} AUTO ALL EXCLUDE "${FETCHCONTENT_BASE_DIR}/*") - # TSan suppression file to be passed to catch_discover_tests + # 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" ) @@ -67,6 +68,31 @@ function(lagrange_add_test) set(_discovery_mode POST_BUILD) endif() + # On Linux, ThreadSanitizer can fail with "FATAL: ThreadSanitizer: unexpected memory mapping" on + # kernels with high ASLR entropy (e.g. kernel 6.x with vm.mmap_rnd_bits > 28). LLVM 18+ / GCC 15+ + # include an auto-retry that re-executes the process with ASLR disabled + # (https://github.com/llvm/llvm-project/pull/78351). For older compilers, we work around this by + # wrapping test discovery and execution with `setarch --addr-no-randomize`. + if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND USE_SANITIZER MATCHES "([Tt]hread)") + set(_tsan_needs_aslr_workaround FALSE) + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS "15") + set(_tsan_needs_aslr_workaround TRUE) + elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS "18") + set(_tsan_needs_aslr_workaround TRUE) + endif() + if(_tsan_needs_aslr_workaround) + find_program(_setarch setarch) + if(_setarch) + set_target_properties(${test_target} PROPERTIES + CROSSCOMPILING_EMULATOR "${_setarch};${CMAKE_HOST_SYSTEM_PROCESSOR};--addr-no-randomize" + ) + set(_discovery_mode PRE_TEST) + else() + message(WARNING "setarch not found — TSan tests may fail with 'unexpected memory mapping' on high-ASLR kernels") + endif() + endif() + endif() + if(LAGRANGE_TOPLEVEL_PROJECT AND NOT USE_SANITIZER MATCHES "([Tt]hread)") catch_discover_tests(${test_target} REPORTER junit diff --git a/cmake/lagrange/lagrange_download_data.cmake b/cmake/lagrange/lagrange_download_data.cmake index 45edf104..93655b1e 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 a8331c8a1fd9c114abdcc3d5ed8ea5f7f8058c99 + GIT_TAG 009e99371d7495f7ad81d42d32080ba8afd4d4cd CONFIGURE_COMMAND "" BUILD_COMMAND "" INSTALL_COMMAND "" diff --git a/cmake/recipes/external/blosc.cmake b/cmake/recipes/external/blosc.cmake index d19ff0f9..89d9e676 100644 --- a/cmake/recipes/external/blosc.cmake +++ b/cmake/recipes/external/blosc.cmake @@ -63,6 +63,8 @@ block() set(BUILD_BENCHMARKS OFF) set(PREFER_EXTERNAL_ZLIB ON) set(ZLIB_FOUND ON) + set(PREFER_EXTERNAL_ZSTD ON) + set(ZSTD_FOUND ON) ignore_package(ZLIB) include(miniz) @@ -73,6 +75,11 @@ block() set(ZLIB_INCLUDE_DIR "") set(ZLIB_LIBRARY ZLIB::ZLIB) + ignore_package(Zstd) + include(zstd) + set(ZSTD_INCLUDE_DIR "") + set(ZSTD_LIBRARY zstd::libzstd) + # Copy miniz.h as zlib.h to have blosc use miniz symbols (which are aliased through #define in miniz.h) FetchContent_GetProperties(miniz) file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/include") @@ -94,6 +101,7 @@ block() endforeach() unignore_package(ZLIB) + unignore_package(Zstd) endblock() set_target_properties(blosc_static PROPERTIES POSITION_INDEPENDENT_CODE ON) diff --git a/cmake/recipes/external/zstd.cmake b/cmake/recipes/external/zstd.cmake index 910d4241..30193ead 100644 --- a/cmake/recipes/external/zstd.cmake +++ b/cmake/recipes/external/zstd.cmake @@ -37,6 +37,8 @@ if(NOT TARGET zstd::libzstd) endif() endif() -if(TARGET libzstd_static) - set_target_properties(libzstd_static PROPERTIES FOLDER "third_party") -endif() +foreach(name IN ITEMS libzstd_static libzstd_shared uninstall clean-all) + if(TARGET ${name}) + set_target_properties(${name} PROPERTIES FOLDER "third_party") + endif() +endforeach() diff --git a/modules/bvh/examples/uv_overlap.cpp b/modules/bvh/examples/uv_overlap.cpp index f0ce31f8..c116a9bf 100644 --- a/modules/bvh/examples/uv_overlap.cpp +++ b/modules/bvh/examples/uv_overlap.cpp @@ -14,14 +14,17 @@ #include #include #include +#include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -108,12 +111,17 @@ void repack_overlapping_charts( for (uint32_t f = 0; f < num_facets; ++f) { split_ids[f] = static_cast(chart_ids[f] * num_colors + color_ids[f]); } + + // Compact per-facet ids so the downstream packer doesn't allocate empty slots for unused + // combinations. + auto compacted = lagrange::internal::compact_chart_ids( + lagrange::span(split_ids.data(), split_ids.size())); mesh.template create_attribute( "@split_chart_id", lagrange::AttributeElement::Facet, lagrange::AttributeUsage::Scalar, 1, - split_ids); + compacted.first); // 3. Split UV indices so that facets in different split charts don't share UV vertices lagrange::DisconnectUVChartsOptions disconnect_options; @@ -127,6 +135,44 @@ void repack_overlapping_charts( lagrange::packing::repack_uv_charts(mesh, repack_options); } +size_t apply_unflip(SurfaceMesh& mesh, const std::string& uv_attribute_name) +{ + // Resolve the target UV attribute before any mapping so the selection is stable. + // map_attribute_in_place() can delete/recreate attributes and alter iteration order, + // which would cause the default "first UV" selection to pick a different attribute. + std::string resolved_name = uv_attribute_name; + if (resolved_name.empty()) { + lagrange::AttributeMatcher matcher; + matcher.usages = lagrange::AttributeUsage::UV; + auto uv_id = lagrange::find_matching_attribute(mesh, matcher); + if (uv_id.has_value()) { + resolved_name = std::string(mesh.get_attribute_name(uv_id.value())); + } + } + + // unflip_uv_charts requires an indexed UV attribute; map only the target attribute if needed. + if (!resolved_name.empty() && mesh.has_attribute(resolved_name)) { + auto id = mesh.get_attribute_id(resolved_name); + if (mesh.get_attribute_base(id).get_element_type() != lagrange::AttributeElement::Indexed) { + lagrange::logger().info( + "Mapping non-indexed UV attribute '{}' to indexed for unflipping.", + resolved_name); + map_attribute_in_place(mesh, id, lagrange::AttributeElement::Indexed); + } + } + + lagrange::UnflipUVChartsOptions unflip_options; + unflip_options.uv_attribute_name = resolved_name; + size_t n = lagrange::unflip_uv_charts(mesh, unflip_options); + lagrange::UVOrientationOptions flip_options; + flip_options.uv_attribute_name = resolved_name; + size_t still_flipped = lagrange::compute_uv_orientation(mesh, flip_options).negative; + lagrange::logger().info( + "Number of flipped triangles after unflipping charts: {}.", + still_flipped); + return n; +} + // ============================================================================ // GUI state and callbacks // ============================================================================ @@ -198,6 +244,10 @@ void register_uv_mesh( auto& ca = mesh.template get_attribute(coloring_id); lagrange::polyscope::register_attribute(*ps_mesh, "uv_overlap_color", ca); } + if (mesh.has_attribute("@uv_orientation")) { + auto& fa = mesh.template get_attribute(mesh.get_attribute_id("@uv_orientation")); + lagrange::polyscope::register_attribute(*ps_mesh, "uv_orientation", fa); + } } /// Remove all polyscope structures and re-register them for the current view mode. @@ -290,6 +340,17 @@ void user_callback(DemoState& state) toggle_uv_view(state); } + if (ImGui::Button("Unflip UV Charts")) { + size_t n = apply_unflip(state.mesh_original, state.uv_attribute_name); + lagrange::logger().info("Unflipped {} chart(s).", n); + state.mesh_display = state.mesh_original; + prepare_mesh_for_display(state.mesh_display); + + auto camera_json = polyscope::view::getViewAsJson(); + register_view(state); + polyscope::view::setViewFromJson(camera_json, false); + } + if (state.has_coloring()) { ImGui::BeginDisabled(state.repacked); if (ImGui::Button("Repack UV Charts")) { @@ -332,6 +393,7 @@ int main(int argc, char** argv) bool gui = false; bool uv_view = false; bool repack = false; + bool unflip = false; int log_level = 2; } args; @@ -345,6 +407,10 @@ int main(int argc, char** argv) app.add_flag("--gui", args.gui, "Launch the Polyscope GUI to visualize results."); app.add_flag("--uv-view", args.uv_view, "Start in the 2D UV layout view (implies --gui)."); app.add_flag("--repack", args.repack, "Repack UV charts per overlap color layer."); + app.add_flag( + "--unflip", + args.unflip, + "Reverse winding of any UV chart with negative signed area."); app.add_option("-l,--level", args.log_level, "Log level (0 = most verbose, 6 = off)."); CLI11_PARSE(app, argc, argv) @@ -394,6 +460,18 @@ int main(int argc, char** argv) lagrange::logger().info("No UV overlap detected."); } + if (args.gui || args.unflip) { + lagrange::UVOrientationOptions orient_options; + orient_options.uv_attribute_name = args.uv_attribute_name; + size_t num_flipped = lagrange::compute_uv_orientation(mesh, orient_options).negative; + lagrange::logger().info("Flipped UV triangles: {}", num_flipped); + + if (args.unflip && num_flipped > 0) { + size_t num_unflipped = apply_unflip(mesh, args.uv_attribute_name); + lagrange::logger().info("Unflipped {} chart(s).", num_unflipped); + } + } + // GUI or CLI output if (args.gui) { polyscope::options::configureImGuiStyleCallback = []() { diff --git a/modules/core/examples/CMakeLists.txt b/modules/core/examples/CMakeLists.txt index 7366228c..a9220c6d 100644 --- a/modules/core/examples/CMakeLists.txt +++ b/modules/core/examples/CMakeLists.txt @@ -17,3 +17,6 @@ target_link_libraries(mesh_cleanup lagrange::core lagrange::io CLI11::CLI11) lagrange_add_example(fix_nonmanifold fix_nonmanifold.cpp) target_link_libraries(fix_nonmanifold lagrange::core lagrange::io CLI11::CLI11) + +lagrange_add_example(orient_outward orient_outward.cpp) +target_link_libraries(orient_outward lagrange::core lagrange::io CLI11::CLI11) diff --git a/modules/core/examples/mesh_cleanup.cpp b/modules/core/examples/mesh_cleanup.cpp index 07468b84..2f925b85 100644 --- a/modules/core/examples/mesh_cleanup.cpp +++ b/modules/core/examples/mesh_cleanup.cpp @@ -12,32 +12,23 @@ #include #include #include +#include #include -#include +#include #include #include +#include #include #include -Eigen::AlignedBox3d mesh_bbox(const lagrange::TriangleMesh3D& mesh) -{ - using Index = typename lagrange::TriangleMesh3D::Index; - Eigen::AlignedBox3d bbox; - la_runtime_assert(mesh.get_vertices().cols() == 3); - for (Index v = 0; v < mesh.get_num_vertices(); ++v) { - bbox.extend(mesh.get_vertices().row(v).transpose()); - } - return bbox; -} - int main(int argc, char** argv) { struct { std::string input; std::string output = "output.obj"; - double tol = 0.001; + float tol = 0.01f; size_t max_holes = 0; bool holes_only = false; bool relative = true; @@ -59,10 +50,10 @@ int main(int argc, char** argv) lagrange::logger().set_level(spdlog::level::trace); lagrange::logger().info("Loading input mesh: {}", args.input); - auto mesh = lagrange::io::load_mesh(args.input); + auto mesh = lagrange::io::load_mesh(args.input); - if (args.relative) { - double diag = mesh_bbox(*mesh).diagonal().norm(); + if (args.relative && mesh.get_num_vertices() > 0) { + float diag = static_cast(lagrange::mesh_bbox<3>(mesh).diagonal().norm()); lagrange::logger().info( "Using a relative tolerance of {:.3f} x {:.3f} = {:.3f}", args.tol, @@ -73,20 +64,27 @@ int main(int argc, char** argv) if (args.max_holes) { lagrange::logger().info("Closing small holes"); - mesh = lagrange::close_small_holes(*mesh, args.max_holes); + lagrange::CloseSmallHolesOptions holes_options; + holes_options.max_hole_size = args.max_holes; + lagrange::close_small_holes(mesh, holes_options); } if (!args.holes_only) { - lagrange::logger().info("Removing degenerate triangles"); - mesh = lagrange::remove_degenerate_triangles(*mesh); + lagrange::logger().info("Triangulating polygonal facets"); + lagrange::triangulate_polygonal_facets(mesh); lagrange::logger().info("Removing duplicate vertices"); - mesh = lagrange::remove_duplicate_vertices(*mesh); + lagrange::remove_duplicate_vertices(mesh); + lagrange::logger().info("Removing degenerate facets"); + lagrange::remove_degenerate_facets(mesh); lagrange::logger().info("Splitting long edges"); - mesh = lagrange::split_long_edges(*mesh, args.tol * args.tol, true); + lagrange::SplitLongEdgesOptions split_options; + split_options.max_edge_length = args.tol; + split_options.recursive = true; + lagrange::split_long_edges(mesh, split_options); } lagrange::logger().info("Saving result: {}", args.output); - lagrange::io::save_mesh(args.output, *mesh); + lagrange::io::save_mesh(args.output, mesh); return 0; } diff --git a/modules/core/examples/orient_outward.cpp b/modules/core/examples/orient_outward.cpp new file mode 100644 index 00000000..8408c432 --- /dev/null +++ b/modules/core/examples/orient_outward.cpp @@ -0,0 +1,55 @@ +/* + * 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 + +int main(int argc, char** argv) +{ + using Scalar = float; + using Index = uint32_t; + using Mesh = lagrange::SurfaceMesh; + + struct + { + std::string input; + std::string output = "output.obj"; + } args; + lagrange::OrientOptions options; + + CLI::App app{argv[0]}; + app.option_defaults()->always_capture_default(); + app.add_option("input", args.input, "Input mesh.")->required()->check(CLI::ExistingFile); + app.add_option("output", args.output, "Output mesh."); + app.add_option( + "-p,--positive", + options.positive, + "Whether to orient each volume positively or negatively."); + CLI11_PARSE(app, argc, argv) + + lagrange::logger().set_level(spdlog::level::trace); + + lagrange::logger().info("Loading input mesh: {}", args.input); + auto mesh = lagrange::io::load_mesh(args.input); + + lagrange::logger().info("Orienting facets ({})", options.positive ? "positive" : "negative"); + lagrange::orient_outward(mesh, options); + + lagrange::logger().info("Saving result: {}", args.output); + lagrange::io::save_mesh(args.output, mesh); + + return 0; +} diff --git a/modules/core/include/lagrange/CameraTransforms.h b/modules/core/include/lagrange/CameraTransforms.h new file mode 100644 index 00000000..3dccea68 --- /dev/null +++ b/modules/core/include/lagrange/CameraTransforms.h @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include + +namespace lagrange { + +/// +/// View and projection matrices defining a camera in world space. +/// +struct CameraTransforms +{ + /// Camera view transform (world space -> view space). + Eigen::Affine3f view = Eigen::Affine3f::Identity(); + + /// Camera projection transform (view space -> NDC space). + /// + /// This is the standard glTF/OpenGL projection matrix, where depth is remapped to [-1, 1] (near + /// plane to -1, far plane to 1). + Eigen::Projective3f projection = Eigen::Projective3f::Identity(); +}; + +} // namespace lagrange diff --git a/modules/core/include/lagrange/compute_area.h b/modules/core/include/lagrange/compute_area.h index c969e0f5..1ee3ad75 100644 --- a/modules/core/include/lagrange/compute_area.h +++ b/modules/core/include/lagrange/compute_area.h @@ -53,7 +53,9 @@ template AttributeId compute_facet_area(SurfaceMesh& mesh, FacetAreaOptions options = {}); /// -/// Compute per-facet area. +/// @overload +/// +/// Also applies an affine transformation to the mesh geometry before computing areas. /// /// @param[in,out] mesh The input mesh. /// @param[in] transformation Affine transformation to apply on mesh geometry. @@ -72,6 +74,9 @@ AttributeId compute_facet_area( const Eigen::Transform& transformation, FacetAreaOptions options = {}); +/// +/// Option struct for computing per-facet vector area. +/// struct FacetVectorAreaOptions { /// Output attribute name for facet vector area. @@ -96,17 +101,39 @@ struct FacetVectorAreaOptions /// [2] Alexa, Marc, and Max Wardetzky. "Discrete Laplacians on general polygonal meshes." ACM /// SIGGRAPH 2011 papers. 2011. 1-10. /// -/// @tparam Scalar Mesh scalar type. -/// @tparam Index Mesh index type. +/// @note Only 3D meshes are supported. /// /// @param[in,out] mesh The input mesh. /// @param[in] options The options controlling the computation. /// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return The attribute id of the facet vector area attribute. +/// +template +AttributeId compute_facet_vector_area( + SurfaceMesh& mesh, + FacetVectorAreaOptions options = {}); + +/// +/// @overload +/// +/// Also applies an affine transformation to the mesh geometry before computing vector areas. +/// +/// @param[in,out] mesh The input mesh. +/// @param[in] transformation Affine transformation to apply on mesh geometry. +/// @param[in] options The options controlling the computation. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// /// @return The attribute id of the facet vector area attribute. /// template AttributeId compute_facet_vector_area( SurfaceMesh& mesh, + const Eigen::Transform& transformation, FacetVectorAreaOptions options = {}); /// @@ -138,7 +165,9 @@ template Scalar compute_mesh_area(const SurfaceMesh& mesh, MeshAreaOptions options = {}); /// -/// Compute mesh area. +/// @overload +/// +/// Also applies an affine transformation to the mesh geometry before computing the total area. /// /// @param[in] mesh The input mesh. /// @param[in] transformation Affine transformation to apply on mesh geometry. diff --git a/modules/core/include/lagrange/compute_seam_edges.h b/modules/core/include/lagrange/compute_seam_edges.h index 9b3096c7..60e531a2 100644 --- a/modules/core/include/lagrange/compute_seam_edges.h +++ b/modules/core/include/lagrange/compute_seam_edges.h @@ -25,6 +25,9 @@ struct SeamEdgesOptions { /// Output attribute name. std::string_view output_attribute_name = "@seam_edges"; + + /// If true, boundary edges are also marked as seam edges. + bool include_boundary_edges = false; }; /// diff --git a/modules/core/include/lagrange/compute_uv_orientation.h b/modules/core/include/lagrange/compute_uv_orientation.h new file mode 100644 index 00000000..b78d3165 --- /dev/null +++ b/modules/core/include/lagrange/compute_uv_orientation.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 + +namespace lagrange { + +/// +/// @addtogroup group-surfacemesh-utils +/// @{ +/// + +struct UVOrientationOptions +{ + /// Input UV attribute name. + /// If empty, the first vertex/indexed UV attribute will be used. + std::string_view uv_attribute_name = ""; + + /// Output per-facet attribute name. Stores the raw `orient2D` result as `int8_t`: + /// `+1` for positively oriented (CCW), `0` for degenerate, `-1` for negatively oriented (CW). + std::string_view output_attribute_name = "@uv_orientation"; +}; + +/// Per-facet orientation counts returned by @ref compute_uv_orientation. +struct UVOrientationCount +{ + size_t positive = 0; ///< Number of CCW (positively oriented) facets. + size_t degenerate = 0; ///< Number of degenerate (zero-area) facets. + size_t negative = 0; ///< Number of CW (negatively oriented / flipped) facets. +}; + +/** + * Compute a per-facet orientation attribute using Shewchuk's exact `orient2D` predicate. + * + * Each facet is assigned an `int8_t` value matching the raw predicate result: + * - `+1`: positively oriented (CCW) in UV space. + * - `0`: degenerate (UV vertices are collinear). + * - `-1`: negatively oriented (CW / flipped) in UV space. + * + * @param mesh Input mesh. Must be a triangle mesh. + * @param options Options to control orientation computation. + * + * @tparam Scalar Mesh scalar type. + * @tparam Index Mesh index type. + * + * @return Counts of positively oriented, degenerate, and negatively oriented facets. + * + * @see @ref UVOrientationOptions + */ +template +UVOrientationCount compute_uv_orientation( + SurfaceMesh& mesh, + const UVOrientationOptions& options = {}); + +/// @} + +} // namespace lagrange diff --git a/modules/core/include/lagrange/internal/compact_chart_ids.h b/modules/core/include/lagrange/internal/compact_chart_ids.h new file mode 100644 index 00000000..e4677b95 --- /dev/null +++ b/modules/core/include/lagrange/internal/compact_chart_ids.h @@ -0,0 +1,56 @@ +/* + * 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 + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include +#include +#include + +namespace lagrange::internal { + +/// +/// Compact chart ids to a dense [0, N) range so per-chart storage is bounded by the number of +/// distinct charts, regardless of how sparse user-supplied chart ids may be. +/// +/// @param[in] chart_ids Per-facet chart ids (may be sparse). +/// +/// @tparam Index Index type. +/// +/// @return Pair of (dense per-facet chart ids, number of distinct charts). +/// +template +std::pair, Index> compact_chart_ids(span chart_ids) +{ + std::vector sorted_ids(chart_ids.begin(), chart_ids.end()); + tbb::parallel_sort(sorted_ids.begin(), sorted_ids.end()); + sorted_ids.erase(std::unique(sorted_ids.begin(), sorted_ids.end()), sorted_ids.end()); + const Index num_charts = static_cast(sorted_ids.size()); + + std::vector dense_chart_ids(chart_ids.size()); + tbb::parallel_for(Index(0), static_cast(chart_ids.size()), [&](Index f) { + dense_chart_ids[f] = static_cast( + std::lower_bound(sorted_ids.begin(), sorted_ids.end(), chart_ids[f]) - + sorted_ids.begin()); + }); + return {std::move(dense_chart_ids), num_charts}; +} + +} // namespace lagrange::internal diff --git a/modules/core/include/lagrange/internal/dijkstra.h b/modules/core/include/lagrange/internal/dijkstra.h index 5b83a5e4..63e4de6c 100644 --- a/modules/core/include/lagrange/internal/dijkstra.h +++ b/modules/core/include/lagrange/internal/dijkstra.h @@ -19,32 +19,74 @@ #include #include +#include + +#include +#include + namespace lagrange::internal { -/** - * Traverse the mesh based on Dijkstra's algorithm with customized distance - * metric and process functions. - * - * @tparam Scalar Mesh scalar type - * @tparam Index Mesh index type - * - * @param mesh The input mesh. - * @param seed_vertices Seed vertices. - * @param seed_vertex_dist Initial distance to the seed vertices. - * @param radius The radius of the search. Radius <= 0 denotes the search is over the - * entire mesh. - * @param dist The distance metric. e.g. `d = dist(v0, v1)` - * @param process Call back function to process each new vertex reached. Its return type - * indicates whether the search is done. - * e.g. `done = process(vid, v_dist)` - */ +/// +/// Reusable scratch buffers for Dijkstra traversal. Avoids per-call allocation when +/// dijkstra() is called repeatedly on the same mesh. +/// +template +struct DijkstraCache +{ + using Entry = std::pair; + std::priority_queue, std::greater> queue; + std::vector visited; + std::vector visited_edges; + std::vector chord_bridged; + std::vector edge_indices; +}; + +/// +/// Options for the Dijkstra traversal. +/// +template +struct DijkstraOptions +{ + /// Maximum geodesic distance from the seed. Vertices beyond this distance are not queued, + /// unless they fall within the Euclidean distance (when enabled). A value <= 0 means no + /// geodesic limit. + Scalar geodesic_radius = 0; + + /// Maximum Euclidean distance from `seed_position`. When > 0, traversal continues while + /// vertices are within the Euclidean distance, can bridge through out-of-scope vertices if the + /// chordal distance is still within Euclidean limits. + Scalar euclidean_radius = 0; + + /// Center of the Euclidean ball. Required when `euclidean_radius > 0`. + Eigen::Vector3 seed_position = Eigen::Vector3::Zero(); +}; + +/// +/// Traverse the mesh based on Dijkstra's algorithm with customized distance metric and process +/// functions. +/// +/// @param mesh The input mesh. +/// @param seed_vertices Seed vertices. +/// @param seed_vertex_dist Initial distance to the seed vertices. Assumed to be correct/minimal. +/// @param dijkstra_options Options controlling the traversal radius and behavior. +/// @param dist The distance metric. e.g. `d = dist(v0, v1)` +/// @param process Callback for each reached vertex: `process(vid, v_dist)` +/// @param cache Optional reusable scratch buffers to avoid per-call +/// allocation. When non-null, vectors are resized/reset at the +/// start of the call but their capacity is preserved across +/// calls. When null, a local cache is allocated. +/// +/// @tparam Scalar Mesh scalar type +/// @tparam Index Mesh index type +/// template void dijkstra( SurfaceMesh& mesh, span seed_vertices, span seed_vertex_dist, - Scalar radius, + const DijkstraOptions& dijkstra_options, const function_ref& dist, - const function_ref& process); + const function_ref& process, + DijkstraCache* cache = nullptr); } // namespace lagrange::internal diff --git a/modules/core/include/lagrange/internal/get_uv_attribute.h b/modules/core/include/lagrange/internal/get_uv_attribute.h index b09fcba1..4d5ad1af 100644 --- a/modules/core/include/lagrange/internal/get_uv_attribute.h +++ b/modules/core/include/lagrange/internal/get_uv_attribute.h @@ -12,11 +12,14 @@ #pragma once #include +#include +#include #include #include #include #include +#include namespace lagrange::internal { @@ -85,4 +88,49 @@ std::tuple, VectorView> ref_uv_attribute( SurfaceMesh& mesh, std::string_view uv_attribute_name = ""); +/// Tag carrying a UV scalar type, used to disambiguate the type passed back to +/// `dispatch_uv_scalar_type` callers without relying on C++20 template lambdas. +template +struct UVScalarTag +{ + using type = T; +}; + +/// +/// Dispatch on the scalar type of a UV attribute. +/// +/// Locates the (indexed or vertex) UV attribute on @p mesh, then invokes @p visitor with a +/// `UVScalarTag` and the resolved attribute id. UVScalar is either the mesh scalar +/// type, or its float/double counterpart if the UV attribute uses the other type. +/// +/// @param mesh The mesh to look up the UV attribute on. +/// @param options UV attribute lookup options. +/// @param caller Name of the calling function, used in the error message when no UV +/// attribute is found. +/// @param visitor A callable of the form +/// `auto(UVScalarTag, AttributeId) -> R`. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// @tparam F Visitor type. +/// +/// @return Whatever @p visitor returns. +/// +template +auto dispatch_uv_scalar_type( + SurfaceMesh& mesh, + const UVMeshOptions& options, + std::string_view caller, + F&& visitor) +{ + using OtherScalar = std::conditional_t, double, float>; + if (auto id = uv_attribute_id(mesh, options)) { + return visitor(UVScalarTag{}, *id); + } + if (auto id = uv_attribute_id(mesh, options)) { + return visitor(UVScalarTag{}, *id); + } + throw Error(format("{}: no suitable UV attribute found.", caller)); +} + } // namespace lagrange::internal diff --git a/modules/core/include/lagrange/unflip_uv_charts.h b/modules/core/include/lagrange/unflip_uv_charts.h new file mode 100644 index 00000000..ac3aef8f --- /dev/null +++ b/modules/core/include/lagrange/unflip_uv_charts.h @@ -0,0 +1,73 @@ +/* + * 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 { + +/// +/// @addtogroup group-surfacemesh-utils +/// @{ +/// + +struct UnflipUVChartsOptions +{ + /// Input UV attribute name. + /// If empty, the first indexed UV attribute will be used. The UV attribute must be indexed. + std::string_view uv_attribute_name = ""; + + /// Optional per-facet chart id attribute name. + /// If empty, chart ids are computed automatically using edge connectivity on the UV mesh. + std::string_view chart_id_attribute_name = ""; +}; + +/** + * Mirror the UV positions of every UV vertex in any chart that is "flipped". + * + * A chart is considered flipped when either: + * - its total signed UV area (with each facet's `|area|` signed by the per-facet flip bit from + * @ref compute_uv_orientation) is negative, OR + * - the chart contains at least one flipped triangle, and no positively-oriented triangles (i.e. + * triangles are either flipped or degenerate). + * + * The second predicate catches charts whose floating-point signed area happens to be non-negative + * because of nearly-degenerate triangles, but whose every non-degenerate triangle is still oriented + * incorrectly. + * + * For each flipped chart, the U coordinate of every UV vertex referenced by that chart's facets is + * negated in place (`u <- -u`). This inverts each triangle's orientation in UV space without + * touching the mesh's corner ordering or topology. Assumes UV vertices are not shared across + * charts (the typical case for charts produced by @ref disconnect_uv_charts or + * @ref compute_uv_charts on indexed UV attributes). This fixes globally mirrored charts, but it + * does not repair charts with locally inconsistent per-facet winding. + * + * @param mesh Input triangle mesh. The UV attribute must be indexed. + * @param options Options to control chart unflipping. + * + * @tparam Scalar Mesh scalar type. + * @tparam Index Mesh index type. + * + * @return The number of charts that were unflipped. + * + * @see @ref UnflipUVChartsOptions, @ref compute_uv_orientation + */ +template +size_t unflip_uv_charts( + SurfaceMesh& mesh, + const UnflipUVChartsOptions& options = {}); + +/// @} + +} // namespace lagrange diff --git a/modules/core/include/lagrange/utils/fmt/join.h b/modules/core/include/lagrange/utils/fmt/join.h index b312bbde..3f742750 100644 --- a/modules/core/include/lagrange/utils/fmt/join.h +++ b/modules/core/include/lagrange/utils/fmt/join.h @@ -14,43 +14,56 @@ /// /// @file fmt/join.h /// -/// Provides `lagrange::join` — a uniform wrapper that dispatches to either a custom formattable -/// view (when `SPDLOG_USE_STD_FORMAT` is defined) or `fmt::join` (the default). For ranges, the -/// outer format spec (e.g. `{:.3g}`) is applied to each element, matching `fmt::join` semantics. -/// Tuple/pair joins only support `{}` (elements may have mixed types). +/// Provides `lagrange::join` — a wrapper that returns a custom formattable view (in +/// `lagrange::fmt_detail`). For ranges, the outer format spec (e.g. `{:.3g}`) is applied to each +/// element, matching `fmt::join` semantics. Tuple/pair joins only support `{}` (elements may +/// have mixed types). +/// +/// The view exposes both `std::formatter` and `fmt::formatter` specializations (each guarded by +/// the availability of its respective backend) so that whichever `format` overload is picked at +/// the call site — including `std::format` selected via ADL on `fmt::join_view` template +/// arguments under MSVC 14.50 / VS 2026 — finds a matching formatter. /// -#ifdef SPDLOG_USE_STD_FORMAT +// fmt is reachable whenever spdlog isn't running in pure-std::format mode. +#if !defined(SPDLOG_USE_STD_FORMAT) +// clang-format off + #include + #include + #include +// 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 - #include - #include - #include - #include - #include +#endif + +#include +#include +#include +#include +#include namespace lagrange { /// @cond LA_INTERNAL_DOCS namespace fmt_detail { -/// Lightweight range concept — avoids the heavy `` header. -template -concept range = requires(R& r) { - std::begin(r); - std::end(r); -}; - -/// Element type of a range. +/// Element type of a range (C++17-compatible substitute for `std::remove_cvref_t`). template using range_value_t = - std::remove_cvref_t&>()))>; + std::remove_cv_t()))>>; -/// Lazy view returned by `lagrange::join` for ranges. +/// Lazy view returned by `lagrange::join` for ranges. Stores the range by pointer so that the +/// `Range` template parameter is always a value (non-reference) type — some MSVC versions +/// (e.g. 14.50) reject reference-typed template arguments inside formatter specializations during +/// compile-time format-string parsing. template struct range_join_view { - Range range; + const Range* range; std::string_view sep; }; @@ -58,40 +71,87 @@ struct range_join_view template struct tuple_join_view { - const Tuple& tuple; + const Tuple* tuple; std::string_view sep; }; +/// Backend-agnostic formatting helpers. `format_elem` writes one element to `ctx.out()` and +/// returns the advanced iterator; the helper handles the separator interleaving. +template +auto format_range(const range_join_view& jv, FormatContext& ctx, Func&& format_elem) +{ + auto out = ctx.out(); + bool first = true; + for (const auto& elem : *jv.range) { + if (!first) { + for (auto c : jv.sep) *out++ = c; + ctx.advance_to(out); + } + out = format_elem(elem, ctx); + first = false; + } + return out; +} + +template +auto format_tuple(const tuple_join_view& jv, FormatContext& ctx, Func&& format_elem) +{ + auto out = ctx.out(); + bool first = true; + std::apply( + [&](const auto&... elems) { + ( + [&](const auto& elem) { + if (!first) { + for (auto c : jv.sep) *out++ = c; + ctx.advance_to(out); + } + out = format_elem(elem, ctx); + first = false; + }(elems), + ...); + }, + *jv.tuple); + return out; +} + } // namespace fmt_detail /// @endcond /// Join all elements of @p r into a formattable view separated by @p sep. The outer format spec -/// (e.g. `{:.3g}`) is applied to each element, matching `fmt::join` semantics. -template -auto join(Range&& r, std::string_view sep) +/// (e.g. `{:.3g}`) is applied to each element, matching `fmt::join` semantics. The view stores a +/// pointer to @p r — the temporary must outlive the formatting call (true for the typical +/// `format(..., join(r, ","))` pattern). Tuple/pair overloads below take precedence by partial +/// ordering when @p r is a `std::tuple` or `std::pair`. +template +auto join(const Range& r, std::string_view sep) { - return fmt_detail::range_join_view{std::forward(r), sep}; + return fmt_detail::range_join_view{&r, sep}; } /// Overload for `std::tuple` (not a range; mirrors `fmt::join` tuple support). template auto join(const std::tuple& t, std::string_view sep) { - return fmt_detail::tuple_join_view>{t, sep}; + return fmt_detail::tuple_join_view>{&t, sep}; } /// Overload for `std::pair` (not a range; mirrors `fmt::join` pair support). template auto join(const std::pair& p, std::string_view sep) { - return fmt_detail::tuple_join_view>{p, sep}; + return fmt_detail::tuple_join_view>{&p, sep}; } } // namespace lagrange -/// Formatter for `lagrange::fmt_detail::range_join_view`. Delegates the format spec to the -/// element formatter so that e.g. `format("{:.3g}", join(vec, ", "))` formats each float with -/// `.3g` precision. +// 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) + template struct std::formatter, char> { @@ -103,22 +163,14 @@ struct std::formatter, char> auto format(const lagrange::fmt_detail::range_join_view& jv, std::format_context& ctx) const { - auto out = ctx.out(); - bool first = true; - for (const auto& elem : jv.range) { - if (!first) { - for (auto c : jv.sep) *out++ = c; - ctx.advance_to(out); - } - out = m_elem.format(elem, ctx); - first = false; - } - return out; + return lagrange::fmt_detail::format_range( + jv, + ctx, + [this](const auto& elem, std::format_context& c) { return m_elem.format(elem, c); }); } }; -/// Formatter for `lagrange::fmt_detail::tuple_join_view`. Each element is formatted with `{}`. -/// Custom format specs are not supported for tuple/pair joins (elements may have mixed types). +/// Tuple/pair joins only support `{}` (elements may have mixed types). template struct std::formatter, char> { @@ -133,43 +185,60 @@ struct std::formatter, char> auto format(const lagrange::fmt_detail::tuple_join_view& jv, std::format_context& ctx) const { - auto out = ctx.out(); - bool first = true; - std::apply( - [&](const auto&... elems) { - ( - [&](const auto& elem) { - if (!first) { - for (auto c : jv.sep) *out++ = c; - ctx.advance_to(out); - } - out = std::format_to(out, "{}", elem); - first = false; - }(elems), - ...); - }, - jv.tuple); - return out; + return lagrange::fmt_detail::format_tuple(jv, ctx, [](const auto& elem, auto& c) { + return std::format_to(c.out(), "{}", elem); + }); } }; -#else - -// clang-format off -#include -#include -#include -// clang-format on +#endif // defined(SPDLOG_USE_STD_FORMAT) || defined(__cpp_lib_format) -namespace lagrange { +// 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) -/// Join all elements of @p r into a formattable view separated by @p sep using `fmt::join`. template -auto join(Range&& r, std::string_view sep) +struct fmt::formatter, char> { - return fmt::join(std::forward(r), sep); -} + using value_type = lagrange::fmt_detail::range_value_t; + fmt::formatter m_elem; -} // namespace lagrange + template + constexpr auto parse(ParseContext& ctx) + { + return m_elem.parse(ctx); + } -#endif + 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, FormatContext& c) { return m_elem.format(elem, c); }); + } +}; + +template +struct fmt::formatter, char> +{ + template + constexpr auto parse(ParseContext& ctx) + { + if (ctx.begin() != ctx.end() && *ctx.begin() != '}') { + throw fmt::format_error("format spec not supported for tuple/pair join"); + } + return ctx.begin(); + } + + 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 fmt::format_to(c.out(), "{}", elem); + }); + } +}; + +#endif // !defined(SPDLOG_USE_STD_FORMAT) diff --git a/modules/core/include/lagrange/utils/geometry3d.h b/modules/core/include/lagrange/utils/geometry3d.h index 1e8b7d93..21c4fe32 100644 --- a/modules/core/include/lagrange/utils/geometry3d.h +++ b/modules/core/include/lagrange/utils/geometry3d.h @@ -110,11 +110,13 @@ auto vector_between(const MeshType& mesh, typename MeshType::Index v1, typename } /// -/// Build an orthogonal frame given a single vector. +/// Build an orthogonal frame given a single vector. Implements the branchless basis from +/// Duff et al., "Building an Orthonormal Basis, Revisited", JCGT 2017 +/// (https://jcgt.org/published/0006/01/01/). /// -/// @param[in] x First vector of the frame. -/// @param[out] y Second vector of the frame. -/// @param[out] z Third vector of the frame. +/// @param[in] x First vector of the frame, may be non-unit. +/// @param[out] y Second vector of the frame, normalized. +/// @param[out] z Third vector of the frame, normalized. /// /// @tparam Scalar Scalar type. /// @@ -124,23 +126,13 @@ void orthogonal_frame( Eigen::Matrix& y, Eigen::Matrix& z) { - int imin; - x.array().abs().minCoeff(&imin); - Eigen::Matrix u; - for (int i = 0, s = -1; i < 3; ++i) { - if (i == imin) { - u[i] = 0; - } else { - int j = (i + 1) % 3; - if (j == imin) { - j = (i + 2) % 3; - } - u[i] = s * x[j]; - s *= -1; - } - } - z = x.cross(u).stableNormalized(); - y = z.cross(x).stableNormalized(); + constexpr Scalar one(1); + const Eigen::Matrix n = x.stableNormalized(); + const Scalar sign = std::copysign(one, n.z()); + const Scalar a = -one / (sign + n.z()); + const Scalar b = n.x() * n.y() * a; + y = Eigen::Matrix(one + sign * n.x() * n.x() * a, sign * b, -sign * n.x()); + z = Eigen::Matrix(b, sign + n.y() * n.y() * a, -n.y()); } /// Returns the circumcenter of a 3D triangle. diff --git a/modules/core/js/src/core_utilities.cpp b/modules/core/js/src/core_utilities.cpp index 27837d67..b448a164 100644 --- a/modules/core/js/src/core_utilities.cpp +++ b/modules/core/js/src/core_utilities.cpp @@ -192,8 +192,12 @@ EMSCRIPTEN_BINDINGS(lagrange_core_utilities) function( "computeSeamEdges", - +[](MeshType& mesh, unsigned indexed_attribute_id) { - compute_seam_edges(mesh, static_cast(indexed_attribute_id)); + +[](MeshType& mesh, unsigned indexed_attribute_id, val opts) { + SeamEdgesOptions o; + if (!opts.isUndefined()) { + apply_opt(opts, "includeBoundaryEdges", o.include_boundary_edges); + } + compute_seam_edges(mesh, static_cast(indexed_attribute_id), o); }); function("computeVertexValence", +[](MeshType& mesh) { compute_vertex_valence(mesh); }); diff --git a/modules/core/js/ts/core.ts b/modules/core/js/ts/core.ts index 203c23d0..9d7a3924 100644 --- a/modules/core/js/ts/core.ts +++ b/modules/core/js/ts/core.ts @@ -253,7 +253,7 @@ export interface CoreModule { * (different attribute values on the two sides). Adds a per-edge * boolean attribute. In-place. */ - computeSeamEdges(mesh: SurfaceMesh, indexedAttributeId: number): void; + computeSeamEdges(mesh: SurfaceMesh, indexedAttributeId: number, opts?: SeamEdgesOptions): void; /** Attach a per-vertex valence (incident-edge count) attribute. In-place. */ computeVertexValence(mesh: SurfaceMesh): void; /** @@ -389,7 +389,10 @@ export interface OrientOptions { positive?: boolean; } -export interface SeamEdgesOptions {} +export interface SeamEdgesOptions { + /** If `true`, boundary edges are also marked as seam edges. Default: `false`. */ + includeBoundaryEdges?: boolean; +} export interface VertexValenceOptions {} diff --git a/modules/core/python/scripts/meshstat.py b/modules/core/python/scripts/meshstat.py index 2561d546..017c70e1 100755 --- a/modules/core/python/scripts/meshstat.py +++ b/modules/core/python/scripts/meshstat.py @@ -11,335 +11,71 @@ # OF ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. # -"""Print basic information about a mesh file""" +"""Command-line entry point for ``lagrange.scripts.meshstat``. -import argparse -import colorama # type: ignore -import json -import lagrange -import numpy as np -import pathlib -import platform - -if platform.system() == "Windows": - colorama.just_fix_windows_console() - - -def print_header(message): - print(colorama.Fore.YELLOW + colorama.Style.BRIGHT + message + colorama.Style.RESET_ALL) - - -def print_green(message): - print(colorama.Fore.GREEN + message + colorama.Style.RESET_ALL) - - -def print_red(message): - print(colorama.Fore.RED + message + colorama.Style.RESET_ALL) - - -def print_section_header(val): - print_green(f"{val:_^55}") - - -def print_property(name, val, expected=None): - if expected is not None and val != expected: - print_red(f"{name:-<48}: {val}") - else: - print(f"{name:-<48}: {val}") - - -def print_basic_info(mesh, info): - print_section_header("Basic information") - print(f"dim: {mesh.dimension}") - - # Vertex/facet/edge/corner count - num_vertices = mesh.num_vertices - num_facets = mesh.num_facets - num_edges = mesh.num_edges - num_corners = mesh.num_corners - info["num_vertices"] = num_vertices - info["num_facets"] = num_facets - info["num_edges"] = num_edges - info["num_corners"] = num_corners - print(f"#v: {num_vertices:<10}#f: {num_facets:<10}#e: {num_edges:<10}#c: {num_corners:<10}") - - # Mesh bbox - bbox_min = np.amin(mesh.vertices, axis=0) - bbox_max = np.amax(mesh.vertices, axis=0) - if mesh.dimension == 3: - print(f"bbox min: [{bbox_min[0]:>10.3f} {bbox_min[1]:>10.3f} {bbox_min[2]:>10.3f}]") - print(f"bbox max: [{bbox_max[0]:>10.3f} {bbox_max[1]:>10.3f} {bbox_max[2]:>10.3f}]") - elif mesh.dimension == 2: - print(f"bbox min: [{bbox_min[0]:>10.3f} {bbox_min[1]:>10.3f}]") - print(f"bbox max: [{bbox_max[0]:>10.3f} {bbox_max[1]:>10.3f}]") - else: - print_red(f"Unsupported dimension: {mesh.dimension}") - - # vertex_per_facet - if mesh.is_regular: - if mesh.vertex_per_facet == 3: - print("facet type: triangles") - elif mesh.vertex_per_facet == 4: - print("facet type: quads") - else: - print(f"facet type: polygons ({mesh.vertex_per_facet})") - - info["is_regular"] = True - info["vertex_per_facet"] = mesh.vertex_per_facet - else: - print_red("facet type: hybrid") - info["is_regular"] = False - - two_gons = 0 - triangles = 0 - quads = 0 - polygons = 0 - for fid in range(mesh.num_facets): - f_size = mesh.get_facet_size(fid) - match f_size: - case 2: - two_gons += 1 - case 3: - triangles += 1 - case 4: - quads += 1 - case _: - polygons += 1 - if two_gons > 0: - print_red(f" # 2-gons: {two_gons}") - if triangles > 0: - print(f" # triangles: {triangles}") - if quads > 0: - print(f" # quads: {quads}") - if polygons > 0: - print(f" # polygons (n>4): {polygons}") - - -def print_extra_info(mesh, info): - # components - num_components = lagrange.compute_components(mesh) - print_property("num components", num_components) - info["num_components"] = num_components +Configures a colored stderr handler on the library logger and forwards to +:func:`lagrange.scripts.meshstat.main`. +""" - # boundary check - bd_edges = lagrange.extract_boundary_edges(mesh) - print_property("closed", len(bd_edges) == 0, True) - if len(bd_edges) > 0: - print_property("num boundary edges", len(bd_edges), 0) - bd_loops = lagrange.extract_boundary_loops(mesh) - print_property("num boundary loops", len(bd_loops), 0) +from __future__ import annotations - # manifold check - edge_manifold = lagrange.is_edge_manifold(mesh) - vertex_manifold = lagrange.is_vertex_manifold(mesh) - - info["edge_manifold"] = edge_manifold - info["vertex_manifold"] = vertex_manifold - info["manifold"] = edge_manifold and vertex_manifold - print_property("manifold", info["manifold"], True) - - if not vertex_manifold: - vertex_manifold_attr_id = lagrange.compute_vertex_is_manifold(mesh) - vertex_manifold_data = mesh.attribute(vertex_manifold_attr_id).data - num_non_manifold_vertices = int(np.sum(vertex_manifold_data == 0)) - print_property("non-manifold vertices", num_non_manifold_vertices, 0) - info["nonmanifold_vertices"] = num_non_manifold_vertices - else: - print_property("vertex manifold", vertex_manifold, True) - info["nonmanifold_vertices"] = 0 - - if not edge_manifold: - assert not vertex_manifold # not edge manifold implies not vertex manifold - edge_manifold_attr_id = lagrange.compute_edge_is_manifold(mesh) - edge_manifold_data = mesh.attribute(edge_manifold_attr_id).data - num_non_manifold_edges = int(np.sum(edge_manifold_data == 0)) - print_property("non-manifold edges", num_non_manifold_edges, 0) - info["nonmanifold_edges"] = num_non_manifold_edges - else: - print_property("edge manifold", edge_manifold, True) - info["nonmanifold_edges"] = 0 - - # orientability check - is_orientable = lagrange.is_oriented(mesh) - info["orientable"] = is_orientable - if not is_orientable: - edge_oriented_attr_id = lagrange.compute_edge_is_oriented(mesh) - edge_oriented_data = mesh.attribute(edge_oriented_attr_id).data - num_non_oriented_edges = int(np.sum(edge_oriented_data == 0)) - print_property("non-oriented edges", num_non_oriented_edges, 0) - info["nonoriented_edges"] = num_non_oriented_edges - else: - print_property("orientable", is_orientable, True) - info["nonoriented_edges"] = 0 - - # Degeneracy check - num_degenerate_facets = lagrange.detect_degenerate_facets(mesh) - info["degenerate_facets"] = num_degenerate_facets - print_property("num degenerate facets", len(num_degenerate_facets), 0) - - # Isolated vertices check - vertex_valence_id = lagrange.compute_vertex_valence(mesh) - vertex_valence = mesh.attribute(vertex_valence_id).data - num_isolated_vertices = int(np.sum(vertex_valence == 0)) - info["num_isolated_vertices"] = num_isolated_vertices - print_property("num isolated vertices", num_isolated_vertices, 0) - - # UV check - uv_ids = mesh.get_matching_attribute_ids(usage=lagrange.AttributeUsage.UV) - if mesh.is_triangle_mesh and len(uv_ids) > 0: - for uv_attr_id in uv_ids: - uv_attr_name = mesh.get_attribute_name(uv_attr_id) - if not mesh.is_attribute_indexed(uv_attr_id): - indexed_uv_attr_name = lagrange.get_unique_attribute_name( - mesh, f"{uv_attr_name}_indexed", emit_warning=False - ) - uv_attr_id = lagrange.map_attribute( - mesh, uv_attr_id, indexed_uv_attr_name, lagrange.AttributeElement.Indexed - ) - distortion_id = lagrange.compute_uv_distortion( - mesh, - mesh.get_attribute_name(uv_attr_id), - metric=lagrange.DistortionMetric.AreaRatio, - ) - distortion = mesh.attribute(distortion_id).data - num_flipped_uv = int(np.sum(distortion < 0)) - print_property(f"{uv_attr_name}: num flipped UV facets", num_flipped_uv, 0) - info[f"{uv_attr_name}:num_flipped_uv"] = num_flipped_uv - - if mesh.indexed_attribute(uv_attr_id).values.dtype != np.float64: - new_uv_attr_name = lagrange.get_unique_attribute_name( - mesh, f"{uv_attr_name}_float64", emit_warning=False - ) - uv_attr_id = lagrange.cast_attribute(mesh, uv_attr_id, np.float64, new_uv_attr_name) - uv_mesh = lagrange.uv_mesh_view(mesh, mesh.get_attribute_name(uv_attr_id)) - charts = lagrange.separate_by_components(uv_mesh) - print_property(f"{uv_attr_name}: num charts", len(charts)) - info[f"{uv_attr_name}:num_charts"] = len(charts) - - # Intersecting pairs check - if mesh.dimension == 3: - # Triangulate mesh if needed for intersection check - if mesh.is_triangle_mesh: - mesh_to_check = mesh - else: - mesh_to_check = mesh.clone() - lagrange.triangulate_polygonal_facets(mesh_to_check) - - intersecting_pairs = lagrange.bvh.compute_intersecting_pairs(mesh_to_check) - num_intersecting_pairs = len(intersecting_pairs) - info["num_intersecting_pairs"] = num_intersecting_pairs - print_property("num intersecting pairs", num_intersecting_pairs, 0) - - -def usage_to_str(usage): - return str(usage).split(".")[-1] - - -def element_to_str(element): - return str(element).split(".")[-1] - - -def dtype_to_str(dtype: np.dtype): - match dtype: - case np.float32: - dtype_str = "float32" - case np.float64: - dtype_str = "float64" - case np.int32: - dtype_str = "int32" - case np.int64: - dtype_str = "int64" - case np.uint32: - dtype_str = "uint32" - case np.uint64: - dtype_str = "uint64" - case _: - dtype_str = str(dtype) - return dtype_str - - -def print_attributes(mesh): - for id in mesh.get_matching_attribute_ids(): - name = mesh.get_attribute_name(id) - if name.startswith("@"): - continue - is_indexed = mesh.is_attribute_indexed(id) - if is_indexed: - attr = mesh.indexed_attribute(id) - else: - attr = mesh.attribute(id) - usage = usage_to_str(attr.usage) - element_type = element_to_str(attr.element_type) - num_channels = attr.num_channels - if not is_indexed: - dtype = dtype_to_str(attr.dtype) - else: - dtype = dtype_to_str(attr.values.dtype) - - print(f"Attribute {colorama.Fore.GREEN}{name}{colorama.Style.RESET_ALL} ({dtype})") - print(f" id:{id:<5}usage: {usage:<10}elem: {element_type:<10}channels: {num_channels}") +import logging +import platform +import sys +import colorama # type: ignore +import lagrange.scripts.meshstat as meshstat -def load_info(mesh_file): - mesh_file = pathlib.Path(mesh_file) - info_file = mesh_file.with_suffix(".json") - info = {} - if info_file.exists(): - with open(info_file, "r") as fin: - try: - info = json.load(fin) - except ValueError: - print_red(f"Cannot parse {info_file}, overwriting it") - return info +class _ColorFormatter(logging.Formatter): + _COLORS = { + logging.WARNING: colorama.Fore.YELLOW, + logging.ERROR: colorama.Fore.RED, + logging.CRITICAL: colorama.Fore.RED + colorama.Style.BRIGHT, + } -def save_info(mesh_file, info): - mesh_file = pathlib.Path(mesh_file) - info_file = mesh_file.with_suffix(".json") + def format(self, record: logging.LogRecord) -> str: + msg = super().format(record) + color = self._COLORS.get(record.levelno) + if color is None: + return msg + return f"{color}{msg}{colorama.Style.RESET_ALL}" - with open(info_file, "w") as fout: - json.dump(info, fout, indent=4, sort_keys=True) +class _StderrHandler(logging.StreamHandler): + """StreamHandler that resolves :data:`sys.stderr` on each emit.""" -def parse_args(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--extended", - "-x", - action="store_true", - help="check for extended information such as number of components, manifoldness and more", - ) - parser.add_argument( - "--attribute", - "-a", - action="store_true", - help="print attribute information", - ) - parser.add_argument( - "--export", "-e", action="store_true", help="export stats into a .info file" - ) - parser.add_argument("input_mesh", help="input mesh file") - return parser.parse_args() + def __init__(self) -> None: + super().__init__() + @property + def stream(self): + return sys.stderr -def main(): - args = parse_args() - mesh = lagrange.io.load_mesh(args.input_mesh, quiet=True) - mesh.initialize_edges() - info = load_info(args.input_mesh) + @stream.setter + def stream(self, value): # ignored; resolved dynamically + pass - header = f"Summary of {args.input_mesh}" - print_header(f"{header:=^55}") - print_basic_info(mesh, info) - if args.extended: - print_extra_info(mesh, info) - if args.attribute: - print_attributes(mesh) +def _configure_logging() -> None: + """Install a stderr handler emitting WARNING+ records on the meshstat logger. - if args.export: - save_info(args.input_mesh, info) + Idempotent: re-invoking this function in the same interpreter does not + install a duplicate handler. + """ + if platform.system() == "Windows": + colorama.just_fix_windows_console() + logger = logging.getLogger(meshstat.__name__) + if any(isinstance(h, _StderrHandler) for h in logger.handlers): + return + handler = _StderrHandler() + handler.setLevel(logging.WARNING) + handler.setFormatter(_ColorFormatter("%(levelname)s: %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.WARNING) + logger.propagate = False if __name__ == "__main__": - main() + _configure_logging() + raise SystemExit(meshstat.main()) diff --git a/modules/core/python/src/bind_camera_transforms.h b/modules/core/python/src/bind_camera_transforms.h new file mode 100644 index 00000000..b30cd651 --- /dev/null +++ b/modules/core/python/src/bind_camera_transforms.h @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +namespace lagrange::python { + +namespace nb = nanobind; + +inline void bind_camera_transforms(nb::module_& m) +{ + nb::class_( + m, + "CameraTransforms", + "View and projection matrices defining a camera in world space.") + .def(nb::init<>()) + .def_prop_rw( + "view", + [](const CameraTransforms& self) -> Eigen::Matrix4f { return self.view.matrix(); }, + [](CameraTransforms& self, + std::variant> mat_var) { + Eigen::Matrix4f full = Eigen::Matrix4f::Identity(); + if (auto* mat = std::get_if(&mat_var)) { + la_runtime_assert( + (mat->row(3).array() == Eigen::RowVector4f(0.f, 0.f, 0.f, 1.f).array()) + .all(), + "Last row of 4x4 view matrix must be [0, 0, 0, 1]"); + full = *mat; + } else { + full.topRows<3>() = std::get>(mat_var); + } + self.view = Eigen::Affine3f(full); + }, + R"(4×4 view transform (world space -> view space). + +Accepts a ``(4, 4)`` or ``(3, 4)`` numpy array: + +- ``(4, 4)``: full homogeneous matrix; last row must be ``[0, 0, 0, 1]``. +- ``(3, 4)``: compact ``[R | t]`` form; the implicit last row ``[0, 0, 0, 1]`` is appended + automatically. + +The getter always returns a ``(4, 4)`` numpy array.)") + .def_prop_rw( + "projection", + [](const CameraTransforms& self) -> Eigen::Matrix4f { + return self.projection.matrix(); + }, + [](CameraTransforms& self, const Eigen::Matrix4f& mat) { + self.projection = Eigen::Projective3f(mat); + }, + "4x4 projection transform (view space -> NDC space, depth in [-1, 1])."); +} + +} // namespace lagrange::python diff --git a/modules/core/python/src/bind_utilities.h b/modules/core/python/src/bind_utilities.h index 8a372d54..69736c2d 100644 --- a/modules/core/python/src/bind_utilities.h +++ b/modules/core/python/src/bind_utilities.h @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -59,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -531,19 +533,23 @@ Vertices listed in `cone_vertices` are considered as cone vertices, which is alw "compute_seam_edges", [](MeshType& mesh, AttributeId indexed_attribute_id, - std::optional output_attribute_name) { + std::optional output_attribute_name, + bool include_boundary_edges) { SeamEdgesOptions options; if (output_attribute_name) options.output_attribute_name = *output_attribute_name; + options.include_boundary_edges = include_boundary_edges; return compute_seam_edges(mesh, indexed_attribute_id, options); }, "mesh"_a, "indexed_attribute_id"_a, "output_attribute_name"_a = nb::none(), + "include_boundary_edges"_a = SeamEdgesOptions().include_boundary_edges, R"(Compute seam edges for a given indexed attribute. :param mesh: Input mesh. :param indexed_attribute_id: Input indexed attribute id. :param output_attribute_name: Output attribute name. +:param include_boundary_edges: If true, boundary edges are also marked as seam edges. :returns: Attribute id for the output per-edge seam attribute (1 is a seam, 0 is not).)"); @@ -2139,12 +2145,77 @@ found after ``max_increment`` attempts. "connectivity_type"_a = "Edge", R"(Compute UV charts. -@param mesh: Input mesh. -@param uv_attribute_name: Name of the UV attribute. -@param output_attribute_name: Name of the output attribute to store the chart ids. -@param connectivity_type: Type of connectivity to use for chart computation. Can be "Vertex" or "Edge". +:param mesh: Input mesh. +:param uv_attribute_name: Name of the UV attribute. +:param output_attribute_name: Name of the output attribute to store the chart ids. +:param connectivity_type: Type of connectivity to use for chart computation. Can be "Vertex" or "Edge". + +:returns: The number of charts.)"); + + nb::class_(m, "UVOrientationCount", "Counts of per-facet UV orientations.") + .def(nb::init<>()) + .def_rw( + "positive", + &UVOrientationCount::positive, + "Number of CCW (positively oriented) facets.") + .def_rw( + "degenerate", + &UVOrientationCount::degenerate, + "Number of degenerate (zero-area) facets.") + .def_rw( + "negative", + &UVOrientationCount::negative, + "Number of CW (negatively oriented / flipped) facets."); + + m.def( + "compute_uv_orientation", + [](MeshType& mesh, + std::string_view uv_attribute_name, + std::string_view output_attribute_name) { + UVOrientationOptions options; + options.uv_attribute_name = uv_attribute_name; + options.output_attribute_name = output_attribute_name; + return compute_uv_orientation(mesh, options); + }, + "mesh"_a, + "uv_attribute_name"_a = UVOrientationOptions().uv_attribute_name, + "output_attribute_name"_a = UVOrientationOptions().output_attribute_name, + R"(Compute a per-facet orientation attribute using Shewchuk's exact ``orient2D`` predicate. + +Each facet is assigned an ``int8`` value: ``+1`` for CCW (positively oriented), ``0`` for +degenerate, ``-1`` for CW (negatively oriented / flipped). + +:param mesh: Input triangle mesh. +:param uv_attribute_name: Name of the UV attribute. If empty, uses the first UV attribute. +:param output_attribute_name: Name of the output per-facet attribute (int8). + +:returns: A :class:`UVOrientationCount` with counts of positive, degenerate, and negative facets.)"); + + m.def( + "unflip_uv_charts", + [](MeshType& mesh, + std::string_view uv_attribute_name, + std::string_view chart_id_attribute_name) { + UnflipUVChartsOptions options; + options.uv_attribute_name = uv_attribute_name; + options.chart_id_attribute_name = chart_id_attribute_name; + return unflip_uv_charts(mesh, options); + }, + "mesh"_a, + "uv_attribute_name"_a = UnflipUVChartsOptions().uv_attribute_name, + "chart_id_attribute_name"_a = UnflipUVChartsOptions().chart_id_attribute_name, + R"(Mirror the UV positions of every UV vertex in any chart that is "flipped" by negating +its U coordinate. A chart is considered flipped when either its total signed UV area is negative, +OR every triangle in the chart is individually flipped (per :func:`compute_uv_orientation`); the +latter rule catches charts whose floating-point area sum is non-negative due to nearly-degenerate +triangles. Assumes UV vertices are not shared across charts. + +:param mesh: Input triangle mesh. The UV attribute must be indexed. +:param uv_attribute_name: Name of the UV attribute. If empty, uses the first indexed UV attribute. +:param chart_id_attribute_name: Optional per-facet chart id attribute name. If empty, charts are + computed automatically using edge connectivity on the UV mesh. -@returns: A list of chart ids for each vertex.)"); +:returns: The number of charts that were unflipped.)"); m.def( "disconnect_uv_charts", diff --git a/modules/core/python/src/core.cpp b/modules/core/python/src/core.cpp index f973acdb..f007f7db 100644 --- a/modules/core/python/src/core.cpp +++ b/modules/core/python/src/core.cpp @@ -11,6 +11,7 @@ */ #include "bind_attribute.h" +#include "bind_camera_transforms.h" #include "bind_enum.h" #include "bind_indexed_attribute.h" #include "bind_mesh_cleanup.h" @@ -41,6 +42,7 @@ void populate_core_module(nb::module_& m) lagrange::python::bind_indexed_attribute(m); lagrange::python::bind_utilities(m); lagrange::python::bind_mesh_cleanup(m); + lagrange::python::bind_camera_transforms(m); } } // namespace lagrange::python diff --git a/modules/core/python/tests/test_meshstat_logic.py b/modules/core/python/tests/test_meshstat_logic.py new file mode 100644 index 00000000..4fb794f9 --- /dev/null +++ b/modules/core/python/tests/test_meshstat_logic.py @@ -0,0 +1,216 @@ +# +# 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 json +import logging + +import lagrange +import lagrange.scripts.meshstat as meshstat +import numpy as np +import pytest + + +class TestComputeStats: + def test_empty(self): + stats = meshstat.compute_stats(np.array([], dtype=np.float64)) + assert stats["count"] == 0 + assert stats["num_invalid"] == 0 + assert stats["min"] is None + assert stats["percentiles"] == {} + + def test_all_finite(self): + stats = meshstat.compute_stats(np.array([1.0, 2.0, 3.0, 4.0, 5.0])) + assert stats["count"] == 5 + assert stats["num_invalid"] == 0 + assert stats["min"] == 1.0 + assert stats["max"] == 5.0 + assert stats["mean"] == pytest.approx(3.0) + assert stats["median"] == pytest.approx(3.0) + for key in ("1", "10", "25", "75", "90", "99"): + assert key in stats["percentiles"] + + def test_mixed_invalid(self): + values = np.array([1.0, 2.0, np.inf, np.nan, -np.inf, 3.0]) + stats = meshstat.compute_stats(values) + assert stats["count"] == 6 + assert stats["num_invalid"] == 3 + assert stats["min"] == 1.0 + assert stats["max"] == 3.0 + assert stats["mean"] == pytest.approx(2.0) + + def test_only_invalid(self): + stats = meshstat.compute_stats(np.array([np.nan, np.inf, -np.inf])) + assert stats["count"] == 3 + assert stats["num_invalid"] == 3 + assert stats["min"] is None + + +class TestCollectBasicInfo: + def test_cube_with_uv(self, cube_with_uv): + info: dict = {} + meshstat.collect_basic_info(cube_with_uv, info) + basic = info["basic"] + assert basic["num_vertices"] == 8 + assert basic["num_facets"] == 6 + assert basic["facet_type"] == "quads" + assert basic["dim"] == 3 + assert basic["bbox_min"] == [0.0, 0.0, 0.0] + assert basic["bbox_max"] == [1.0, 1.0, 1.0] + assert basic["bbox_extent"] == [1.0, 1.0, 1.0] + assert basic["max_extent"] == 1.0 + assert basic["bbox_diagonal"] == pytest.approx(np.sqrt(3)) + + def test_initializes_edges(self, cube_with_uv): + """``num_edges`` must be reported correctly even when the caller has + not initialized edges beforehand.""" + assert not cube_with_uv.has_edges + info: dict = {} + meshstat.collect_basic_info(cube_with_uv, info) + assert cube_with_uv.has_edges + assert info["basic"]["num_edges"] == 12 + + def test_empty_mesh(self, capsys): + """An empty mesh must not crash bbox computation, and ``print_basic_info`` + must still emit a sensible summary (so ``--export`` works on failed loads).""" + mesh = lagrange.SurfaceMesh() + info: dict = {} + meshstat.print_basic_info(mesh, info) + basic = info["basic"] + assert basic["num_vertices"] == 0 + assert basic["bbox_min"] is None + assert basic["bbox_max"] is None + assert basic["bbox_extent"] is None + assert basic["bbox_diagonal"] == 0.0 + assert basic["max_extent"] == 0.0 + assert "empty mesh" in capsys.readouterr().out + + +class TestCollectUVInfo: + def test_triangulated_cube(self, cube_with_uv): + mesh = cube_with_uv + mesh.initialize_edges() + lagrange.triangulate_polygonal_facets(mesh) + info: dict = {"basic": {"max_extent": 1.0}} + metrics = [lagrange.DistortionMetric.MIPS, lagrange.DistortionMetric.SymmetricDirichlet] + meshstat.collect_uv_info(mesh, info, metrics) + + assert "uv" in info["uv"] + uv_info = info["uv"]["uv"] + assert uv_info["num_facets_evaluated"] == mesh.num_facets + assert uv_info["num_charts"] >= 1 + assert uv_info["num_flipped_facets"] == 0 + assert uv_info["num_degenerate_facets"] == 0 + assert uv_info["fraction_flipped_facets"] == 0.0 + overlap = uv_info["overlap"] + assert overlap["has_overlap"] is False + assert overlap["overlap_area"] == 0.0 + assert overlap["num_overlapping_pairs"] == 0 + seams = uv_info["seams"] + assert seams["num_seam_edges"] >= 0 + assert seams["total_length_3d"] >= 0.0 + assert seams["relative_length_3d"] is not None + for metric_name in ("MIPS", "SymmetricDirichlet"): + stats = uv_info["distortion"][metric_name] + for key in ( + "count", + "num_invalid", + "min", + "max", + "mean", + "median", + "std", + "percentiles", + ): + assert key in stats + assert stats["count"] == mesh.num_facets + + def test_no_uv_warns(self, cube, caplog): + cube.initialize_edges() + lagrange.triangulate_polygonal_facets(cube) + info: dict = {} + with caplog.at_level(logging.WARNING, logger=meshstat.__name__): + meshstat.collect_uv_info(cube, info, [lagrange.DistortionMetric.MIPS]) + assert info["uv"] == {} + assert any("No UV attributes" in rec.message for rec in caplog.records) + + def test_idempotent(self, cube_with_uv): + """Calling ``collect_uv_info`` twice on the same mesh must not crash: + intermediate attributes should get unique names instead of colliding.""" + mesh = cube_with_uv + mesh.initialize_edges() + lagrange.triangulate_polygonal_facets(mesh) + info: dict = {"basic": {"max_extent": 1.0}} + metrics = [lagrange.DistortionMetric.MIPS] + meshstat.collect_uv_info(mesh, info, metrics) + first = dict(info["uv"]["uv"]) + info["uv"] = {} + meshstat.collect_uv_info(mesh, info, metrics) + second = info["uv"]["uv"] + assert first["num_charts"] == second["num_charts"] + assert first["seams"]["num_seam_edges"] == second["seams"]["num_seam_edges"] + + def test_open_boundary_counts_as_seams(self): + """A flat square (open boundary, single chart, no UV cut) must report + the four perimeter edges as seams, since boundary edges bound the + UV chart even when ``compute_seam_edges`` does not flag them.""" + mesh = lagrange.SurfaceMesh() + mesh.add_vertices(np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float64)) + mesh.add_triangles(np.array([[0, 1, 2], [0, 2, 3]], dtype=np.uint32)) + mesh.create_attribute( + "uv", + lagrange.AttributeElement.Indexed, + lagrange.AttributeUsage.UV, + np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32), + np.array([0, 1, 2, 0, 2, 3], dtype=np.uint32), + ) + mesh.initialize_edges() + info: dict = {"basic": {"max_extent": 1.0}} + meshstat.collect_uv_info(mesh, info, []) + seams = info["uv"]["uv"]["seams"] + assert seams["num_seam_edges"] == 4 + assert seams["total_length_3d"] == pytest.approx(4.0) + + +class TestMain: + def _save_mesh(self, mesh, path): + lagrange.io.save_mesh(str(path), mesh) + + def test_export_writes_nested_json(self, cube_with_uv, tmp_path): + mesh_path = tmp_path / "cube.obj" + self._save_mesh(cube_with_uv, mesh_path) + rc = meshstat.main(["--export", "--extended", str(mesh_path)]) + assert rc == 0 + json_path = mesh_path.with_suffix(".json") + assert json_path.exists() + info = json.loads(json_path.read_text()) + assert "basic" in info and "extended" in info + assert info["basic"]["num_vertices"] == cube_with_uv.num_vertices + + def test_uv_export_schema(self, cube_with_uv, tmp_path): + mesh_path = tmp_path / "cube.obj" + self._save_mesh(cube_with_uv, mesh_path) + rc = meshstat.main(["--uv", "--export", str(mesh_path)]) + assert rc == 0 + info = json.loads(mesh_path.with_suffix(".json").read_text()) + assert "uv" in info + for uv_entry in info["uv"].values(): + assert {"num_charts", "overlap", "seams", "distortion"} <= set(uv_entry.keys()) + + def test_uv_triangulates_in_place_with_warning(self, cube_with_uv, tmp_path, caplog): + mesh_path = tmp_path / "cube.obj" + self._save_mesh(cube_with_uv, mesh_path) + with caplog.at_level(logging.WARNING, logger=meshstat.__name__): + rc = meshstat.main(["--uv", str(mesh_path)]) + assert rc == 0 + triangulate_records = [ + rec for rec in caplog.records if "triangulating in place" in rec.message.lower() + ] + assert len(triangulate_records) == 1 diff --git a/modules/core/python/tests/test_meshstat_smoke.py b/modules/core/python/tests/test_meshstat_smoke.py new file mode 100644 index 00000000..b1412bae --- /dev/null +++ b/modules/core/python/tests/test_meshstat_smoke.py @@ -0,0 +1,46 @@ +# +# 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. +# +"""Subprocess smoke tests for the installed ``lagrange.scripts.meshstat`` module.""" + +import json +import subprocess +import sys + +import lagrange + + +class TestMeshstatSmoke: + def test_help(self): + result = subprocess.run( + [sys.executable, "-m", "lagrange.scripts.meshstat", "--help"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert "usage:" in result.stdout.lower() + + def test_export_uv(self, cube_with_uv, tmp_path): + mesh_path = tmp_path / "cube.obj" + lagrange.io.save_mesh(str(mesh_path), cube_with_uv) + result = subprocess.run( + [sys.executable, "-m", "lagrange.scripts.meshstat", "--uv", "--export", str(mesh_path)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + json_path = mesh_path.with_suffix(".json") + assert json_path.exists() + info = json.loads(json_path.read_text()) + assert "basic" in info + assert "uv" in info diff --git a/modules/core/python/tests/test_unflip_uv_charts.py b/modules/core/python/tests/test_unflip_uv_charts.py new file mode 100644 index 00000000..2a22ad6a --- /dev/null +++ b/modules/core/python/tests/test_unflip_uv_charts.py @@ -0,0 +1,180 @@ +# +# 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 + + +@pytest.fixture +def make_mesh_with_indexed_uv(): + def _make(vertices, facets, uv_values, uv_indices): + mesh = lagrange.SurfaceMesh() + mesh.add_vertices(np.array(vertices, dtype=np.float64)) + for f in facets: + mesh.add_triangle(*f) + mesh.create_attribute( + "uv", + element=lagrange.AttributeElement.Indexed, + usage=lagrange.AttributeUsage.UV, + initial_values=np.array(uv_values, dtype=np.float64), + initial_indices=np.array(uv_indices, dtype=np.uint32), + ) + return mesh + + return _make + + +class TestComputeUVOrientation: + def test_no_flips(self, make_mesh_with_indexed_uv): + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [1, 3, 2]], + uv_values=[[0, 0], [1, 0], [0, 1], [1, 1]], + uv_indices=[[0, 1, 2], [1, 3, 2]], + ) + counts = lagrange.compute_uv_orientation(mesh, uv_attribute_name="uv") + assert counts.positive == 2 + assert counts.degenerate == 0 + assert counts.negative == 0 + orient = mesh.attribute("@uv_orientation").data + assert np.array_equal(orient, [1, 1]) + + def test_all_flipped(self, make_mesh_with_indexed_uv): + # Both triangles wound CW in UV space. + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [1, 3, 2]], + uv_values=[[0, 0], [0, 1], [1, 0], [1, 1]], + uv_indices=[[0, 1, 2], [1, 3, 2]], + ) + counts = lagrange.compute_uv_orientation(mesh, uv_attribute_name="uv") + assert counts.negative == 2 + assert counts.positive == 0 + assert counts.degenerate == 0 + orient = mesh.attribute("@uv_orientation").data + assert np.array_equal(orient, [-1, -1]) + + def test_mixed(self, make_mesh_with_indexed_uv): + # tri0 CCW (positive), tri1 CW (negative) — disconnected charts. + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [1, 3, 2]], + uv_values=[[0, 0], [1, 0], [0, 1], [2, 0], [2, 1], [3, 0]], + uv_indices=[[0, 1, 2], [3, 4, 5]], + ) + counts = lagrange.compute_uv_orientation(mesh, uv_attribute_name="uv") + assert counts.positive == 1 + assert counts.negative == 1 + assert counts.degenerate == 0 + orient = mesh.attribute("@uv_orientation").data + assert np.array_equal(orient, [1, -1]) + + def test_custom_output_attribute(self, make_mesh_with_indexed_uv): + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0]], + facets=[[0, 1, 2]], + uv_values=[[0, 0], [0, 1], [1, 0]], + uv_indices=[[0, 1, 2]], + ) + counts = lagrange.compute_uv_orientation( + mesh, uv_attribute_name="uv", output_attribute_name="@my_orient" + ) + assert counts.negative == 1 + assert mesh.has_attribute("@my_orient") + orient = mesh.attribute("@my_orient").data + assert np.array_equal(orient, [-1]) + + +class TestUnflipUVCharts: + def test_all_flipped_chart(self, make_mesh_with_indexed_uv): + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [1, 3, 2]], + uv_values=[[0, 0], [0, 1], [1, 0], [1, 1]], + uv_indices=[[0, 1, 2], [1, 3, 2]], + ) + assert lagrange.compute_uv_orientation(mesh, uv_attribute_name="uv").negative == 2 + num_unflipped = lagrange.unflip_uv_charts(mesh, uv_attribute_name="uv") + assert num_unflipped == 1 + assert lagrange.compute_uv_orientation(mesh, uv_attribute_name="uv").negative == 0 + # U is negated for every UV vertex in the flipped chart; V is unchanged. + uv_attr = mesh.indexed_attribute("uv") + assert np.array_equal(uv_attr.values.data, [[0, 0], [0, 1], [-1, 0], [-1, 1]]) + assert np.array_equal(uv_attr.indices.data, [0, 1, 2, 1, 3, 2]) + + def test_only_flipped_chart_is_unflipped(self, make_mesh_with_indexed_uv): + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [1, 3, 2]], + uv_values=[[0, 0], [1, 0], [0, 1], [2, 0], [2, 1], [3, 0]], + uv_indices=[[0, 1, 2], [3, 4, 5]], + ) + assert lagrange.compute_uv_orientation(mesh, uv_attribute_name="uv").negative == 1 + num_unflipped = lagrange.unflip_uv_charts(mesh, uv_attribute_name="uv") + assert num_unflipped == 1 + assert lagrange.compute_uv_orientation(mesh, uv_attribute_name="uv").negative == 0 + # Chart A (UV verts 0..2) untouched; Chart B (UV verts 3..5) has negated U. + uv_attr = mesh.indexed_attribute("uv") + assert np.array_equal( + uv_attr.values.data, + [[0, 0], [1, 0], [0, 1], [-2, 0], [-2, 1], [-3, 0]], + ) + + def test_no_flipped_charts(self, make_mesh_with_indexed_uv): + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [1, 3, 2]], + uv_values=[[0, 0], [1, 0], [0, 1], [1, 1]], + uv_indices=[[0, 1, 2], [1, 3, 2]], + ) + num_unflipped = lagrange.unflip_uv_charts(mesh, uv_attribute_name="uv") + assert num_unflipped == 0 + + def test_flipped_with_degenerate(self, make_mesh_with_indexed_uv): + # Two triangles in one chart: one flipped (CW) and one degenerate (zero area). + # The chart has no positively-oriented triangles, so it should be unflipped. + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [1, 3, 2]], + uv_values=[[0, 0], [0, 1], [1, 0], [1, 1]], + uv_indices=[[0, 1, 2], [1, 1, 3]], + ) + counts = lagrange.compute_uv_orientation(mesh, uv_attribute_name="uv") + assert counts.negative == 1 + assert counts.degenerate == 1 + assert counts.positive == 0 + num_unflipped = lagrange.unflip_uv_charts(mesh, uv_attribute_name="uv") + assert num_unflipped == 1 + post = lagrange.compute_uv_orientation(mesh, uv_attribute_name="uv") + assert post.negative == 0 + + def test_with_explicit_chart_id_attribute(self, make_mesh_with_indexed_uv): + # Force both triangles into a single chart via an explicit chart-id attribute, + # even though they have disconnected UVs. The chart's total signed area drives + # the decision: tri0 is CCW (+0.5), tri1 is CW (-0.5), so the chart's sum is 0 + # and is NOT unflipped. + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [1, 3, 2]], + uv_values=[[0, 0], [1, 0], [0, 1], [2, 0], [2, 1], [3, 0]], + uv_indices=[[0, 1, 2], [3, 4, 5]], + ) + mesh.create_attribute( + "@my_chart", + element=lagrange.AttributeElement.Facet, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.array([0, 0], dtype=np.uint32), + ) + num_unflipped = lagrange.unflip_uv_charts( + mesh, uv_attribute_name="uv", chart_id_attribute_name="@my_chart" + ) + assert num_unflipped == 0 diff --git a/modules/core/src/compute_area.cpp b/modules/core/src/compute_area.cpp index f94670ab..3fcc2df1 100644 --- a/modules/core/src/compute_area.cpp +++ b/modules/core/src/compute_area.cpp @@ -362,6 +362,47 @@ AttributeId compute_facet_vector_area( return id; } +template +AttributeId compute_facet_vector_area( + SurfaceMesh& mesh, + const Eigen::Transform& transformation, + FacetVectorAreaOptions options) +{ + const auto dim = mesh.get_dimension(); + la_runtime_assert(dim == 3, "Facet vector area only supports 3D meshes."); + const auto num_facets = mesh.get_num_facets(); + + AttributeId id = internal::find_or_create_attribute( + mesh, + options.output_attribute_name, + Facet, + AttributeUsage::Vector, + dim, + internal::ResetToDefault::No); + auto vector_area = attribute_matrix_ref(mesh, id); + vector_area.setZero(); + + auto vertices = vertex_view(mesh); + + tbb::parallel_for(Index(0), num_facets, [&](Index fid) { + auto f_size = mesh.get_facet_size(fid); + auto f = mesh.get_facet_vertices(fid); + for (Index lv = 0; lv < f_size; lv++) { + Index lv_next = (lv + 1) % f_size; + LA_IGNORE_ARRAY_BOUNDS_BEGIN + const Eigen::Vector3 v0 = + transformation * vertices.row(f[lv]).template head<3>().transpose(); + const Eigen::Vector3 v1 = + transformation * vertices.row(f[lv_next]).template head<3>().transpose(); + vector_area.row(fid) += v0.cross(v1).transpose(); + LA_IGNORE_ARRAY_BOUNDS_END + } + }); + vector_area /= 2; + + return id; +} + template Scalar compute_mesh_area(const SurfaceMesh& mesh, MeshAreaOptions options) { @@ -407,6 +448,10 @@ Scalar compute_uv_area(const SurfaceMesh& mesh, MeshAreaOptions o template LA_CORE_API AttributeId compute_facet_vector_area( \ SurfaceMesh&, \ FacetVectorAreaOptions); \ + template LA_CORE_API AttributeId compute_facet_vector_area( \ + SurfaceMesh&, \ + const Eigen::Transform&, \ + FacetVectorAreaOptions); \ template LA_CORE_API Scalar compute_mesh_area( \ const SurfaceMesh&, \ MeshAreaOptions); \ diff --git a/modules/core/src/compute_dijkstra_distance.cpp b/modules/core/src/compute_dijkstra_distance.cpp index acdf5bd3..4118e9fb 100644 --- a/modules/core/src/compute_dijkstra_distance.cpp +++ b/modules/core/src/compute_dijkstra_distance.cpp @@ -63,25 +63,24 @@ std::optional> compute_dijkstra_distance( involved_vts = std::vector(); } - std::function process; + std::function process; if (options.output_involved_vertices) { process = [&](Index vi, Scalar d) { dist_data[vi] = d; involved_vts->push_back(vi); - return false; }; } else { - process = [&](Index vi, Scalar d) { - dist_data[vi] = d; - return false; - }; + process = [&](Index vi, Scalar d) { dist_data[vi] = d; }; } + internal::DijkstraOptions dijkstra_opts; + dijkstra_opts.geodesic_radius = options.radius; + internal::dijkstra( mesh, seed_vertices, span(initial_dist.data(), static_cast(initial_dist.size())), - options.radius, + dijkstra_opts, dist, process); diff --git a/modules/core/src/compute_seam_edges.cpp b/modules/core/src/compute_seam_edges.cpp index 089a194a..de03fb4d 100644 --- a/modules/core/src/compute_seam_edges.cpp +++ b/modules/core/src/compute_seam_edges.cpp @@ -51,6 +51,10 @@ AttributeId compute_seam_edges( auto process_attribute = [&](auto&& attr) { auto indices = attr.indices().get_all(); tbb::parallel_for(Index(0), mesh.get_num_edges(), [&](Index e) { + if (options.include_boundary_edges && mesh.is_boundary_edge(e)) { + output_is_seam[e] = 1; + return; + } auto v = mesh.get_edge_vertices(e); std::optional> prev_indices; mesh.foreach_corner_around_edge(e, [&](Index c0) { diff --git a/modules/core/src/compute_uv_orientation.cpp b/modules/core/src/compute_uv_orientation.cpp new file mode 100644 index 00000000..d474857a --- /dev/null +++ b/modules/core/src/compute_uv_orientation.cpp @@ -0,0 +1,133 @@ +/* + * 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 + +// clang-format off +#include +#include +#include +#include +// clang-format on + +namespace lagrange { + +namespace { + +template +UVOrientationCount compute_uv_orientation_impl( + SurfaceMesh& mesh, + AttributeId out_id, + std::string_view uv_attr_name) +{ + auto uv = internal::get_uv_attribute(mesh, uv_attr_name); + const auto& uv_values = std::get<0>(uv); + const auto& uv_indices = std::get<1>(uv); + + auto out = mesh.template ref_attribute(out_id).ref_all(); + + ExactPredicatesShewchuk predicates; + + auto counts = tbb::parallel_reduce( + tbb::blocked_range(0, mesh.get_num_facets()), + UVOrientationCount{}, + [&](const tbb::blocked_range& r, UVOrientationCount init) { + double p0[2], p1[2], p2[2]; + for (Index f = r.begin(); f != r.end(); ++f) { + const auto c = mesh.get_facet_corner_begin(f); + const Index i0 = uv_indices[c + 0]; + const Index i1 = uv_indices[c + 1]; + const Index i2 = uv_indices[c + 2]; + p0[0] = static_cast(uv_values(i0, 0)); + p0[1] = static_cast(uv_values(i0, 1)); + p1[0] = static_cast(uv_values(i1, 0)); + p1[1] = static_cast(uv_values(i1, 1)); + p2[0] = static_cast(uv_values(i2, 0)); + p2[1] = static_cast(uv_values(i2, 1)); + const short orient = predicates.orient2D(p0, p1, p2); + out[f] = static_cast(orient); + if (orient > 0) { + ++init.positive; + } else if (orient == 0) { + ++init.degenerate; + } else { + ++init.negative; + } + } + return init; + }, + [](UVOrientationCount a, const UVOrientationCount& b) { + a.positive += b.positive; + a.degenerate += b.degenerate; + a.negative += b.negative; + return a; + }); + + if (counts.degenerate > 0) { + logger().debug( + "compute_uv_orientation: {} degenerate UV triangle(s) detected.", + counts.degenerate); + } + return counts; +} + +} // namespace + +template +UVOrientationCount compute_uv_orientation( + SurfaceMesh& mesh, + const UVOrientationOptions& options) +{ + la_runtime_assert( + mesh.is_triangle_mesh(), + "compute_uv_orientation: mesh must be a triangle mesh."); + + AttributeId out_id = internal::find_or_create_attribute( + mesh, + options.output_attribute_name, + Facet, + AttributeUsage::Scalar, + 1, + internal::ResetToDefault::No); + + UVMeshOptions uv_mesh_options; + uv_mesh_options.uv_attribute_name = options.uv_attribute_name; + return internal::dispatch_uv_scalar_type( + mesh, + uv_mesh_options, + "compute_uv_orientation", + [&](auto tag, AttributeId uv_attr_id) { + using UVScalar = typename decltype(tag)::type; + return compute_uv_orientation_impl( + mesh, + out_id, + mesh.get_attribute_name(uv_attr_id)); + }); +} + +#define LA_X_compute_uv_orientation(_, Scalar, Index) \ + template LA_CORE_API UVOrientationCount compute_uv_orientation( \ + SurfaceMesh&, \ + const UVOrientationOptions&); +LA_SURFACE_MESH_X(compute_uv_orientation, 0) + +} // namespace lagrange diff --git a/modules/core/src/disconnect_uv_charts.cpp b/modules/core/src/disconnect_uv_charts.cpp index f6aab39c..743d8cac 100644 --- a/modules/core/src/disconnect_uv_charts.cpp +++ b/modules/core/src/disconnect_uv_charts.cpp @@ -17,9 +17,11 @@ #include #include #include +#include +#include +#include #include #include -#include #include #include #include @@ -27,7 +29,7 @@ #include #include -#include +#include #include namespace lagrange { @@ -47,37 +49,149 @@ size_t disconnect_uv_charts_impl( const Index num_old_values = static_cast(old_values.rows()); const Index num_cols = static_cast(old_values.cols()); - // Remap (uv_index, chart_id) -> new_uv_index, rebuilding the values array. - using Key = std::pair; - using KeyHash = OrderedPairHash; - std::unordered_map remap; - std::vector> new_values; - new_values.reserve(num_old_values); - const Index num_facets = mesh.get_num_facets(); + // Pass 1: chart split. Dedupe (uv_idx, chart_id) -> new_uv_idx via a counting-sort layout + // (Liani, https://maxliani.wordpress.com/2025/03/01/mesh-edge-construction/): count corners + // per uv_idx, prefix-sum to offsets, then for each corner linearly scan its small bucket + // (avg ~1-2 entries — one per chart touching that uv) for a matching chart_id. Counting + // chart duplicates explicitly (a bucket gaining its 2nd+ entry) stays correct when the input + // has unreferenced UV values that get compacted away here. + std::vector bucket_count(static_cast(num_old_values), Index(0)); + for (Index f = 0; f < num_facets; ++f) { + const auto c_begin = mesh.get_facet_corner_begin(f); + const auto c_end = mesh.get_facet_corner_end(f); + for (auto c = c_begin; c < c_end; ++c) { + ++bucket_count[static_cast(uv_indices[c])]; + } + } + std::vector bucket_offsets(static_cast(num_old_values) + 1, Index(0)); + for (Index v = 0; v < num_old_values; ++v) { + bucket_offsets[v + 1] = bucket_offsets[v] + bucket_count[v]; + } + std::fill(bucket_count.begin(), bucket_count.end(), Index(0)); + std::vector bucket_chart(static_cast(bucket_offsets.back())); + std::vector bucket_new_idx(static_cast(bucket_offsets.back())); + + std::vector> new_values; + new_values.reserve(static_cast(num_old_values)); + size_t num_chart_duplicated = 0; + for (Index f = 0; f < num_facets; ++f) { const Index chart = chart_ids[f]; const auto c_begin = mesh.get_facet_corner_begin(f); const auto c_end = mesh.get_facet_corner_end(f); for (auto c = c_begin; c < c_end; ++c) { - const Index uv_idx = static_cast(uv_indices[c]); - auto [it, inserted] = - remap.emplace(Key{uv_idx, chart}, static_cast(new_values.size())); - if (inserted) { - std::array v; + const Index uv_idx = uv_indices[c]; + const Index base = bucket_offsets[uv_idx]; + const Index count = bucket_count[uv_idx]; + Index new_idx = invalid(); + for (Index k = 0; k < count; ++k) { + if (bucket_chart[base + k] == chart) { + new_idx = bucket_new_idx[base + k]; + break; + } + } + if (new_idx == invalid()) { + new_idx = static_cast(new_values.size()); + bucket_chart[base + count] = chart; + bucket_new_idx[base + count] = new_idx; + bucket_count[uv_idx] = count + 1; + if (count > 0) ++num_chart_duplicated; + std::array val; for (Index col = 0; col < num_cols; ++col) { - v[col] = old_values(uv_idx, col); + val[col] = old_values(uv_idx, col); } - new_values.push_back(v); + new_values.push_back(val); } - uv_indices[c] = it->second; + uv_indices[c] = new_idx; } } - size_t num_duplicated = new_values.size() > static_cast(num_old_values) - ? new_values.size() - static_cast(num_old_values) - : 0; + // Pass 2: split bowtie / pinch-point UV vertices. After the chart-based remap above, two + // facets that belong to the same chart but only share a single UV vertex (no UV edge) still + // reference that vertex through a single index. This makes the UV mesh non-manifold at that + // vertex and breaks downstream consumers (e.g. repack_uv_charts) that assume charts are + // vertex-disjoint. Here we partition the corners incident at each UV vertex into UV-edge- + // connected wedges and duplicate the vertex per extra wedge. + const size_t num_after_chart_split = new_values.size(); + + // Flat CSR of corners-per-uv: target-to-source inverse of corner -> uv_indices[corner]. + const Index num_corners = static_cast(uv_indices.size()); + auto corners_per_uv = internal::invert_mapping( + num_corners, + [&](Index c) -> Index { return uv_indices[c]; }, + static_cast(num_after_chart_split)); + const auto& corner_offsets = corners_per_uv.offsets; + const auto& corner_indices = corners_per_uv.data; + + // Per-vertex scratch, reused across iterations. `neighbor_to_local` holds (neighbor_uv, + // local_corner_idx) pairs — valence is small (typ. ≤ 6, so ≤ 12 probes), linear scan suffices. + // `root_to_target` is indexed by DisjointSets root, which is itself a local corner index in + // [0, num_inc). `dirty_roots` tracks slots touched this iteration so we reset only those. + DisjointSets uf; + std::vector> neighbor_to_local; + std::vector root_to_target; + std::vector dirty_roots; + size_t num_bowtie_duplicated = 0; + for (Index v = 0; v < static_cast(num_after_chart_split); ++v) { + const Index c_off = corner_offsets[v]; + const Index num_inc = corner_offsets[v + 1] - c_off; + if (num_inc <= 1) continue; + const Index* corners = corner_indices.data() + c_off; + + uf.init(static_cast(num_inc)); + neighbor_to_local.clear(); + + // Two incident corners belong to the same wedge if they share a UV edge through `v`, i.e. + // their facet's prev/next corner points to the same neighbor UV index. + for (Index i = 0; i < num_inc; ++i) { + const Index c = corners[i]; + const Index f = mesh.get_corner_facet(c); + const auto fc_begin = mesh.get_facet_corner_begin(f); + const auto fc_end = mesh.get_facet_corner_end(f); + const Index fc_size = static_cast(fc_end - fc_begin); + const Index k = c - fc_begin; + const Index prev_c = fc_begin + (k + fc_size - 1) % fc_size; + const Index next_c = fc_begin + (k + 1) % fc_size; + for (Index n : {uv_indices[prev_c], uv_indices[next_c]}) { + bool merged = false; + for (const auto& [nb, li] : neighbor_to_local) { + if (nb == n) { + uf.merge(i, li); + merged = true; + break; + } + } + if (!merged) neighbor_to_local.emplace_back(n, i); + } + } + + // First wedge keeps index v; each additional wedge gets a duplicate of v. + if (static_cast(root_to_target.size()) < num_inc) { + root_to_target.resize(static_cast(num_inc), invalid()); + } + dirty_roots.clear(); + for (Index i = 0; i < num_inc; ++i) { + const Index r = uf.find(i); + if (root_to_target[r] != invalid()) continue; + if (dirty_roots.empty()) { + root_to_target[r] = v; + } else { + root_to_target[r] = static_cast(new_values.size()); + new_values.push_back(new_values[v]); + ++num_bowtie_duplicated; + } + dirty_roots.push_back(r); + } + for (Index i = 0; i < num_inc; ++i) { + const Index target = root_to_target[uf.find(i)]; + if (target != v) uv_indices[corners[i]] = target; + } + for (Index r : dirty_roots) root_to_target[r] = invalid(); + } + + const size_t num_duplicated = num_chart_duplicated + num_bowtie_duplicated; // Rewrite UV values uv_attr.values().resize_elements(new_values.size()); @@ -90,8 +204,10 @@ size_t disconnect_uv_charts_impl( if (num_duplicated > 0) { logger().info( - "Disconnected UV charts: duplicated {} UV vertices ({} -> {}).", + "Disconnected UV charts: duplicated {} UV vertices ({} chart, {} bowtie) ({} -> {}).", num_duplicated, + num_chart_duplicated, + num_bowtie_duplicated, num_old_values, new_values.size()); } @@ -111,67 +227,58 @@ size_t disconnect_uv_charts( uv_mesh_options.uv_attribute_name = options.uv_attribute_name; uv_mesh_options.element_types = UVMeshOptions::ElementTypes::IndexedOrVertex; - using OtherScalar = std::conditional_t, double, float>; - - AttributeId uv_attr_id; - bool is_other_scalar = false; - if (auto id = uv_attribute_id(mesh, uv_mesh_options)) { - uv_attr_id = *id; - } else if (auto id2 = uv_attribute_id(mesh, uv_mesh_options)) { - uv_attr_id = *id2; - is_other_scalar = true; - } else { - throw Error("disconnect_uv_charts: no suitable indexed UV attribute found."); - } - if (!mesh.is_attribute_indexed(uv_attr_id)) { - throw Error( - "disconnect_uv_charts: UV attribute must be indexed. " - "Found a vertex UV attribute, but this function requires an indexed UV attribute."); - } + return internal::dispatch_uv_scalar_type( + mesh, + uv_mesh_options, + "disconnect_uv_charts", + [&](auto tag, AttributeId uv_attr_id) -> size_t { + using UVScalar = typename decltype(tag)::type; + if (!mesh.is_attribute_indexed(uv_attr_id)) { + throw Error( + "disconnect_uv_charts: UV attribute must be indexed. " + "Found a vertex UV attribute, but this function requires an indexed UV " + "attribute."); + } + std::string uv_attr_name(mesh.get_attribute_name(uv_attr_id)); - std::string_view uv_attr_name = mesh.get_attribute_name(uv_attr_id); + // Get or compute chart ids + std::string chart_attr_name; + auto cleanup_guard = make_scope_guard([&]() noexcept { + if (!chart_attr_name.empty() && mesh.has_attribute(chart_attr_name)) { + mesh.delete_attribute(chart_attr_name); + } + }); - // Get or compute chart ids - std::string chart_attr_name; - auto cleanup_guard = make_scope_guard([&]() noexcept { - if (!chart_attr_name.empty() && mesh.has_attribute(chart_attr_name)) { - mesh.delete_attribute(chart_attr_name); - } - }); - - if (options.chart_id_attribute_name.empty()) { - chart_attr_name = get_unique_attribute_name(mesh, "@_disconnect_uv_charts_tmp"); - UVChartOptions chart_options; - chart_options.uv_attribute_name = uv_attr_name; - chart_options.output_attribute_name = chart_attr_name; - compute_uv_charts(mesh, chart_options); - } else { - cleanup_guard.dismiss(); - chart_attr_name = options.chart_id_attribute_name; - } + if (options.chart_id_attribute_name.empty()) { + chart_attr_name = get_unique_attribute_name(mesh, "@_disconnect_uv_charts_tmp"); + UVChartOptions chart_options; + chart_options.uv_attribute_name = uv_attr_name; + chart_options.output_attribute_name = chart_attr_name; + compute_uv_charts(mesh, chart_options); + } else { + cleanup_guard.dismiss(); + chart_attr_name = options.chart_id_attribute_name; + } - if (!mesh.has_attribute(chart_attr_name)) { - throw Error("disconnect_uv_charts: chart ID attribute does not exist."); - } - auto chart_id_attr_id = mesh.get_attribute_id(chart_attr_name); - if (mesh.get_attribute_base(chart_id_attr_id).get_element_type() != AttributeElement::Facet) { - throw Error("disconnect_uv_charts: chart ID attribute must be a facet attribute."); - } - auto chart_ids = attribute_vector_view(mesh, chart_id_attr_id); - if (static_cast(chart_ids.size()) != mesh.get_num_facets()) { - throw Error("disconnect_uv_charts: chart ID attribute must have one value per facet."); - } - span chart_ids_span{chart_ids.data(), static_cast(chart_ids.size())}; - - // Dispatch based on UV scalar type - size_t num_duplicated; - if (!is_other_scalar) { - num_duplicated = disconnect_uv_charts_impl(mesh, uv_attr_id, chart_ids_span); - } else { - num_duplicated = disconnect_uv_charts_impl(mesh, uv_attr_id, chart_ids_span); - } + if (!mesh.has_attribute(chart_attr_name)) { + throw Error("disconnect_uv_charts: chart ID attribute does not exist."); + } + auto chart_id_attr_id = mesh.get_attribute_id(chart_attr_name); + if (mesh.get_attribute_base(chart_id_attr_id).get_element_type() != + AttributeElement::Facet) { + throw Error("disconnect_uv_charts: chart ID attribute must be a facet attribute."); + } + auto chart_ids = attribute_vector_view(mesh, chart_id_attr_id); + if (static_cast(chart_ids.size()) != mesh.get_num_facets()) { + throw Error( + "disconnect_uv_charts: chart ID attribute must have one value per facet."); + } + span chart_ids_span{ + chart_ids.data(), + static_cast(chart_ids.size())}; - return num_duplicated; + return disconnect_uv_charts_impl(mesh, uv_attr_id, chart_ids_span); + }); } #define LA_X_disconnect_uv_charts(_, Scalar, Index) \ diff --git a/modules/core/src/internal/dijkstra.cpp b/modules/core/src/internal/dijkstra.cpp index 5439dab1..b53ebcc0 100644 --- a/modules/core/src/internal/dijkstra.cpp +++ b/modules/core/src/internal/dijkstra.cpp @@ -12,76 +12,281 @@ #include #include -#include -#include #include #include +#include +#include namespace lagrange::internal { -template -void dijkstra( +namespace { + +/// +/// Iterates over each pair of consecutive edges (within an incident facet) around a vertex. +/// +template +void foreach_edge_pair_around_vertex_with_duplicates( + const SurfaceMesh& mesh, + Index v, + Func&& func) +{ + mesh.foreach_corner_around_vertex(v, [&](Index c) { + const Index f = mesh.get_corner_facet(c); + const Index c_start = mesh.get_facet_corner_begin(f); + const Index nv = mesh.get_facet_size(f); + const Index lv_curr = c - c_start; + const Index lv_prev = (lv_curr + nv - 1) % nv; + const Index e_curr = mesh.get_corner_edge(c_start + lv_curr); + const Index e_prev = mesh.get_corner_edge(c_start + lv_prev); + func(e_curr, e_prev); + }); +} + +/// +/// Shared implementation used by both the geodesic-only and the geodesic+Euclidean variants. +/// The neighbor expansion is delegated to `explore(vi, di)`, which is expected to push +/// reachable neighbors onto `cache.queue`. `in_scope(vi, di)` decides whether `process` fires +/// for a popped vertex; if `always_explore` is true, `explore` also runs for out-of-scope pops +/// (used by the chord-bridge variant). `terminate_on_out_of_scope` enables an early-out for the +/// geodesic-only variant, whose priority queue is monotonic in geodesic distance. +/// +template +void dijkstra_impl( SurfaceMesh& mesh, span seed_vertices, span seed_vertex_dist, - Scalar radius, - const function_ref& dist, - const function_ref& process) + DijkstraCache* cache_ptr, + bool always_explore, + bool terminate_on_out_of_scope, + InScope&& in_scope, + const function_ref& process, + Explore&& explore) { - if (radius <= 0) { - radius = std::numeric_limits::max(); - } + DijkstraCache local_cache; + DijkstraCache& cache = cache_ptr ? *cache_ptr : local_cache; mesh.initialize_edges(); const auto num_vertices = mesh.get_num_vertices(); const auto num_edges = mesh.get_num_edges(); - using Entry = std::pair; - std::priority_queue, std::greater> Q; - std::vector visited(num_vertices, false); + auto& Q = cache.queue; + while (!Q.empty()) Q.pop(); + cache.visited.assign(num_vertices, false); + cache.visited_edges.assign(num_edges, false); + cache.chord_bridged.assign(num_vertices, false); + cache.edge_indices.clear(); + cache.edge_indices.reserve(16); size_t num_seeds = seed_vertices.size(); la_runtime_assert(num_seeds == seed_vertex_dist.size()); + + // Seed vertices are always processed and always explore their neighbors, + // regardless of whether their initial distance exceeds the radius. + // Initial distances are assumed to be correct/minimal, so we + // already mark seed vertices as visited. for (size_t i = 0; i < num_seeds; i++) { - la_runtime_assert(seed_vertices[i] < num_vertices); - Q.push({seed_vertex_dist[i], seed_vertices[i]}); + Index vi = seed_vertices[i]; + Scalar di = seed_vertex_dist[i]; + la_runtime_assert(vi < num_vertices); + if (cache.visited[vi]) continue; + cache.visited[vi] = true; + + process(vi, di); + explore(vi, di); } - std::vector visited_edges(num_edges, false); - std::vector edge_indices; - edge_indices.reserve(16); while (!Q.empty()) { - Entry entry = Q.top(); + auto entry = Q.top(); Q.pop(); Index vi = entry.second; Scalar di = entry.first; - if (visited[vi]) continue; + if (cache.visited[vi]) continue; + cache.visited[vi] = true; + + if (in_scope(vi, di)) { + process(vi, di); + } else if (!always_explore) { + if (terminate_on_out_of_scope) break; + continue; + } + + explore(vi, di); + } +} - bool done = process(vi, di); - if (done) break; - visited[vi] = true; +// Geodesic-only traversal: enqueue a neighbor only if it stays within the geodesic ball, and +// break out as soon as the priority queue head exceeds the radius. +template +void dijkstra_geodesic_only( + SurfaceMesh& mesh, + span seed_vertices, + span seed_vertex_dist, + Scalar geo_radius, + const function_ref& dist, + const function_ref& process, + DijkstraCache& cache) +{ + auto in_scope = [&](Index /*vi*/, Scalar di) { return di <= geo_radius; }; - edge_indices.clear(); + auto explore = [&](Index vi, Scalar di) { + cache.edge_indices.clear(); mesh.foreach_edge_around_vertex_with_duplicates(vi, [&](Index ei) { - if (visited_edges[ei]) return; - visited_edges[ei] = true; - edge_indices.push_back(ei); + if (cache.visited_edges[ei]) return; + cache.visited_edges[ei] = true; + cache.edge_indices.push_back(ei); auto e = mesh.get_edge_vertices(ei); Index vj = (e[0] == vi) ? e[1] : e[0]; Scalar dj = di + dist(vi, vj); - if (dj < radius) { - Q.push({dj, vj}); + if (dj <= geo_radius) { + cache.queue.push({dj, vj}); } }); + for (auto ei : cache.edge_indices) { + cache.visited_edges[ei] = false; + } + }; + + dijkstra_impl( + mesh, + seed_vertices, + seed_vertex_dist, + &cache, + /*always_explore=*/false, + /*terminate_on_out_of_scope=*/true, + in_scope, + process, + explore); +} + +// Geodesic + Euclidean traversal: vertices outside both balls are still allowed to bridge to +// neighbors via the chord-in-scope check, recovering reachable in-scope vertices that the +// pure geodesic variant would miss when the only path passes through out-of-scope vertices. +template +void dijkstra_geodesic_and_euclidean( + SurfaceMesh& mesh, + span seed_vertices, + span seed_vertex_dist, + Scalar geo_radius, + Scalar euclidean_radius_sq, + const Eigen::Vector3& seed_position, + const function_ref& dist, + const function_ref& process, + DijkstraCache& cache) +{ + const auto vertices = vertex_view(mesh); + + auto vertex_in_eucl_ball = [&](Index vi) -> bool { + return (Eigen::Vector3(vertices.row(vi)) - seed_position).squaredNorm() <= + euclidean_radius_sq; + }; + + // Distance from `seed_position` to the segment (a, b) <= euclidean_radius? Used as the + // chord-bridge predicate: when true, both endpoints get enqueued from the current vertex + // even if the current vertex itself sits outside both balls. + auto chord_in_eucl_ball = [&](Index va, Index vb) -> bool { + Eigen::Vector3 pa = vertices.row(va); + Eigen::Vector3 pb = vertices.row(vb); + return point_segment_squared_distance(seed_position, pa, pb) <= euclidean_radius_sq; + }; - for (auto ei : edge_indices) { - visited_edges[ei] = false; + // A chord-bridged vertex is also considered in scope, so it is reported via `process()` when + // popped at its minimum graph distance (the `cache.visited` flag deduplicates the call). + auto in_scope = [&](Index vi, Scalar di) { + return di <= geo_radius || vertex_in_eucl_ball(vi) || cache.chord_bridged[vi]; + }; + + auto explore = [&](Index vi, Scalar di) { + cache.edge_indices.clear(); + foreach_edge_pair_around_vertex_with_duplicates(mesh, vi, [&](Index e_curr, Index e_prev) { + // The two non-vi endpoints of this facet corner form the chord opposite vi. + auto ec = mesh.get_edge_vertices(e_curr); + auto ep = mesh.get_edge_vertices(e_prev); + Index v_curr = (ec[0] == vi) ? ec[1] : ec[0]; + Index v_prev = (ep[0] == vi) ? ep[1] : ep[0]; + + // Chord bridge: when the chord (v_curr, v_prev) stays inside the Euclidean + // ball, we may need to enqueue both endpoints even if they are individually + // out of scope, so the search can keep advancing through the edge. + bool chord_bridge = (v_curr != v_prev) && chord_in_eucl_ball(v_curr, v_prev); + if (chord_bridge) { + cache.chord_bridged[v_curr] = true; + cache.chord_bridged[v_prev] = true; + } + + auto try_enqueue = [&](Index ei, Index vj) { + if (!chord_bridge && cache.visited_edges[ei]) return; + cache.visited_edges[ei] = true; + cache.edge_indices.push_back(ei); + + Scalar dj = di + dist(vi, vj); + if (chord_bridge || in_scope(vj, dj)) { + cache.queue.push({dj, vj}); + } + }; + try_enqueue(e_curr, v_curr); + try_enqueue(e_prev, v_prev); + }); + for (auto ei : cache.edge_indices) { + cache.visited_edges[ei] = false; } + }; + + dijkstra_impl( + mesh, + seed_vertices, + seed_vertex_dist, + &cache, + /*always_explore=*/true, + /*terminate_on_out_of_scope=*/false, + in_scope, + process, + explore); +} + +} // namespace + +template +void dijkstra( + SurfaceMesh& mesh, + span seed_vertices, + span seed_vertex_dist, + const DijkstraOptions& opts, + const function_ref& dist, + const function_ref& process, + DijkstraCache* cache_ptr) +{ + DijkstraCache local_cache; + DijkstraCache& cache = cache_ptr ? *cache_ptr : local_cache; + + const Scalar geo_radius = opts.geodesic_radius > Scalar(0) ? opts.geodesic_radius + : std::numeric_limits::max(); + + if (opts.euclidean_radius > Scalar(0)) { + const Scalar euclidean_radius_sq = opts.euclidean_radius * opts.euclidean_radius; + dijkstra_geodesic_and_euclidean( + mesh, + seed_vertices, + seed_vertex_dist, + geo_radius, + euclidean_radius_sq, + opts.seed_position, + dist, + process, + cache); + } else { + dijkstra_geodesic_only( + mesh, + seed_vertices, + seed_vertex_dist, + geo_radius, + dist, + process, + cache); } } @@ -90,9 +295,10 @@ void dijkstra( SurfaceMesh&, \ span, \ span, \ - Scalar, \ + const DijkstraOptions&, \ const function_ref&, \ - const function_ref&); + const function_ref&, \ + DijkstraCache*); LA_SURFACE_MESH_X(dijkstra, 0) diff --git a/modules/core/src/orient_outward.cpp b/modules/core/src/orient_outward.cpp index 75f05559..9ea95216 100644 --- a/modules/core/src/orient_outward.cpp +++ b/modules/core/src/orient_outward.cpp @@ -130,6 +130,10 @@ void orient_outward(lagrange::SurfaceMesh& mesh, const OrientOpti bool had_edges = mesh.has_edges(); mesh.initialize_edges(); + if (mesh.get_num_vertices() == 0) { + return; + } + // Orient facets consistently within a connected component (if possible) auto should_flip = bfs_orient(mesh); @@ -151,7 +155,7 @@ void orient_outward(lagrange::SurfaceMesh& mesh, const OrientOpti std::vector signed_volumes(num_components, 0); { auto vertices = vertex_view(mesh).template leftCols<3>().template cast(); - Eigen::RowVector3d zero = Eigen::RowVector3d::Zero(); + Eigen::RowVector3d anchor = vertices.colwise().mean(); for (Index f = 0; f < mesh.get_num_facets(); ++f) { auto facet = mesh.get_facet_vertices(f); Index nv = mesh.get_facet_size(f); @@ -163,9 +167,9 @@ void orient_outward(lagrange::SurfaceMesh& mesh, const OrientOpti vertices.row(facet[0]), vertices.row(facet[1]), vertices.row(facet[2]), - zero); + anchor); } else { - Eigen::RowVector3d bary = zero; + Eigen::RowVector3d bary = Eigen::RowVector3d::Zero(); for (Index lv = 0; lv < nv; ++lv) { bary += vertices.row(facet[lv]); } @@ -175,7 +179,7 @@ void orient_outward(lagrange::SurfaceMesh& mesh, const OrientOpti vertices.row(facet[lv]), vertices.row(facet[(lv + 1) % nv]), bary, - zero); + anchor); } } } diff --git a/modules/core/src/unflip_uv_charts.cpp b/modules/core/src/unflip_uv_charts.cpp new file mode 100644 index 00000000..929ec4ad --- /dev/null +++ b/modules/core/src/unflip_uv_charts.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 +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include +#include + +namespace lagrange { + +namespace { + +template +struct ChartStatsT +{ + UVScalar signed_area = 0; + Index num_facets = 0; + Index num_flipped = 0; + Index num_degenerate = 0; +}; + +template +size_t unflip_uv_charts_impl( + SurfaceMesh& mesh, + AttributeId uv_attr_id, + span chart_ids, + span uv_orientation) +{ + auto& uv_attr = mesh.template ref_indexed_attribute(uv_attr_id); + auto uv_values = matrix_ref(uv_attr.values()); + auto uv_indices = vector_view(uv_attr.indices()); + + const Index num_facets = mesh.get_num_facets(); + if (num_facets == 0) return 0; + + auto compacted = internal::compact_chart_ids(chart_ids); + const auto& dense_chart_ids = compacted.first; + const Index num_charts = compacted.second; + + // Per-thread accumulator: bounded by hardware concurrency (not by tbb task splits). + using ChartStats = ChartStatsT; + tbb::enumerable_thread_specific> tls_stats( + [&]() { return std::vector(num_charts); }); + + tbb::parallel_for( + tbb::blocked_range(0, num_facets), + [&](const tbb::blocked_range& r) { + auto& local = tls_stats.local(); + for (Index f = r.begin(); f != r.end(); ++f) { + const auto c = mesh.get_facet_corner_begin(f); + const Index i0 = uv_indices[c + 0]; + const Index i1 = uv_indices[c + 1]; + const Index i2 = uv_indices[c + 2]; + const std::array p0 = {uv_values(i0, 0), uv_values(i0, 1)}; + const std::array p1 = {uv_values(i1, 0), uv_values(i1, 1)}; + const std::array p2 = {uv_values(i2, 0), uv_values(i2, 1)}; + const UVScalar abs_area = std::abs( + triangle_area_2d( + span(p0.data(), 2), + span(p1.data(), 2), + span(p2.data(), 2))); + const int8_t orient = uv_orientation[f]; + const UVScalar sign = orient > 0 ? UVScalar(1) + : orient < 0 ? UVScalar(-1) + : UVScalar(0); + auto& s = local[dense_chart_ids[f]]; + s.signed_area += sign * abs_area; + s.num_facets += 1; + s.num_flipped += (orient < 0) ? 1 : 0; + s.num_degenerate += (orient == 0) ? 1 : 0; + } + }); + + std::vector chart_stats(num_charts); + for (const auto& v : tls_stats) { + for (Index ci = 0; ci < num_charts; ++ci) { + chart_stats[ci].signed_area += v[ci].signed_area; + chart_stats[ci].num_facets += v[ci].num_facets; + chart_stats[ci].num_flipped += v[ci].num_flipped; + chart_stats[ci].num_degenerate += v[ci].num_degenerate; + } + } + + std::vector chart_flipped(num_charts, 0); + size_t num_flipped = 0; + for (Index ci = 0; ci < num_charts; ++ci) { + const auto& s = chart_stats[ci]; + // No positively-oriented triangles, but at least one flipped one (degenerates allowed). + const bool fully_flipped = + s.num_flipped > 0 && s.num_flipped + s.num_degenerate == s.num_facets; + if (s.signed_area < UVScalar(0) || fully_flipped) { + chart_flipped[ci] = 1; + ++num_flipped; + } + } + if (num_flipped == 0) return 0; + + // Mark every UV vertex referenced by a facet in a flipped chart, then negate its U coordinate. + // Assumes UV vertices are not shared across charts (the typical case for charts produced by + // disconnect_uv_charts or compute_uv_charts on indexed UV attributes). + // Atomic flags: multiple facets can share a UV vertex, so concurrent writes of the same + // value are benign but flagged by ThreadSanitizer without atomic accesses. + const Index num_uv_vertices = static_cast(uv_values.rows()); + std::vector> uv_vertex_flipped(num_uv_vertices); + tbb::parallel_for(Index(0), num_uv_vertices, [&](Index v) { + uv_vertex_flipped[v].store(0, std::memory_order_relaxed); + }); + tbb::parallel_for(Index(0), num_facets, [&](Index f) { + if (!chart_flipped[dense_chart_ids[f]]) return; + const auto c_begin = mesh.get_facet_corner_begin(f); + const auto c_end = mesh.get_facet_corner_end(f); + for (auto c = c_begin; c != c_end; ++c) { + uv_vertex_flipped[uv_indices[c]].store(1, std::memory_order_relaxed); + } + }); + tbb::parallel_for(Index(0), num_uv_vertices, [&](Index v) { + if (uv_vertex_flipped[v].load(std::memory_order_relaxed)) { + uv_values(v, 0) = -uv_values(v, 0); + } + }); + + logger().info("Unflipped {} UV chart(s).", num_flipped); + return num_flipped; +} + +} // namespace + +template +size_t unflip_uv_charts(SurfaceMesh& mesh, const UnflipUVChartsOptions& options) +{ + la_runtime_assert(mesh.is_triangle_mesh(), "unflip_uv_charts: mesh must be a triangle mesh."); + + UVMeshOptions uv_mesh_options; + uv_mesh_options.uv_attribute_name = options.uv_attribute_name; + + return internal::dispatch_uv_scalar_type( + mesh, + uv_mesh_options, + "unflip_uv_charts", + [&](auto tag, AttributeId uv_attr_id) -> size_t { + using UVScalar = typename decltype(tag)::type; + if (!mesh.is_attribute_indexed(uv_attr_id)) { + throw Error("unflip_uv_charts: UV attribute must be indexed."); + } + std::string uv_attr_name(mesh.get_attribute_name(uv_attr_id)); + + std::string chart_attr_name; + auto cleanup_guard = make_scope_guard([&]() noexcept { + if (!chart_attr_name.empty() && mesh.has_attribute(chart_attr_name)) { + mesh.delete_attribute(chart_attr_name); + } + }); + + if (options.chart_id_attribute_name.empty()) { + chart_attr_name = get_unique_attribute_name(mesh, "@_unflip_uv_charts_tmp"); + UVChartOptions chart_options; + chart_options.uv_attribute_name = uv_attr_name; + chart_options.output_attribute_name = chart_attr_name; + compute_uv_charts(mesh, chart_options); + } else { + cleanup_guard.dismiss(); + chart_attr_name = options.chart_id_attribute_name; + } + + if (!mesh.has_attribute(chart_attr_name)) { + throw Error("unflip_uv_charts: chart ID attribute does not exist."); + } + auto chart_id_attr_id = mesh.get_attribute_id(chart_attr_name); + if (mesh.get_attribute_base(chart_id_attr_id).get_element_type() != + AttributeElement::Facet) { + throw Error("unflip_uv_charts: chart ID attribute must be a facet attribute."); + } + auto chart_ids = attribute_vector_view(mesh, chart_id_attr_id); + if (static_cast(chart_ids.size()) != mesh.get_num_facets()) { + throw Error("unflip_uv_charts: chart ID attribute must have one value per facet."); + } + span chart_ids_span{ + chart_ids.data(), + static_cast(chart_ids.size())}; + + std::string orient_attr_name = + get_unique_attribute_name(mesh, "@_unflip_uv_orient_tmp"); + auto orient_cleanup = make_scope_guard([&]() noexcept { + if (mesh.has_attribute(orient_attr_name)) mesh.delete_attribute(orient_attr_name); + }); + UVOrientationOptions orient_options; + orient_options.uv_attribute_name = uv_attr_name; + orient_options.output_attribute_name = orient_attr_name; + compute_uv_orientation(mesh, orient_options); + auto uv_orientation = + attribute_vector_view(mesh, mesh.get_attribute_id(orient_attr_name)); + span uv_orientation_span{ + uv_orientation.data(), + static_cast(uv_orientation.size())}; + + return unflip_uv_charts_impl( + mesh, + uv_attr_id, + chart_ids_span, + uv_orientation_span); + }); +} + +#define LA_X_unflip_uv_charts(_, Scalar, Index) \ + template LA_CORE_API size_t unflip_uv_charts( \ + SurfaceMesh&, \ + const UnflipUVChartsOptions&); +LA_SURFACE_MESH_X(unflip_uv_charts, 0) + +} // namespace lagrange diff --git a/modules/core/tests/test_compute_seam_edges.cpp b/modules/core/tests/test_compute_seam_edges.cpp index e1f416cd..9305fc1f 100644 --- a/modules/core/tests/test_compute_seam_edges.cpp +++ b/modules/core/tests/test_compute_seam_edges.cpp @@ -69,4 +69,51 @@ TEST_CASE("compute_seam_edges", "[core][seam]") auto normal_seam_id = lagrange::compute_seam_edges(mesh, normal_id); REQUIRE(count_seam_edges(mesh, normal_seam_id) == 0); } + + SECTION("Quad with boundary") + { + // Two triangles sharing one interior edge. The indexed UV uses distinct + // indices on each side of that shared edge, so the interior edge is a seam. + // The four other edges are boundary edges. + lagrange::SurfaceMesh mesh(2); + mesh.add_vertex({0, 0}); + mesh.add_vertex({1, 0}); + mesh.add_vertex({1, 1}); + mesh.add_vertex({0, 1}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + + // 6 distinct UV values (one per corner) so the shared interior edge is a seam. + Scalar uv_values[] = { + 0, + 0, + 1, + 0, + 2, + 0, // Triangle 0 corners + 3, + 0, + 4, + 0, + 5, + 0, // Triangle 1 corners + }; + Index uv_indices[] = {0, 1, 2, 3, 4, 5}; + auto uv_id = mesh.template create_attribute( + "uv", + lagrange::AttributeElement::Indexed, + 2, + lagrange::AttributeUsage::UV, + {uv_values, 12}, + {uv_indices, 6}); + + auto seam_id = lagrange::compute_seam_edges(mesh, uv_id); + REQUIRE(count_seam_edges(mesh, seam_id) == 1); + + lagrange::SeamEdgesOptions opts; + opts.include_boundary_edges = true; + opts.output_attribute_name = "@seam_edges_with_boundary"; + auto seam_with_boundary_id = lagrange::compute_seam_edges(mesh, uv_id, opts); + REQUIRE(count_seam_edges(mesh, seam_with_boundary_id) == 5); + } } diff --git a/modules/core/tests/test_compute_uv_charts.cpp b/modules/core/tests/test_compute_uv_charts.cpp index 8056b4bf..bb1ff487 100644 --- a/modules/core/tests/test_compute_uv_charts.cpp +++ b/modules/core/tests/test_compute_uv_charts.cpp @@ -11,7 +11,12 @@ */ #include +#include +#include #include +#include +#include +#include using namespace lagrange; @@ -126,3 +131,84 @@ TEST_CASE("compute_uv_charts: different UV scalar type", "[surface][utilities]") REQUIRE(num_charts == 1); } } + +TEST_CASE("compute_uv_orientation and unflip_uv_charts", "[surface][utilities]") +{ + using Scalar = double; + using Index = uint32_t; + + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 3, 2); + + SECTION("All flipped") + { + std::vector uv_values = {0, 0, 0, 1, 1, 0, 1, 1}; + std::vector uv_indices = {0, 1, 2, 1, 3, 2}; + + mesh.template create_attribute( + "uv", + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + {uv_values.data(), uv_values.size()}, + {uv_indices.data(), uv_indices.size()}); + + auto counts = compute_uv_orientation(mesh); + REQUIRE(counts.negative == 2); + REQUIRE(counts.positive == 0); + + size_t n_unflipped = unflip_uv_charts(mesh); + REQUIRE(n_unflipped == 1); + REQUIRE(compute_uv_orientation(mesh).negative == 0); + + // U should be negated for every UV vertex in the (single, flipped) chart; + // V and corner indices should be unchanged. + auto& uv_attr = mesh.template get_indexed_attribute("uv"); + auto post_values = matrix_view(uv_attr.values()); + auto post_indices = vector_view(uv_attr.indices()); + const std::array, 4> expected_values = { + {{0, 0}, {0, 1}, {-1, 0}, {-1, 1}}}; + for (Index v = 0; v < 4; ++v) { + REQUIRE(post_values(v, 0) == expected_values[v][0]); + REQUIRE(post_values(v, 1) == expected_values[v][1]); + } + const std::array expected_indices = {0, 1, 2, 1, 3, 2}; + for (size_t i = 0; i < expected_indices.size(); ++i) { + REQUIRE(post_indices[i] == expected_indices[i]); + } + } + + SECTION("Mixed: only flipped chart is unflipped") + { + // Chart A (UV verts 0..2, tri0) is CCW. Chart B (UV verts 3..5, tri1) is CW. + std::vector uv_values = {0, 0, 1, 0, 0, 1, 2, 0, 2, 1, 3, 0}; + std::vector uv_indices = {0, 1, 2, 3, 4, 5}; + + mesh.template create_attribute( + "uv", + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + {uv_values.data(), uv_values.size()}, + {uv_indices.data(), uv_indices.size()}); + + REQUIRE(compute_uv_orientation(mesh).negative == 1); + REQUIRE(unflip_uv_charts(mesh) == 1); + REQUIRE(compute_uv_orientation(mesh).negative == 0); + + // Chart A vertices (0..2) untouched; Chart B vertices (3..5) have negated U. + auto& uv_attr = mesh.template get_indexed_attribute("uv"); + auto post = matrix_view(uv_attr.values()); + const std::array, 6> expected = { + {{0, 0}, {1, 0}, {0, 1}, {-2, 0}, {-2, 1}, {-3, 0}}}; + for (Index v = 0; v < 6; ++v) { + REQUIRE(post(v, 0) == expected[v][0]); + REQUIRE(post(v, 1) == expected[v][1]); + } + } +} diff --git a/modules/core/tests/test_dijkstra.cpp b/modules/core/tests/test_dijkstra.cpp index 10578c83..8117b791 100644 --- a/modules/core/tests/test_dijkstra.cpp +++ b/modules/core/tests/test_dijkstra.cpp @@ -15,17 +15,134 @@ #include #include +#include #include +#include #include #include -TEST_CASE("dijkstra", "[surface][internal][utility]") +#include +#include +#include + +namespace { + +template +struct GridInfo +{ + lagrange::SurfaceMesh mesh; + Eigen::Vector3 seed_pt; + std::array seeds{}; + std::array seed_dist{}; +}; + +/// Run dijkstra with Euclidean edge-length distances; returns per-vertex distances (infinity +/// for unreached). +template +std::vector run_dijkstra( + lagrange::SurfaceMesh& mesh, + lagrange::span seeds, + lagrange::span seed_dists, + const lagrange::internal::DijkstraOptions& opts) +{ + auto vertices = lagrange::vertex_view(mesh); + auto dist_fn = [&](Index vi, Index vj) { return (vertices.row(vi) - vertices.row(vj)).norm(); }; + std::vector got(mesh.get_num_vertices(), std::numeric_limits::infinity()); + lagrange::internal::dijkstra( + mesh, + seeds, + seed_dists, + opts, + dist_fn, + [&](Index vi, Scalar d) { got[vi] = d; }); + return got; +} + +/// Build an `n x n` triangle grid with cell size `(hx, hy)`, seed at the middle facet's +/// centroid, and export the mesh for inspection. +template +GridInfo make_grid(Index n, Scalar hx, Scalar hy, const std::string& name) +{ + using namespace lagrange; + GridInfo info; + auto& mesh = info.mesh; + mesh = SurfaceMesh(3); + mesh.add_vertices(n * n); + auto vertices = vertex_ref(mesh); + for (Index j = 0; j < n; ++j) { + for (Index i = 0; i < n; ++i) { + vertices.row(j * n + i) << hx * static_cast(i), hy * static_cast(j), + Scalar(0); + } + } + mesh.add_triangles((n - 1) * (n - 1) * 2); + auto facets = facet_ref(mesh); + for (Index j = 0; j < n - 1; ++j) { + for (Index i = 0; i < n - 1; ++i) { + Index v00 = j * n + i; + Index v10 = j * n + (i + 1); + Index v01 = (j + 1) * n + i; + Index v11 = (j + 1) * n + (i + 1); + facets.row((j * (n - 1) + i) * 2 + 0) << v00, v10, v01; + facets.row((j * (n - 1) + i) * 2 + 1) << v10, v11, v01; + } + } + + Index seed_facet = ((n - 1) / 2 * (n - 1) + (n - 1) / 2) * 2; + auto fv = mesh.get_facet_vertices(seed_facet); + info.seed_pt = Eigen::Vector3::Zero(); + for (int k = 0; k < 3; ++k) { + info.seeds[k] = fv[k]; + info.seed_pt += Eigen::Vector3(vertices.row(fv[k])); + } + info.seed_pt /= Scalar(3); + for (int k = 0; k < 3; ++k) { + info.seed_dist[k] = + (Eigen::Vector3(vertices.row(info.seeds[k])) - info.seed_pt).norm(); + } + + fs::path output_dir = lagrange::testing::get_test_output_dir() / "dijkstra"; + fs::create_directories(output_dir); + fs::path output_path = output_dir / (name + ".obj"); + fs::ofstream output_stream(output_path); + lagrange::io::save_mesh_obj(output_stream, mesh); + lagrange::logger().info("Exported grid '{}' to {}", name, output_path.string()); + + return info; +} + +} // namespace + +TEST_CASE("dijkstra geodesic radius", "[surface][internal][utility]") { using namespace lagrange; using Scalar = double; using Index = uint32_t; - SECTION("quad") + auto check = [](SurfaceMesh& mesh, + span expected_dist, + Scalar radius, + size_t expected_reached) { + Index seed = 0; + Scalar seed_dist = 0; + lagrange::internal::DijkstraOptions opts; + opts.geodesic_radius = radius; + auto got = run_dijkstra( + mesh, + span(&seed, 1), + span(&seed_dist, 1), + opts); + size_t num_reached = 0; + for (Index vi = 0; vi < mesh.get_num_vertices(); ++vi) { + if (!std::isfinite(got[vi])) continue; + REQUIRE(got[vi] <= radius); + REQUIRE(got[vi] == Catch::Approx(expected_dist[vi])); + ++num_reached; + } + REQUIRE(num_reached == expected_reached); + }; + + SECTION("triangulated quad") { SurfaceMesh mesh; mesh.add_vertex({0, 0, 0}); @@ -34,42 +151,13 @@ TEST_CASE("dijkstra", "[surface][internal][utility]") mesh.add_vertex({1, 1, 0}); mesh.add_triangle(0, 1, 2); mesh.add_triangle(2, 1, 3); - - Index seed_vertices[] = {0}; - Scalar seed_vertex_distance[] = {0}; - Scalar expected_dist[]{0, 1, 1, 2}; - - auto vertices = vertex_view(mesh); - auto check = [&](Scalar radius, size_t expected_num_vertices_reached) { - auto dist = [&](Index vi, Index vj) { - return (vertices.row(vi) - vertices.row(vj)).norm(); - }; - - size_t num_vertices_reached = 0; - auto process = [&](Index vi, Scalar d) -> bool { - logger().debug("{}: {}", vi, d); - REQUIRE(d < radius); - REQUIRE(d == Catch::Approx(expected_dist[vi])); - num_vertices_reached++; - return false; - }; - - lagrange::internal::dijkstra( - mesh, - seed_vertices, - seed_vertex_distance, - radius, - dist, - process); - REQUIRE(expected_num_vertices_reached == num_vertices_reached); - }; - - check(0.1, 1); - check(1.1, 3); - check(2.1, 4); + std::array expected{0, 1, 1, 2}; + check(mesh, span(expected.data(), expected.size()), 0.1, 1); + check(mesh, span(expected.data(), expected.size()), 1.1, 3); + check(mesh, span(expected.data(), expected.size()), 2.1, 4); } - SECTION("mixed") + SECTION("mixed quad and triangles") { SurfaceMesh mesh; mesh.add_vertex({0, 0, 0}); @@ -81,40 +169,222 @@ TEST_CASE("dijkstra", "[surface][internal][utility]") mesh.add_quad(0, 2, 3, 1); mesh.add_triangle(1, 3, 4); mesh.add_triangle(4, 3, 5); + std::array expected{0, 1, 1, 2, 2, 3}; + check(mesh, span(expected.data(), expected.size()), 0.1, 1); + check(mesh, span(expected.data(), expected.size()), 1.1, 3); + check(mesh, span(expected.data(), expected.size()), 2.1, 5); + check(mesh, span(expected.data(), expected.size()), 3.1, 6); + } +} + +TEST_CASE("dijkstra euclidean radius on grid", "[surface][internal][utility]") +{ + // Sweep (geo_radius, euclidean_radius) on an (n x n) triangle grid (optionally Y-scaled) + // and check the reached set against a brute-force ground truth. + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; - Index seed_vertices[]{0}; - Scalar seed_vertex_distance[]{0}; - Scalar expected_dist[]{0, 1, 1, 2, 2, 3}; + auto run_grid = [](Index n, Scalar y_scale, const std::string& name) { + INFO("grid n=" << n << " y_scale=" << y_scale); + Scalar h = Scalar(1) / static_cast(n - 1); + auto info = make_grid(n, h, h * y_scale, name); + auto& mesh = info.mesh; auto vertices = vertex_view(mesh); - auto check = [&](Scalar radius, size_t expected_num_vertices_reached) { - auto dist = [&](Index vi, Index vj) { - return (vertices.row(vi) - vertices.row(vj)).norm(); - }; + auto seeds = span(info.seeds.data(), 3); + auto seed_dists = span(info.seed_dist.data(), 3); - size_t num_vertices_reached = 0; - auto process = [&](Index vi, Scalar d) -> bool { - logger().debug("{}: {}", vi, d); - REQUIRE(d < radius); - REQUIRE(d == Catch::Approx(expected_dist[vi])); - num_vertices_reached++; - return false; - }; + // Brute-force ground-truth graph distances from the seed point (no radius). + auto truth_dist = run_dijkstra(mesh, seeds, seed_dists, {}); + for (Index vi = 0; vi < n * n; ++vi) { + REQUIRE(std::isfinite(truth_dist[vi])); + } - lagrange::internal::dijkstra( - mesh, - seed_vertices, - seed_vertex_distance, - radius, - dist, - process); - REQUIRE(expected_num_vertices_reached == num_vertices_reached); + // `min_fallback_count` asserts that at least N vertices are reached only via the + // Euclidean fallback (inside the Euclidean ball but outside the geodesic ball). + auto check = [&](Scalar geo_radius, Scalar euclidean_radius, Index min_fallback_count = 0) { + INFO("geo_radius=" << geo_radius << " euclidean_radius=" << euclidean_radius); + lagrange::internal::DijkstraOptions opts; + opts.geodesic_radius = geo_radius; + opts.euclidean_radius = euclidean_radius; + opts.seed_position = info.seed_pt; + auto got_dist = run_dijkstra(mesh, seeds, seed_dists, opts); + + Scalar effective_geo = geo_radius > 0 ? geo_radius : std::numeric_limits::max(); + bool has_eucl = euclidean_radius > 0; + Scalar eucl_sq = euclidean_radius * euclidean_radius; + auto is_seed = [&](Index vi) { + return vi == info.seeds[0] || vi == info.seeds[1] || vi == info.seeds[2]; + }; + // Vertices in either ball are reached with the exact graph distance. Chord-bridge + // extras outside both balls may also be reached, but only with an upper-bound + // distance (the true shortest path may transit unbridged vertices). + Index fallback_count = 0; + for (Index vi = 0; vi < n * n; ++vi) { + bool within_geo = truth_dist[vi] <= effective_geo; + bool within_eucl = + has_eucl && + (Eigen::Vector3(vertices.row(vi)) - info.seed_pt).squaredNorm() <= + eucl_sq; + bool expected = is_seed(vi) || within_geo || within_eucl; + bool reached = std::isfinite(got_dist[vi]); + INFO( + "vertex " << vi << " expected=" << expected << " reached=" << reached + << " truth_dist=" << truth_dist[vi] << " got_dist=" << got_dist[vi]); + if (expected) { + REQUIRE(reached); + REQUIRE(got_dist[vi] == Catch::Approx(truth_dist[vi])); + } else if (reached) { + REQUIRE(got_dist[vi] >= truth_dist[vi] - Scalar(1e-9)); + } + if (within_eucl && !within_geo && !is_seed(vi)) { + ++fallback_count; + } + } + INFO( + "fallback_count=" << fallback_count + << " min_fallback_count=" << min_fallback_count); + REQUIRE(fallback_count >= min_fallback_count); }; - check(0.1, 1); - check(1.1, 3); - check(2.1, 5); - check(3.1, 6); + check(Scalar(0.25), Scalar(0)); // geodesic-only, small radius + check(Scalar(2.0), Scalar(0)); // geodesic-only, full mesh + check(Scalar(0.001), Scalar(0.5)); // euclidean-only + check(Scalar(0.2), Scalar(0.5)); // eucl extends geo + check(Scalar(0.5), Scalar(0.1)); // geo dominates + }; + + run_grid(/*n=*/9, /*y_scale=*/Scalar(1), "grid_9_iso"); + run_grid(/*n=*/9, /*y_scale=*/Scalar(0.25), "grid_9_y025"); + run_grid(/*n=*/11, /*y_scale=*/Scalar(0.5), "grid_11_y05"); +} + +TEST_CASE("dijkstra euclidean chord bridge", "[surface][internal][utility]") +{ + // Two-triangle mesh where the only graph path from `v0` to `v3` transits `v2`/`v4` (both + // outside both balls). Geodesic-only drops `v3`; the Euclidean variant recovers it via the + // chord (v4, v3), whose closest point to the seed is `v3` itself. + // + // v1 --- v2 ----- v3 + // \ / \ / + // \ / \ / + // v0 \ / + // v4 + // + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SurfaceMesh mesh; + mesh.add_vertex({Scalar(0.0), Scalar(0.0), Scalar(0.0)}); // v0 (seed) + mesh.add_vertex({Scalar(0.5), Scalar(0.0), Scalar(0.0)}); // v1 (in scope) + mesh.add_vertex({Scalar(3.0), Scalar(1.0), Scalar(0.0)}); // v2 (out of scope) + mesh.add_vertex({Scalar(2.0), Scalar(0.0), Scalar(0.0)}); // v3 (target, in eucl ball) + mesh.add_vertex({Scalar(3.0), Scalar(-1.0), Scalar(0.0)}); // v4 (out of scope) + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(2, 4, 3); + + Index seed = 0; + Scalar seed_dist = 0; + auto seeds = span(&seed, 1); + auto seed_dists = span(&seed_dist, 1); + + SECTION("geodesic-only leaves v3 unreachable") + { + lagrange::internal::DijkstraOptions opts; + opts.geodesic_radius = Scalar(2.0); + auto got = run_dijkstra(mesh, seeds, seed_dists, opts); + + REQUIRE(std::isfinite(got[0])); + REQUIRE(std::isfinite(got[1])); + REQUIRE_FALSE(std::isfinite(got[2])); + REQUIRE_FALSE(std::isfinite(got[3])); + REQUIRE_FALSE(std::isfinite(got[4])); + } + + SECTION("euclidean fallback recovers v3 via chord bridge") + { + lagrange::internal::DijkstraOptions opts; + opts.geodesic_radius = Scalar(2.0); + opts.euclidean_radius = Scalar(2.0); + auto got = run_dijkstra(mesh, seeds, seed_dists, opts); + + // All five vertices are reached: v0/v1 in scope, v2/v3/v4 as chord-bridge endpoints. + for (Index vi = 0; vi < 5; ++vi) { + INFO("vertex " << vi); + REQUIRE(std::isfinite(got[vi])); + } + } + + SECTION("euclidean fallback with too-tight radius cannot reach v3") + { + // Shrink the Euclidean ball below the chord (v4, v3) so the bridge can no longer fire. + lagrange::internal::DijkstraOptions opts; + opts.geodesic_radius = Scalar(2.0); + opts.euclidean_radius = Scalar(1.5); + auto got = run_dijkstra(mesh, seeds, seed_dists, opts); + + REQUIRE(std::isfinite(got[0])); + REQUIRE(std::isfinite(got[1])); + REQUIRE_FALSE(std::isfinite(got[3])); + } +} + +TEST_CASE("dijkstra chord bridge across visited edges", "[surface][internal][utility]") +{ + // Regression for an edge-visitation bug in the chord-bridge handler. Three triangles fan + // around the seed `v0`; only the middle triangle's chord (vL, vR) bridges through the seed, + // but its two edges incident to `v0` are shared with the flanking triangles. The facet + // add-order is chosen so the bridging triangle is walked last around `v0`, so both shared + // edges are already marked visited when the bridge fires. The handler must still enqueue + // `vL` and `vR` despite the visited flag (the `!chord_bridge` guard in `try_enqueue`). + // + // vAR(-3,3) vAL(3,3) + // \ / + // \ f_c f_a / + // \ / \ / + // vR(-3,0)--f_b--vL(3,0) + // \ / + // \ / + // v0(0,0) + // + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SurfaceMesh mesh; + mesh.add_vertex({Scalar(0), Scalar(0), Scalar(0)}); // v0 (seed) + mesh.add_vertex({Scalar(3), Scalar(0), Scalar(0)}); // vL + mesh.add_vertex({Scalar(-3), Scalar(0), Scalar(0)}); // vR + mesh.add_vertex({Scalar(3), Scalar(3), Scalar(0)}); // vAL + mesh.add_vertex({Scalar(-3), Scalar(3), Scalar(0)}); // vAR + + // Corners around v0 are linked LIFO during edge initialization, so the corner walk visits + // facets in reverse add-order. Adding f_b first ensures it is walked last around v0. + mesh.add_triangle(0, 1, 2); // f_b: bridging, chord (vL, vR) passes through origin + mesh.add_triangle(0, 3, 1); // f_a: shares edge (v0, vL); chord (vAL, vL) does not bridge + mesh.add_triangle(0, 2, 4); // f_c: shares edge (v0, vR); chord (vR, vAR) does not bridge + + Index seed = 0; + Scalar seed_dist = 0; + auto seeds = span(&seed, 1); + auto seed_dists = span(&seed_dist, 1); + + // Geodesic ball is tiny so no neighbor is geodesically in scope; Euclidean ball excludes + // every non-seed vertex (||vL|| = ||vR|| = 3, ||vAL|| = ||vAR|| = sqrt(18)). Only the + // (vL, vR) chord bridges. + lagrange::internal::DijkstraOptions opts; + opts.geodesic_radius = Scalar(0.1); + opts.euclidean_radius = Scalar(2); + auto got = run_dijkstra(mesh, seeds, seed_dists, opts); + + // With the fix, vL and vR are enqueued by the bridging facet despite the shared edges + // being already marked visited; vAL and vAR are then reached as further chord-bridge + // endpoints from vL and vR. + for (Index vi = 0; vi < 5; ++vi) { + INFO("vertex " << vi); + REQUIRE(std::isfinite(got[vi])); } } @@ -134,17 +404,14 @@ TEST_CASE("dijkstra benchmark", "[surface][utility][internal][!benchmark]") size_t count = 0; Index sources[]{0}; Scalar source_dist[]{0}; - auto process = [&](Index, Scalar) { - count++; - return false; - }; + auto process = [&](Index, Scalar) { count++; }; meter.measure([&]() { lagrange::internal::dijkstra( mesh, sources, source_dist, - 0, + lagrange::internal::DijkstraOptions{}, dist, process); return count; diff --git a/modules/core/tests/test_disconnect_uv_charts.cpp b/modules/core/tests/test_disconnect_uv_charts.cpp index 77b44045..e04d3a9a 100644 --- a/modules/core/tests/test_disconnect_uv_charts.cpp +++ b/modules/core/tests/test_disconnect_uv_charts.cpp @@ -311,6 +311,160 @@ TEST_CASE("disconnect_uv_charts", "[surface][utilities]") CHECK(indices[2] != indices[6]); } + SECTION("Bowtie within a single user-supplied chart") + { + // Two triangles touching at UV vertex 2 only (no shared UV edge). The user labels both + // facets as the same chart, so the chart-split alone would not duplicate vertex 2. The + // bowtie pass must still split the pinch point so that the two wedges get independent + // UV indices, leaving the UV mesh manifold for downstream consumers (e.g. repack). + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 0.5, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(2, 3, 4); + + add_indexed_uv(mesh, {0, 0, 1, 0, 0.5, 0.5, 0, 1, 1, 1}, {0, 1, 2, 2, 3, 4}); + + mesh.template create_attribute( + "@chart_id", + AttributeElement::Facet, + AttributeUsage::Scalar, + 1, + std::vector{0, 0}); // both facets in the same chart + + DisconnectUVChartsOptions opts; + opts.chart_id_attribute_name = "@chart_id"; + auto num_duped = disconnect_uv_charts(mesh, opts); + CHECK(num_duped == 1); + CHECK(get_num_uv_values(mesh) == 6); + + auto indices = get_uv_indices(mesh); + // The two corners that previously shared UV index 2 must now reference different indices. + CHECK(indices[2] != indices[3]); + } + + SECTION("Multiple wedges within a single user-supplied chart") + { + // Four triangles meeting at UV vertex 0, forming two wedges of two triangles each: + // wedge A: triangles 0,1 connected via UV edge 0-2 + // wedge B: triangles 2,3 connected via UV edge 0-5 + // Wedge A and wedge B share only UV vertex 0 (no UV edge between them). + // With all facets labeled as chart 0, the bowtie pass should produce exactly one + // duplicate of vertex 0 (the second wedge gets a new index, the first keeps the original). + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({-1, 0, 0}); + mesh.add_vertex({-1, -1, 0}); + mesh.add_vertex({0, -1, 0}); + mesh.add_triangle(0, 1, 2); // wedge A + mesh.add_triangle(0, 2, 3); // wedge A + mesh.add_triangle(0, 4, 5); // wedge B + mesh.add_triangle(0, 5, 6); // wedge B + + add_indexed_uv( + mesh, + {0, 0, 1, 0, 1, 1, 0, 1, -1, 0, -1, -1, 0, -1}, + {0, 1, 2, 0, 2, 3, 0, 4, 5, 0, 5, 6}); + + mesh.template create_attribute( + "@chart_id", + AttributeElement::Facet, + AttributeUsage::Scalar, + 1, + std::vector{0, 0, 0, 0}); + + DisconnectUVChartsOptions opts; + opts.chart_id_attribute_name = "@chart_id"; + auto num_duped = disconnect_uv_charts(mesh, opts); + CHECK(num_duped == 1); // one extra wedge → one duplicate + CHECK(get_num_uv_values(mesh) == 8); + + auto indices = get_uv_indices(mesh); + // Wedge A facets (f0, f1) keep a single shared index for vertex 0. + CHECK(indices[0] == indices[3]); + // Wedge B facets (f2, f3) keep a single shared (different) index for vertex 0. + CHECK(indices[6] == indices[9]); + // Wedge A and wedge B must use different indices for vertex 0. + CHECK(indices[0] != indices[6]); + } + + SECTION("Bowtie split combined with chart split") + { + // Three triangles meeting at UV vertex 0 with no shared UV edges. The user supplies + // chart_id = {0, 0, 1}. The first two facets are a single chart with an internal + // bowtie (1 bowtie duplicate). The third facet is in a different chart and triggers a + // chart-level duplicate (1 chart duplicate). Total: 2 duplicates of vertex 0. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 1, 0}); + mesh.add_vertex({-1, 0, 0}); + mesh.add_vertex({-0.5, 1, 0}); + mesh.add_vertex({0, -1, 0}); + mesh.add_vertex({1, -1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 3, 4); + mesh.add_triangle(0, 5, 6); + + add_indexed_uv( + mesh, + {0, 0, 1, 0, 0.5, 1, -1, 0, -0.5, 1, 0, -1, 1, -1}, + {0, 1, 2, 0, 3, 4, 0, 5, 6}); + + mesh.template create_attribute( + "@chart_id", + AttributeElement::Facet, + AttributeUsage::Scalar, + 1, + std::vector{0, 0, 1}); + + DisconnectUVChartsOptions opts; + opts.chart_id_attribute_name = "@chart_id"; + auto num_duped = disconnect_uv_charts(mesh, opts); + CHECK(num_duped == 2); + CHECK(get_num_uv_values(mesh) == 9); + + auto indices = get_uv_indices(mesh); + // All three facets must reference different indices for the formerly-shared vertex 0. + CHECK(indices[0] != indices[3]); + CHECK(indices[0] != indices[6]); + CHECK(indices[3] != indices[6]); + } + + SECTION("Bowtie pass leaves manifold input untouched") + { + // Two triangles connected by a real UV edge form a single wedge at every shared + // vertex. The bowtie pass must not introduce spurious duplicates. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 3, 2); + + add_indexed_uv(mesh, {0, 0, 1, 0, 0, 1, 1, 1}, {0, 1, 2, 1, 3, 2}); + + mesh.template create_attribute( + "@chart_id", + AttributeElement::Facet, + AttributeUsage::Scalar, + 1, + std::vector{0, 0}); + + DisconnectUVChartsOptions opts; + opts.chart_id_attribute_name = "@chart_id"; + auto num_duped = disconnect_uv_charts(mesh, opts); + CHECK(num_duped == 0); + CHECK(get_num_uv_values(mesh) == 4); + } + SECTION("Interleaved chart ordering") { // Facets from two charts are interleaved: A, B, A, B. @@ -350,16 +504,16 @@ TEST_CASE("disconnect_uv_charts", "[surface][utilities]") DisconnectUVChartsOptions opts; opts.chart_id_attribute_name = "@chart_id"; auto num_duped = disconnect_uv_charts(mesh, opts); - CHECK(num_duped == 1); // vertex 0 duplicated once for chart B - CHECK(get_num_uv_values(mesh) == 10); // 9 original + 1 duplicate + // 1 chart-split duplicate (vertex 0 between chart A and chart B), plus 2 bowtie + // duplicates (one within chart A for f0/f2, one within chart B for f1/f3). + CHECK(num_duped == 3); + CHECK(get_num_uv_values(mesh) == 12); auto indices = get_uv_indices(mesh); - // Chart A facets (f0, f2) should share the same index for vertex 0 - CHECK(indices[0] == indices[6]); - // Chart B facets (f1, f3) should share the same index for vertex 0 - CHECK(indices[3] == indices[9]); - // Chart A and B should have different indices for vertex 0 - CHECK(indices[0] != indices[3]); + // After bowtie disconnection, all four facets should reference distinct indices for the + // formerly-shared UV vertex 0. + std::set v0_refs{indices[0], indices[3], indices[6], indices[9]}; + CHECK(v0_refs.size() == 4); } } diff --git a/modules/core/tests/test_orient_outward.cpp b/modules/core/tests/test_orient_outward.cpp index 1178aa58..ee361300 100644 --- a/modules/core/tests/test_orient_outward.cpp +++ b/modules/core/tests/test_orient_outward.cpp @@ -12,12 +12,16 @@ #include #include +#include #include #include +#include #include #include #include +#include #include +#include #include #include #include @@ -170,6 +174,66 @@ TEST_CASE("orient_outward: double flip", "[mesh][orient]" LA_CORP_FLAG) } } +TEST_CASE("orient_outward: bear", "[mesh][orient]" LA_CORP_FLAG) +{ + using Scalar = double; + using Index = uint32_t; + + // bear.obj is stored as a triangle soup: 36606 vertex entries, only 6103 unique + // positions, every triangle has its own copies of its corners. Loading with + // `stitch_vertices = true` welds positions so that adjacent triangles share + // vertex indices. + auto path = lagrange::testing::get_data_path("corp/core/bear.obj"); + lagrange::io::LoadOptions options; + options.quiet = true; + options.stitch_vertices = true; + auto mesh = lagrange::io::load_mesh>(path, options); + + // Sanity: stitching should bring the vertex count down to the unique-position count. + REQUIRE(mesh.get_num_vertices() == 6103); + + // After stitching, the mesh is closed: no boundary edges remain. + REQUIRE(is_closed(mesh)); + + // The stitched mesh is a single connected component. + { + auto copy = mesh; + auto num_components = compute_components(copy); + REQUIRE(num_components == 1); + } + + CHECK(!is_oriented(mesh)); + + // Orient outward should orient the mesh + orient_outward(mesh); + + // The oriented mesh is a single connected component. + { + auto copy = mesh; + auto num_components = compute_components(copy); + REQUIRE(num_components == 1); + } + + // (a) Edges should be oriented (each interior edge traversed once in each direction). + CHECK(is_oriented(mesh)); + + // (b) With `positive = true`, the resulting closed mesh should enclose a positive + // signed volume. Using the same tetra-from-origin formula as orient_outward.cpp. + auto vertices = vertex_view(mesh).leftCols<3>().template cast(); + double total_signed_volume = 0.0; + for (Index f = 0; f < mesh.get_num_facets(); ++f) { + auto facet = mesh.get_facet_vertices(f); + Eigen::RowVector3d p1 = vertices.row(facet[0]); + Eigen::RowVector3d p2 = vertices.row(facet[1]); + Eigen::RowVector3d p3 = vertices.row(facet[2]); + total_signed_volume += p1.dot(p2.cross(p3)) / 6.0; + } + CAPTURE(total_signed_volume); + CHECK(total_signed_volume > 0.0); + + // TODO: Add support for disconnected triangle soups in orient_outward. +} + TEST_CASE("orient_outward: poly", "[mesh][orient]") { using Scalar = double; diff --git a/modules/geodesic/python/tests/conftest.py b/modules/geodesic/python/tests/conftest.py index c6306c8b..0f2dc038 100644 --- a/modules/geodesic/python/tests/conftest.py +++ b/modules/geodesic/python/tests/conftest.py @@ -1,4 +1,5 @@ -# Copyright 2025 Adobe. All rights reserved. +# +# 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 @@ -7,6 +8,7 @@ # 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. +# """ Pytest fixtures for geodesic module tests. diff --git a/modules/geodesic/src/GeodesicEngineDGPC.cpp b/modules/geodesic/src/GeodesicEngineDGPC.cpp index dd7e9c23..04dccfc4 100644 --- a/modules/geodesic/src/GeodesicEngineDGPC.cpp +++ b/modules/geodesic/src/GeodesicEngineDGPC.cpp @@ -367,6 +367,7 @@ SingleSourceGeodesicResult GeodesicEngineDGPC::single_source_geod auto valence_attr_id = compute_vertex_valence(this->mesh()); const auto& valence = attribute_matrix_view(this->mesh(), valence_attr_id); la_runtime_assert(!Q.empty()); + std::vector adj_facets; while (!Q.empty()) { const auto entry = Q.top(); const auto v = entry.first; @@ -380,7 +381,7 @@ SingleSourceGeodesicResult GeodesicEngineDGPC::single_source_geod counters[v]++; if (counters[v] > valence(v, 0)) continue; - std::vector adj_facets; + adj_facets.clear(); this->mesh().foreach_facet_around_vertex(v, [&](Index fi) { adj_facets.push_back(fi); }); for (const auto fi : adj_facets) { const Eigen::Matrix f = facets.row(fi); diff --git a/modules/geodesic/tests/test_geodesic_path.cpp b/modules/geodesic/tests/test_geodesic_path.cpp index 00474bed..1704132f 100644 --- a/modules/geodesic/tests/test_geodesic_path.cpp +++ b/modules/geodesic/tests/test_geodesic_path.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 Adobe. All rights reserved. + * 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 diff --git a/modules/image/include/lagrange/image/ImageType.h b/modules/image/include/lagrange/image/ImageType.h index c547000e..efda05cf 100644 --- a/modules/image/include/lagrange/image/ImageType.h +++ b/modules/image/include/lagrange/image/ImageType.h @@ -22,6 +22,7 @@ namespace image { enum class ImagePrecision : unsigned int { uint8, int8, + uint16, uint32, int32, float32, @@ -77,6 +78,19 @@ static_assert(false, "LAGRANGE_IMAGE_COMMA was defined somewhere else") }; #define LAGRANGE_IMAGE_COMMA , +LAGRANGE_IMAGE_TRAITS(uint16_t, uint16_t, 1, uint16, one) +LAGRANGE_IMAGE_TRAITS( + Eigen::Matrix, + uint16_t, + 3, + uint16, + three) +LAGRANGE_IMAGE_TRAITS( + Eigen::Matrix, + uint16_t, + 4, + uint16, + four) LAGRANGE_IMAGE_TRAITS(unsigned char, unsigned char, 1, uint8, one) LAGRANGE_IMAGE_TRAITS( Eigen::Matrix, @@ -162,6 +176,19 @@ LAGRANGE_IMAGE_TRAITS( return static_cast( std::clamp(val, static_cast(0), static_cast(1)) * static_cast(std::numeric_limits::max())); + } + // convert from uint16_t to float/double: normalize [0, 65535] -> [0, 1] + else if constexpr ( + std::is_same::value && std::is_floating_point::value) { + return static_cast(val) / + static_cast(std::numeric_limits::max()); + } + // convert from float/double to uint16_t: [0, 1] -> [0, 65535] + else if constexpr ( + std::is_floating_point::value && std::is_same::value) { + return static_cast( + std::clamp(val, static_cast(0), static_cast(1)) * + static_cast(std::numeric_limits::max())); } else { // clamping, prepare to convert from signed to unsigned if constexpr (std::is_signed::value && !std::is_signed::value) { diff --git a/modules/image/include/lagrange/image/split_grid.h b/modules/image/include/lagrange/image/split_grid.h new file mode 100644 index 00000000..18782541 --- /dev/null +++ b/modules/image/include/lagrange/image/split_grid.h @@ -0,0 +1,159 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +namespace lagrange::image::experimental { + +/// @addtogroup module-image +/// @{ + +/// +/// Options controlling how a grid image is split by split_grid(). +/// +/// A value of zero on either ``rows`` or ``cols`` means "auto-detect". The resolution rules are: +/// - ``rows = 0, cols = 0``: pick the factorization producing cells closest to square. +/// - ``rows = R, cols = 0``: derive ``cols = num_cells / R``. +/// - ``rows = 0, cols = C``: derive ``rows = num_cells / C``. +/// - ``rows = R, cols = C``: validate ``R * C == num_cells``. +/// +/// In all cases, the grid extents must be divisible by the resulting layout. +/// +struct SplitGridOptions +{ + /// Number of cells the grid contains. + size_t num_cells = 0; + + /// Number of cell rows in the grid (0 = auto). + size_t rows = 0; + + /// Number of cell columns in the grid (0 = auto). + size_t cols = 0; +}; + +/// +/// Split a grid image into row-major cell views. +/// +/// The returned views share memory with the input grid image (no copy). The layout is controlled +/// by ``options``; see SplitGridOptions for resolution rules. +/// +/// @param[in] grid Grid image of shape ``(width, height, channels)`` per View3D axis +/// ordering (note: the Python binding accepts HxWxC numpy arrays and +/// transposes the axes internally). +/// @param[in] options Options including ``num_cells`` and an optional explicit layout. +/// +/// @tparam T Pixel element type (e.g. ``float``, ``const float``, ``uint8_t``). +/// +/// @return List of ``options.num_cells`` views into the grid, ordered row-major. +/// +template +std::vector> split_grid(View3D grid, const SplitGridOptions& options) +{ + const size_t grid_width = grid.extent(0); + const size_t grid_height = grid.extent(1); + const size_t num_channels = grid.extent(2); + const size_t num_cells = options.num_cells; + la_runtime_assert(num_cells > 0, "num_cells must be greater than 0"); + + auto resolve = [&]() -> std::pair { + if (options.rows != 0 && options.cols != 0) { + return {options.rows, options.cols}; + } + if (options.rows != 0) { + la_runtime_assert( + num_cells % options.rows == 0, + lagrange::format( + "Number of cells ({}) is not divisible by rows ({})", + num_cells, + options.rows)); + return {options.rows, num_cells / options.rows}; + } + if (options.cols != 0) { + la_runtime_assert( + num_cells % options.cols == 0, + lagrange::format( + "Number of cells ({}) is not divisible by cols ({})", + num_cells, + options.cols)); + return {num_cells / options.cols, options.cols}; + } + size_t best_rows = 0; + size_t best_cols = 0; + double best_aspect_diff = std::numeric_limits::max(); + for (size_t cols = 1; cols <= num_cells; ++cols) { + if (num_cells % cols != 0) continue; + size_t rows = num_cells / cols; + if (grid_width % cols != 0 || grid_height % rows != 0) continue; + size_t cell_w = grid_width / cols; + size_t cell_h = grid_height / rows; + double aspect_diff = std::abs(static_cast(cell_w) / cell_h - 1.0); + if (aspect_diff < best_aspect_diff) { + best_aspect_diff = aspect_diff; + best_rows = rows; + best_cols = cols; + } + } + la_runtime_assert( + best_cols > 0, + lagrange::format( + "Cannot evenly divide grid image ({}x{}) into {} cells", + grid_width, + grid_height, + num_cells)); + return {best_rows, best_cols}; + }; + + auto [rows, cols] = resolve(); + la_runtime_assert( + rows * cols == num_cells, + lagrange::format( + "Layout {}x{} does not match number of cells ({})", + rows, + cols, + num_cells)); + la_runtime_assert( + grid_width % cols == 0 && grid_height % rows == 0, + lagrange::format( + "Grid image ({}x{}) is not divisible by layout ({}x{})", + grid_width, + grid_height, + rows, + cols)); + + const size_t cell_width = grid_width / cols; + const size_t cell_height = grid_height / rows; + + std::vector> views; + views.reserve(num_cells); + const dextents cell_shape{cell_width, cell_height, num_channels}; + const std::array cell_strides{grid.stride(0), grid.stride(1), grid.stride(2)}; + const layout_stride::mapping> cell_mapping{cell_shape, cell_strides}; + for (size_t row = 0; row < rows; ++row) { + for (size_t col = 0; col < cols; ++col) { + T* cell_ptr = &grid(col * cell_width, row * cell_height, 0); + views.emplace_back(cell_ptr, cell_mapping); + } + } + return views; +} + +/// @} + +} // namespace lagrange::image::experimental diff --git a/modules/image/python/src/image.cpp b/modules/image/python/src/image.cpp index 7fcfb925..6d462f31 100644 --- a/modules/image/python/src/image.cpp +++ b/modules/image/python/src/image.cpp @@ -12,12 +12,15 @@ #include #include +#include #include +#include #include namespace lagrange::python { namespace nb = nanobind; +using namespace nb::literals; void populate_image_module(nb::module_& m) { @@ -60,6 +63,58 @@ void populate_image_module(nb::module_& m) return span_to_tensor(s, nb::find(&img)); }, "Raw image data"); + + m.def( + "split_grid", + [](ImageTensor grid, size_t num_cells, size_t rows, size_t cols) { + image::experimental::SplitGridOptions options; + options.num_cells = num_cells; + options.rows = rows; + options.cols = cols; + const auto cells = image::experimental::split_grid(tensor_to_image_view(grid), options); + + nb::object owner = nb::cast(grid); + std::vector tensors; + tensors.reserve(cells.size()); + for (const auto& cell : cells) { + const size_t shape[3] = {cell.extent(1), cell.extent(0), cell.extent(2)}; + const int64_t strides[3] = { + static_cast(cell.stride(1)), + static_cast(cell.stride(0)), + static_cast(cell.stride(2)), + }; + nb::ndarray tensor( + cell.data_handle(), + 3, + shape, + owner, + strides); + tensors.emplace_back(nb::cast(tensor)); + } + return tensors; + }, + "grid"_a, + "num_cells"_a, + "rows"_a = 0, + "cols"_a = 0, + R"(Split a grid image into ``num_cells`` row-major sub-images. + +The grid is split into ``rows`` x ``cols`` cells. A value of zero on either dimension means +auto-detect: + +- ``rows=0, cols=0``: pick the factorization producing cells closest to square. +- ``rows=R, cols=0``: derive ``cols = num_cells / R``. +- ``rows=0, cols=C``: derive ``rows = num_cells / C``. +- ``rows=R, cols=C``: validate ``R * C == num_cells``. + +Returned views share memory with the input grid (no copy). + +:param grid: HxWxC grid image as a numpy array. +:param num_cells: Number of cells to split the grid into. +:param rows: Number of cell rows in the grid (0 = auto). +:param cols: Number of cell columns in the grid (0 = auto). + +:return: List of ``num_cells`` numpy views into the grid, in row-major order.)"); } } // namespace lagrange::python diff --git a/modules/image/python/tests/test_image.py b/modules/image/python/tests/test_image.py new file mode 100644 index 00000000..28152b5a --- /dev/null +++ b/modules/image/python/tests/test_image.py @@ -0,0 +1,88 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +import lagrange +import pytest + +import numpy as np + + +class TestImage: + def test_split_grid_auto(self): + # Grid shape is (H, W, C) = (256, 384, 3): 2 rows x 3 cols of 128x128 cells. + grid = np.zeros((256, 384, 3), dtype=np.float32) + for row in range(2): + for col in range(3): + grid[ + row * 128 : (row + 1) * 128, + col * 128 : (col + 1) * 128, + :, + ] = float(row * 3 + col + 1) + + cells = lagrange.image.split_grid(grid, 6) + assert len(cells) == 6 + for idx, cell in enumerate(cells): + assert cell.shape == (128, 128, 3) + assert np.all(cell == float(idx + 1)) + + def test_split_grid_explicit_layout(self): + # Force a 1x6 layout: 6 cells of (128, 64, 3). + grid = np.zeros((128, 384, 3), dtype=np.float32) + for col in range(6): + grid[:, col * 64 : (col + 1) * 64, :] = float(col + 1) + + cells = lagrange.image.split_grid(grid, 6, rows=1, cols=6) + assert len(cells) == 6 + for idx, cell in enumerate(cells): + assert cell.shape == (128, 64, 3) + assert np.all(cell == float(idx + 1)) + + def test_split_grid_partial_layout(self): + # Specify only rows: cols is derived as num_cells / rows. + grid = np.zeros((256, 256, 3), dtype=np.float32) + for row in range(2): + for col in range(2): + grid[ + row * 128 : (row + 1) * 128, + col * 128 : (col + 1) * 128, + :, + ] = float(row * 2 + col + 1) + + cells = lagrange.image.split_grid(grid, 4, rows=2) + assert len(cells) == 4 + for idx, cell in enumerate(cells): + assert cell.shape == (128, 128, 3) + assert np.all(cell == float(idx + 1)) + + def test_split_grid_view_writes_through(self): + # Mutating a returned cell view should mutate the source grid. + grid = np.zeros((128, 256, 3), dtype=np.float32) + cells = lagrange.image.split_grid(grid, 2) + cells[1][:] = 7.0 + assert np.all(grid[:, 128:, :] == 7.0) + assert np.all(grid[:, :128, :] == 0.0) + + def test_split_grid_zero_cells_raises(self): + grid = np.zeros((128, 128, 3), dtype=np.float32) + with pytest.raises(RuntimeError): + lagrange.image.split_grid(grid, 0) + + def test_split_grid_indivisible_raises(self): + # 100x100 grid cannot be evenly split into 3 cells. + grid = np.zeros((100, 100, 3), dtype=np.float32) + with pytest.raises(RuntimeError): + lagrange.image.split_grid(grid, 3) + + def test_split_grid_explicit_mismatch_raises(self): + # rows=2, cols=2 gives 4 cells but num_cells=3. + grid = np.zeros((128, 128, 3), dtype=np.float32) + with pytest.raises(RuntimeError): + lagrange.image.split_grid(grid, 3, rows=2, cols=2) diff --git a/modules/image_io/include/lagrange/image_io/common.h b/modules/image_io/include/lagrange/image_io/common.h index f1244e3a..7d41385f 100644 --- a/modules/image_io/include/lagrange/image_io/common.h +++ b/modules/image_io/include/lagrange/image_io/common.h @@ -49,6 +49,7 @@ inline FileType precision_to_file_type(image::ImagePrecision precision) { switch (precision) { case lagrange::image::ImagePrecision::uint8: return FileType::png; + case lagrange::image::ImagePrecision::uint16: return FileType::unknown; case lagrange::image::ImagePrecision::uint32: case lagrange::image::ImagePrecision::float32: return FileType::exr; case lagrange::image::ImagePrecision::int8: @@ -64,6 +65,7 @@ inline size_t size_of_precision(image::ImagePrecision precision) switch (precision) { case lagrange::image::ImagePrecision::uint8: case lagrange::image::ImagePrecision::int8: return 1; + case lagrange::image::ImagePrecision::uint16: return 2; case lagrange::image::ImagePrecision::uint32: case lagrange::image::ImagePrecision::int32: case lagrange::image::ImagePrecision::float32: return 4; diff --git a/modules/image_io/include/lagrange/image_io/load_image.h b/modules/image_io/include/lagrange/image_io/load_image.h index 296050c1..60a35190 100644 --- a/modules/image_io/include/lagrange/image_io/load_image.h +++ b/modules/image_io/include/lagrange/image_io/load_image.h @@ -85,13 +85,21 @@ bool load_image_as(const fs::path& path, image::ImageView& img) valid_conversion = img.convert_from(temp_image_view, 1); \ } #define LAGRANGE_TMP_COMMA , + // clang-format off LAGRANGE_TMP(uint8, one, unsigned char) - else LAGRANGE_TMP(uint8, three, Eigen::Matrix) else LAGRANGE_TMP( - uint8, - four, - Eigen:: - Matrix< - unsigned char LAGRANGE_TMP_COMMA 4 LAGRANGE_TMP_COMMA 1>) else LAGRANGE_TMP(float32, one, float) else LAGRANGE_TMP(float32, three, Eigen::Vector3f) else LAGRANGE_TMP(float32, four, Eigen::Vector4f) else LAGRANGE_TMP(float64, one, double) else LAGRANGE_TMP(float64, three, Eigen::Vector3d) else LAGRANGE_TMP(float64, four, Eigen::Vector4d) return valid_conversion; + else LAGRANGE_TMP(uint8, three, Eigen::Matrix) + else LAGRANGE_TMP(uint8, four, Eigen::Matrix) + else LAGRANGE_TMP(uint16, one, uint16_t) + else LAGRANGE_TMP(uint16, three, Eigen::Matrix) + else LAGRANGE_TMP(uint16, four, Eigen::Matrix) + else LAGRANGE_TMP(float32, one, float) + else LAGRANGE_TMP(float32, three, Eigen::Vector3f) + else LAGRANGE_TMP(float32, four, Eigen::Vector4f) + else LAGRANGE_TMP(float64, one, double) + else LAGRANGE_TMP(float64, three, Eigen::Vector3d) + else LAGRANGE_TMP(float64, four, Eigen::Vector4d) + // clang-format on + return valid_conversion; #undef LAGRANGE_TMP #undef LAGRANGE_TMP_COMMA } diff --git a/modules/image_io/src/load_image.cpp b/modules/image_io/src/load_image.cpp index 9e5048eb..9304f0ca 100644 --- a/modules/image_io/src/load_image.cpp +++ b/modules/image_io/src/load_image.cpp @@ -65,24 +65,51 @@ LoadImageResult load_image_stb(const fs::path& path, spdlog::level::level_enum e LA_IGNORE(error_lvl); LoadImageResult rtn; - rtn.precision = image::ImagePrecision::uint8; int w, h, ch; - unsigned char* data = stbi_load(path.string().c_str(), &w, &h, &ch, STBI_default); - if (data == nullptr) return rtn; + if (stbi_is_16_bit(path.string().c_str())) { + rtn.precision = image::ImagePrecision::uint16; + uint16_t* data = stbi_load_16(path.string().c_str(), &w, &h, &ch, STBI_default); + if (data == nullptr) return rtn; + if (ch != 1 && ch != 3 && ch != 4) { + logger().warn("load_image_stb: unsupported channel count {}: {}", ch, path.string()); + stbi_image_free(data); + return rtn; + } - size_t _w = static_cast(w); - size_t _h = static_cast(h); - size_t _ch = static_cast(ch); + size_t _w = static_cast(w); + size_t _h = static_cast(h); + size_t _ch = static_cast(ch); - rtn.valid = true; - rtn.width = _w; - rtn.height = _h; - rtn.channel = static_cast(ch); - rtn.storage = std::make_shared(_ch * _w, _h, 1); - std::copy_n(data, _ch * _w * _h, rtn.storage->data()); - stbi_image_free(data); - data = nullptr; + rtn.valid = true; + rtn.width = _w; + rtn.height = _h; + rtn.channel = static_cast(ch); + rtn.storage = std::make_shared(sizeof(uint16_t) * _ch * _w, _h, 1); + std::copy_n(data, _ch * _w * _h, reinterpret_cast(rtn.storage->data())); + stbi_image_free(data); + } else { + rtn.precision = image::ImagePrecision::uint8; + unsigned char* data = stbi_load(path.string().c_str(), &w, &h, &ch, STBI_default); + if (data == nullptr) return rtn; + if (ch != 1 && ch != 3 && ch != 4) { + logger().warn("load_image_stb: unsupported channel count {}: {}", ch, path.string()); + stbi_image_free(data); + return rtn; + } + + size_t _w = static_cast(w); + size_t _h = static_cast(h); + size_t _ch = static_cast(ch); + + rtn.valid = true; + rtn.width = _w; + rtn.height = _h; + rtn.channel = static_cast(ch); + rtn.storage = std::make_shared(_ch * _w, _h, 1); + std::copy_n(data, _ch * _w * _h, rtn.storage->data()); + stbi_image_free(data); + } return rtn; } diff --git a/modules/image_io/tests/test_image_io.cpp b/modules/image_io/tests/test_image_io.cpp index eda6d874..a9a8d892 100644 --- a/modules/image_io/tests/test_image_io.cpp +++ b/modules/image_io/tests/test_image_io.cpp @@ -102,6 +102,35 @@ TEST_CASE("load exr", "[image_io]") check_pixel(image.width - 1, image.height - 1, 1.f, 1.f, 0.f, 0.f); } +TEST_CASE("load 16bit png", "[image_io]") +{ + auto image = lagrange::image_io::load_image( + lagrange::testing::get_data_path("open/image_io/disparity16.png")); + REQUIRE(image.valid); + REQUIRE(image.width == 880); + REQUIRE(image.height == 576); + REQUIRE(image.channel == lagrange::image::ImageChannel::one); + REQUIRE(image.precision == lagrange::image::ImagePrecision::uint16); + + uint16_t* data = reinterpret_cast(image.storage->data()); + + // top row is black + for (size_t x = 0; x < image.width; ++x) { + REQUIRE(data[x] < 256); + } + // bottom row is white + for (size_t x = 0; x < image.width; ++x) { + REQUIRE(data[(image.height - 1) * image.width + x] > 64000); + } + + // load_image_as float conversion + lagrange::image::ImageView float_img; + REQUIRE( + lagrange::image_io::load_image_as( + lagrange::testing::get_data_path("open/image_io/disparity16.png"), + float_img)); +} + TEST_CASE("Exr IO", "[image_io]") { size_t width = 2, height = 2; diff --git a/modules/packing/CMakeLists.txt b/modules/packing/CMakeLists.txt index fd116c69..b015286a 100644 --- a/modules/packing/CMakeLists.txt +++ b/modules/packing/CMakeLists.txt @@ -24,3 +24,7 @@ target_link_libraries(lagrange_packing if(LAGRANGE_UNIT_TESTS) add_subdirectory(tests) endif() + +if(LAGRANGE_MODULE_PYTHON) + add_subdirectory(python) +endif() diff --git a/modules/packing/include/lagrange/packing/repack_uv_charts.h b/modules/packing/include/lagrange/packing/repack_uv_charts.h index b32df79b..02ff0424 100644 --- a/modules/packing/include/lagrange/packing/repack_uv_charts.h +++ b/modules/packing/include/lagrange/packing/repack_uv_charts.h @@ -35,10 +35,14 @@ struct RepackOptions bool allow_rotation = true; #endif - /// Should the output be normalized to fit into a unit box. + /// Whether the output should be normalized to fit into a unit box. When false, the + /// packed charts preserve their original scale but are still translated so the + /// minimum UV is at the origin. bool normalize = true; - /// Minimum allowed distance between two boxes normalized within [0, 1] domain. + /// Minimum allowed distance between two boxes. When @c normalize is true, this value + /// is measured in the normalized [0, 1] output domain. When @c normalize is false, + /// it is interpreted as an absolute distance in the original UV units. float margin = 1e-3f; }; diff --git a/modules/packing/python/CMakeLists.txt b/modules/packing/python/CMakeLists.txt new file mode 100644 index 00000000..884be284 --- /dev/null +++ b/modules/packing/python/CMakeLists.txt @@ -0,0 +1,12 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +lagrange_add_python_binding() diff --git a/modules/packing/python/include/lagrange/python/packing.h b/modules/packing/python/include/lagrange/python/packing.h new file mode 100644 index 00000000..bd807a9f --- /dev/null +++ b/modules/packing/python/include/lagrange/python/packing.h @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include + +namespace lagrange::python { +void populate_packing_module(nanobind::module_& m); +} diff --git a/modules/packing/python/src/packing.cpp b/modules/packing/python/src/packing.cpp new file mode 100644 index 00000000..b03ac00c --- /dev/null +++ b/modules/packing/python/src/packing.cpp @@ -0,0 +1,76 @@ +/* + * 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 + +namespace lagrange::python { + +namespace nb = nanobind; +using namespace nb::literals; + +void populate_packing_module(nb::module_& m) +{ + using Scalar = double; + using Index = uint32_t; + + using Options = lagrange::packing::RepackOptions; + + m.def( + "repack_uv_charts", + [](SurfaceMesh& mesh, + std::string_view uv_attribute_name, + std::string_view chart_attribute_name, +#ifndef RECTANGLE_BIN_PACK_OSS + bool allow_rotation, +#endif + bool normalize, + float margin) { + Options options; + options.uv_attribute_name = uv_attribute_name; + options.chart_attribute_name = chart_attribute_name; +#ifndef RECTANGLE_BIN_PACK_OSS + options.allow_rotation = allow_rotation; +#endif + options.normalize = normalize; + options.margin = margin; + lagrange::packing::repack_uv_charts(mesh, options); + }, + "mesh"_a, + nb::kw_only(), + "uv_attribute_name"_a = "", + "chart_attribute_name"_a = "", +#ifndef RECTANGLE_BIN_PACK_OSS + "allow_rotation"_a = Options().allow_rotation, +#endif + "normalize"_a = Options().normalize, + "margin"_a = Options().margin, + R"(Pack UV charts of a given mesh. + +The UV attribute is updated in place. + +:param mesh: The mesh with UV attribute. +:param uv_attribute_name: Name of the indexed attribute to use as UV coordinates. If empty, the first indexed UV attribute will be used. +:param chart_attribute_name: Name of the facet attribute that groups facets into UV charts. If empty, it will be computed based on UV chart connectivity. +)" +#ifndef RECTANGLE_BIN_PACK_OSS + R"(:param allow_rotation: Whether to allow boxes to rotate by 90 degrees when packing. +)" +#endif + R"(:param normalize: Whether the output should be normalized to fit into a unit box. When false, the packed charts preserve their original scale but are still translated so the minimum UV is at the origin. +:param margin: Minimum allowed distance between two boxes. When ``normalize`` is true, this value is measured in the normalized ``[0, 1]`` output domain. When ``normalize`` is false, it is interpreted as an absolute distance in the original UV units. + +:return: None. The UV attribute is modified in place.)"); +} + +} // namespace lagrange::python diff --git a/modules/packing/python/tests/test_repack_uv_charts.py b/modules/packing/python/tests/test_repack_uv_charts.py new file mode 100644 index 00000000..b5cae2a9 --- /dev/null +++ b/modules/packing/python/tests/test_repack_uv_charts.py @@ -0,0 +1,179 @@ +# +# 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 + + +@pytest.fixture +def make_mesh_with_indexed_uv(): + """Factory fixture to create a mesh with an indexed UV attribute.""" + + def _factory(vertices, facets, uv_values, uv_indices, name="uv"): + mesh = lagrange.SurfaceMesh() + mesh.add_vertices(np.array(vertices, dtype=np.float64)) + for f in facets: + mesh.add_triangle(*f) + mesh.create_attribute( + name, + element=lagrange.AttributeElement.Indexed, + usage=lagrange.AttributeUsage.UV, + initial_values=np.array(uv_values, dtype=np.float64), + initial_indices=np.array(uv_indices, dtype=np.uint32), + ) + return mesh + + return _factory + + +def _uv_bbox(mesh, name="uv"): + """Return (min_u, min_v, max_u, max_v) over referenced UV values.""" + attr = mesh.indexed_attribute(name) + values = np.asarray(attr.values.data) + indices = np.asarray(attr.indices.data).flatten() + used = values[indices] + return used[:, 0].min(), used[:, 1].min(), used[:, 0].max(), used[:, 1].max() + + +def _assert_no_overlap(mesh, name="uv"): + """Assert that the mesh's UV charts do not overlap.""" + result = lagrange.bvh.compute_uv_overlap(mesh, uv_attribute_name=name) + assert not result.has_overlap + + +class TestRepackUVCharts: + def test_single_triangle_normalized_to_unit_square(self, make_mesh_with_indexed_uv): + # UVs span [0, 2] x [0, 2] -> should be normalized into ~[0, 1]. + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0]], + facets=[[0, 1, 2]], + uv_values=[[0, 0], [2, 0], [0, 2]], + uv_indices=[[0, 1, 2]], + ) + lagrange.packing.repack_uv_charts(mesh) + + min_u, min_v, max_u, max_v = _uv_bbox(mesh) + assert abs(min_u) < 1e-6 + assert abs(min_v) < 1e-6 + assert 0.99 < max_u <= 1.0 - 1e-6 + assert 0.99 < max_v <= 1.0 - 1e-6 + + def test_two_disconnected_charts_fit_unit_square(self, make_mesh_with_indexed_uv): + mesh = make_mesh_with_indexed_uv( + vertices=[ + [0, 0, 0], + [1, 0, 0], + [0.5, 1, 0], + [2, 0, 0], + [3, 0, 0], + [2.5, 1, 0], + ], + facets=[[0, 1, 2], [3, 4, 5]], + uv_values=[[0, 0], [1, 0], [0.5, 1], [2, 0], [3, 0], [2.5, 1]], + uv_indices=[[0, 1, 2], [3, 4, 5]], + ) + lagrange.packing.repack_uv_charts(mesh) + + min_u, min_v, max_u, max_v = _uv_bbox(mesh) + assert abs(min_u) < 1e-6 + assert abs(min_v) < 1e-6 + assert max_u <= 1.0 - 1e-6 + assert max_v <= 1.0 - 1e-6 + + # Repacked charts should not overlap each other. + _assert_no_overlap(mesh) + + def test_with_chart_attribute_name(self, make_mesh_with_indexed_uv): + """Force two triangles into separate charts via a chart_id attribute.""" + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [1, 3, 2]], + uv_values=[[0, 0], [1, 0], [0, 1], [1, 1]], + uv_indices=[[0, 1, 2], [1, 3, 2]], + ) + mesh.create_attribute( + "@chart_id", + element=lagrange.AttributeElement.Facet, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.array([0, 1], dtype=np.uint32), + ) + lagrange.packing.repack_uv_charts(mesh, chart_attribute_name="@chart_id") + + min_u, min_v, max_u, max_v = _uv_bbox(mesh) + assert min_u >= -1e-6 + assert min_v >= -1e-6 + assert max_u <= 1.0 + 1e-6 + assert max_v <= 1.0 + 1e-6 + + # The two forced-apart charts should not overlap after repacking. + _assert_no_overlap(mesh) + + def test_named_uv_attribute(self, make_mesh_with_indexed_uv): + """Explicit uv_attribute_name argument should route to the right attribute.""" + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0]], + facets=[[0, 1, 2]], + uv_values=[[10, 10], [12, 10], [10, 12]], + uv_indices=[[0, 1, 2]], + name="my_uv", + ) + lagrange.packing.repack_uv_charts(mesh, uv_attribute_name="my_uv") + + min_u, min_v, max_u, max_v = _uv_bbox(mesh, name="my_uv") + assert abs(min_u) < 1e-6 + assert abs(min_v) < 1e-6 + assert max_u <= 1.0 + 1e-6 + assert max_v <= 1.0 + 1e-6 + + def test_normalize_false_preserves_scale(self, make_mesh_with_indexed_uv): + """With normalize=False, output should preserve the original chart scale.""" + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0]], + facets=[[0, 1, 2]], + uv_values=[[0, 0], [5, 0], [0, 5]], + uv_indices=[[0, 1, 2]], + ) + lagrange.packing.repack_uv_charts(mesh, normalize=False) + + min_u, min_v, max_u, max_v = _uv_bbox(mesh) + # Min is still shifted to the origin, and the 5x5 chart extent is preserved. + assert np.isclose(min_u, 0.0, atol=1e-6) + assert np.isclose(min_v, 0.0, atol=1e-6) + assert np.isclose(max_u - min_u, 5.0, atol=1e-6) + assert np.isclose(max_v - min_v, 5.0, atol=1e-6) + + def test_margin_shrinks_packed_region(self, make_mesh_with_indexed_uv): + """Larger margin should leave more whitespace around the packed chart.""" + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0]], + facets=[[0, 1, 2]], + uv_values=[[0, 0], [1, 0], [0, 1]], + uv_indices=[[0, 1, 2]], + ) + lagrange.packing.repack_uv_charts(mesh, margin=0.1) + + _, _, max_u, max_v = _uv_bbox(mesh) + assert max_u <= 1.0 + 1e-6 + assert max_v <= 1.0 + 1e-6 + # A 0.1 margin should visibly shrink the chart. + assert max_u < 0.95 or max_v < 0.95 + + def test_arguments_after_mesh_are_keyword_only(self, make_mesh_with_indexed_uv): + """All arguments after ``mesh`` must be passed as keyword arguments.""" + mesh = make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0]], + facets=[[0, 1, 2]], + uv_values=[[0, 0], [1, 0], [0, 1]], + uv_indices=[[0, 1, 2]], + ) + with pytest.raises(TypeError): + lagrange.packing.repack_uv_charts(mesh, "uv") diff --git a/modules/packing/src/pack_boxes.h b/modules/packing/src/pack_boxes.h index 4246f0d1..81d98c03 100644 --- a/modules/packing/src/pack_boxes.h +++ b/modules/packing/src/pack_boxes.h @@ -129,8 +129,11 @@ pack_boxes( boxes.emplace_back(); auto& box = boxes.back(); if (bbox_maxs(i, 0) < bbox_mins(i, 0) || bbox_maxs(i, 1) < bbox_mins(i, 1)) { - // Invalid bounding box. - logger().warn("Skipping invalid bounding box (index {})!", i); + // Degenerate bounding box (max < min in at least one dimension). Most often this + // means the caller reserved a slot for a chart id that has no geometry + logger().warn( + "Skipping degenerate bounding box at index {} (chart id with no geometry)", + i); box.width = 0; box.height = 0; } else { diff --git a/modules/packing/src/repack_uv_charts.cpp b/modules/packing/src/repack_uv_charts.cpp index e188345d..a37d9b0b 100644 --- a/modules/packing/src/repack_uv_charts.cpp +++ b/modules/packing/src/repack_uv_charts.cpp @@ -91,7 +91,11 @@ void repack_uv_charts_impl( } const auto all_bbox_min = uv_values.colwise().minCoeff().eval(); - uv_values = (uv_values.rowwise() - all_bbox_min) / canvas_size; + if (options.normalize) { + uv_values = (uv_values.rowwise() - all_bbox_min) / canvas_size; + } else { + uv_values = uv_values.rowwise() - all_bbox_min; + } } } // namespace diff --git a/modules/packing/tests/test_repack_uv_charts.cpp b/modules/packing/tests/test_repack_uv_charts.cpp index 879625cd..5c132a53 100644 --- a/modules/packing/tests/test_repack_uv_charts.cpp +++ b/modules/packing/tests/test_repack_uv_charts.cpp @@ -211,6 +211,55 @@ TEST_CASE("repack_uv_charts: multiple charts", "[packing][repack]") } } +TEST_CASE("repack_uv_charts: normalize option", "[packing][repack]") +{ + SECTION("normalize=false preserves original chart scale") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_triangle(0, 1, 2); + + // Chart bbox is 5x5; with normalize=false it must remain that size. + add_indexed_uv(mesh, {0, 0, 5, 0, 0, 5}, {0, 1, 2}); + + packing::RepackOptions opts; + opts.normalize = false; + packing::repack_uv_charts(mesh, opts); + + auto bbox = get_uv_bbox(mesh); + // Min still shifted to origin even when normalization is disabled. + CHECK_THAT(bbox[0], Catch::Matchers::WithinAbs(0.0, 1e-6)); + CHECK_THAT(bbox[1], Catch::Matchers::WithinAbs(0.0, 1e-6)); + CHECK_THAT(bbox[2] - bbox[0], Catch::Matchers::WithinAbs(5.0, 1e-6)); + CHECK_THAT(bbox[3] - bbox[1], Catch::Matchers::WithinAbs(5.0, 1e-6)); + } + + SECTION("normalize=true (default) rescales to unit box") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_triangle(0, 1, 2); + + add_indexed_uv(mesh, {0, 0, 5, 0, 0, 5}, {0, 1, 2}); + + packing::RepackOptions opts; + opts.normalize = true; + packing::repack_uv_charts(mesh, opts); + + auto bbox = get_uv_bbox(mesh); + CHECK_THAT(bbox[0], Catch::Matchers::WithinAbs(0.0, 1e-6)); + CHECK_THAT(bbox[1], Catch::Matchers::WithinAbs(0.0, 1e-6)); + CHECK(bbox[2] <= 1.0 + 1e-6); + CHECK(bbox[3] <= 1.0 + 1e-6); + CHECK(bbox[2] > 0.99); + CHECK(bbox[3] > 0.99); + } +} + TEST_CASE("repack_uv_charts: different UV scalar type", "[packing][repack]") { using UVScalar = float; diff --git a/modules/polyddg/include/lagrange/polyddg/compute_smooth_direction_field.h b/modules/polyddg/include/lagrange/polyddg/compute_smooth_direction_field.h index b53b3a65..b2254316 100644 --- a/modules/polyddg/include/lagrange/polyddg/compute_smooth_direction_field.h +++ b/modules/polyddg/include/lagrange/polyddg/compute_smooth_direction_field.h @@ -16,6 +16,7 @@ #include #include +#include #include namespace lagrange::polyddg { @@ -32,43 +33,56 @@ struct SmoothDirectionFieldOptions /// 4 = cross field). uint8_t nrosy = 4; - /// Stabilization weight for the VEM projection term in the connection Laplacian. + /// Controls where the output direction field is stored. + /// + /// - @c AttributeElement::Vertex (default): stores a per-vertex 3-D tangent vector + /// attribute using the vertex-based connection Laplacian (Knöppel et al. 2013). + /// - @c AttributeElement::Facet: stores a per-facet 3-D tangent vector attribute using + /// the face-based connection Laplacian. + AttributeElement output_element_type = AttributeElement::Vertex; + + /// Stabilization weight for the VEM projection term in the connection Laplacian + /// (vertex-based path only). double lambda = 1.0; - /// Name of a per-vertex 3-D tangent vector field attribute used as alignment constraints. - /// Each vertex with a non-zero vector is softly constrained to align to that direction. - /// Vertices with a zero vector are unconstrained. If empty (the default), no alignment - /// constraints are applied and the globally smoothest field is computed via inverse power - /// iteration. + /// Name of an alignment constraint attribute used as soft constraints. + /// Each non-zero entry is softly constrained to align to that direction; zero entries are + /// unconstrained. If empty (the default), no constraints are applied and the globally + /// smoothest field is computed. + /// + /// Must match the element type selected by @c output_element_type: + /// - @c AttributeElement::Vertex: a per-vertex 3-D tangent vector attribute + /// (AttributeElement::Vertex, AttributeUsage::Vector, 3 channels). + /// - @c AttributeElement::Facet: a per-facet 3-D tangent vector attribute + /// (AttributeElement::Facet, AttributeUsage::Vector, 3 channels). std::string_view alignment_attribute = ""; - /// Scaling factor for the spectral shift in the alignment solve, following the fieldgen - /// formulation (Knöppel et al. 2013). The actual shift is @f$ \alpha = s \cdot - /// \sigma_{\min} @f$, where @f$ s @f$ is this value and @f$ \sigma_{\min} @f$ is the - /// smallest eigenvalue of the connection Laplacian (computed automatically). At the - /// default value of 1.0, the shift equals @f$ \sigma_{\min} @f$, giving maximum - /// alignment. Values in (0, 1) give weaker alignment (more smoothness). - double alignment_weight = 1.0; - - /// Output attribute name for the smooth direction field (3-D vector, per vertex). - std::string_view direction_field_attribute = "@smooth_direction_field"; + /// Output attribute name for the smooth direction field. If not set (std::nullopt, the + /// default), the canonical name depends on @c output_element_type: + /// + /// - @c AttributeElement::Vertex: @c \@smooth_direction_field + /// - @c AttributeElement::Facet: @c \@smooth_direction_field_facets + std::optional direction_field_attribute; }; /// /// Compute the globally smoothest n-direction field on a surface mesh. /// -/// This function is based on the following paper: -/// -/// Knöppel, Felix, et al. "Globally optimal direction fields." ACM Transactions on Graphics (ToG) -/// 32.4 (2013): 1-10. +/// Dispatches to a vertex-based or facet-based implementation depending on +/// @c options.output_element_type: /// -/// The solution is stored as a per-vertex 3-D tangent vector attribute in world-space -/// coordinates, obtained by mapping the local 2-D solution through the vertex tangent basis. +/// - @c AttributeElement::Vertex (default): solves the vertex-based connection Laplacian +/// (Knöppel et al., "Globally optimal direction fields", ACM ToG 32(4), 2013). The result +/// is stored as a per-vertex 3-D tangent vector attribute. +/// - @c AttributeElement::Facet: solves the face-based connection Laplacian, minimizing +/// @f$ E(u) = \sum_{e=(f,g)} w_e \| R_{f \to g}^n u_f - u_g \|^2 @f$. The result is +/// stored as a per-facet 3-D tangent vector attribute. /// /// @param[in,out] mesh Input surface mesh. The output attribute is added or overwritten. /// @param[in] ops Precomputed differential operators for the mesh. -/// @param[in] options Options controlling the rosy order, stabilization weight, -/// optional alignment constraints, and output attribute name. +/// @param[in] options Options controlling the rosy order, output element type, +/// stabilization weight, optional alignment constraints, and output +/// attribute name. /// /// @return Attribute ID of the output direction field attribute. /// @@ -78,6 +92,14 @@ LA_POLYDDG_API AttributeId compute_smooth_direction_field( const DifferentialOperators& ops, SmoothDirectionFieldOptions options = {}); +/// +/// Convenience overload that constructs a DifferentialOperators object internally. +/// +template +LA_POLYDDG_API AttributeId compute_smooth_direction_field( + SurfaceMesh& mesh, + SmoothDirectionFieldOptions options = {}); + /// @} } // namespace lagrange::polyddg diff --git a/modules/polyddg/python/examples/hodge_decomposition.py b/modules/polyddg/python/examples/hodge_decomposition.py index e9f6ddce..dbc072f2 100644 --- a/modules/polyddg/python/examples/hodge_decomposition.py +++ b/modules/polyddg/python/examples/hodge_decomposition.py @@ -4,7 +4,7 @@ # 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 https://www.apache.org/licenses/LICENSE-2.0 +# 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 diff --git a/modules/polyddg/python/src/polyddg.cpp b/modules/polyddg/python/src/polyddg.cpp index a7d7c85f..28c16ab4 100644 --- a/modules/polyddg/python/src/polyddg.cpp +++ b/modules/polyddg/python/src/polyddg.cpp @@ -780,22 +780,22 @@ are the principal directions. All four quantities are stored as vertex attribute // ---- compute_smooth_direction_field ---- const polyddg::SmoothDirectionFieldOptions default_sdf_opts{}; - m.def( "compute_smooth_direction_field", [](SurfaceMesh& mesh, const polyddg::DifferentialOperators& ops, uint8_t nrosy, double beta, + lagrange::AttributeElement output_element_type, std::string_view alignment_attribute, - double alignment_weight, std::string_view direction_field_attribute) { polyddg::SmoothDirectionFieldOptions opts; opts.nrosy = nrosy; opts.lambda = beta; + opts.output_element_type = output_element_type; opts.alignment_attribute = alignment_attribute; - opts.alignment_weight = alignment_weight; - opts.direction_field_attribute = direction_field_attribute; + if (!direction_field_attribute.empty()) + opts.direction_field_attribute = direction_field_attribute; return polyddg::compute_smooth_direction_field(mesh, ops, opts); }, "mesh"_a, @@ -803,38 +803,94 @@ are the principal directions. All four quantities are stored as vertex attribute nb::kw_only(), "nrosy"_a = default_sdf_opts.nrosy, "beta"_a = default_sdf_opts.lambda, + "output_element_type"_a = default_sdf_opts.output_element_type, "alignment_attribute"_a = default_sdf_opts.alignment_attribute, - "alignment_weight"_a = default_sdf_opts.alignment_weight, - "direction_field_attribute"_a = default_sdf_opts.direction_field_attribute, + "direction_field_attribute"_a = "", R"(Compute the globally smoothest n-direction field on a surface mesh. -Based on: Knöppel et al., "Globally optimal direction fields", ACM ToG 32(4), 2013. +Dispatches to a vertex-based or facet-based implementation depending on +``output_element_type``: + +- ``AttributeElement.Vertex`` (default): solves the vertex-based connection Laplacian + (Knöppel et al., "Globally optimal direction fields", ACM ToG 32(4), 2013). The result + is stored as a per-vertex 3-D tangent vector attribute (default name + ``"@smooth_direction_field"``). +- ``AttributeElement.Facet``: solves the face-based connection Laplacian, minimizing + the face-based connection-Laplacian energy. The result is stored as a per-facet 3-D + tangent vector attribute (default name ``"@smooth_direction_field_facets"``). Without alignment constraints (``alignment_attribute`` is empty), solves the generalized -eigenvalue problem :math:`L u = \sigma M u` for the smallest eigenvector. The result -minimizes the Dirichlet energy of the connection. +eigenvalue problem for the smallest eigenvector. -With alignment constraints, reads per-vertex prescribed 3-D tangent vectors from the given -attribute (zero-length vectors are unconstrained) and solves the shifted linear system -:math:`(L - \alpha M) u = M q`, where :math:`q` is the M-normalized prescribed field and -:math:`\alpha = \texttt{alignment\_lambda} \cdot \sigma_{\min}`. +With alignment constraints, reads prescribed 3-D tangent vectors from the given attribute +(zero-length vectors are unconstrained). The attribute must match ``output_element_type``: +per-vertex for ``Vertex``, per-facet for ``Facet``. :param mesh: Input surface mesh (modified in place with the new attribute). :param ops: Precomputed :class:`DifferentialOperators` for the mesh. :param nrosy: Symmetry order of the direction field (1 = vector field, 2 = line field, 4 = cross field, default: 4). :param beta: Stabilization weight for the VEM projection term in the connection Laplacian - (default: 1). -:param alignment_attribute: Name of a per-vertex 3-D alignment vector attribute (zero = - unconstrained). If empty, the unconstrained smoothest field is computed. -:param alignment_weight: Scaling factor for the spectral shift (default: 1). The actual - shift is ``alignment_weight * sigma_min``, where ``sigma_min`` is the smallest eigenvalue - of the connection Laplacian (computed automatically). Values in (0, 1) give weaker - alignment (more smoothness). -:param direction_field_attribute: Output attribute name for the per-vertex 3-D direction - field (default: ``"@smooth_direction_field"``). - -:return: Attribute ID of the output per-vertex direction field.)"); + (vertex-based path only, default: 1). +:param output_element_type: Where to store the result — ``AttributeElement.Vertex`` + (default) for per-vertex output, or ``AttributeElement.Facet`` for per-facet output. +:param alignment_attribute: Name of an alignment vector attribute (zero = unconstrained). + Must match ``output_element_type``. If empty, the unconstrained smoothest field is computed. +:param direction_field_attribute: Output attribute name. Pass ``""`` (the default) to use + the canonical name (``"@smooth_direction_field"`` for Vertex, + ``"@smooth_direction_field_facets"`` for Facet). + +:return: Attribute ID of the output direction field.)"); + + m.def( + "compute_smooth_direction_field", + [](SurfaceMesh& mesh, + uint8_t nrosy, + double beta, + lagrange::AttributeElement output_element_type, + std::string_view alignment_attribute, + std::string_view direction_field_attribute) { + polyddg::SmoothDirectionFieldOptions opts; + opts.nrosy = nrosy; + opts.lambda = beta; + opts.output_element_type = output_element_type; + opts.alignment_attribute = alignment_attribute; + if (!direction_field_attribute.empty()) + opts.direction_field_attribute = direction_field_attribute; + return polyddg::compute_smooth_direction_field(mesh, opts); + }, + "mesh"_a, + nb::kw_only(), + "nrosy"_a = default_sdf_opts.nrosy, + "beta"_a = default_sdf_opts.lambda, + "output_element_type"_a = default_sdf_opts.output_element_type, + "alignment_attribute"_a = default_sdf_opts.alignment_attribute, + "direction_field_attribute"_a = "", + R"(Compute the globally smoothest n-direction field on a surface mesh. + +Convenience overload that constructs a :class:`DifferentialOperators` instance internally. + +Dispatches to a vertex-based or facet-based implementation depending on +``output_element_type``: + +- ``AttributeElement.Vertex`` (default): stores a per-vertex 3-D tangent vector attribute + (default name ``"@smooth_direction_field"``). +- ``AttributeElement.Facet``: stores a per-facet 3-D tangent vector attribute (default name + ``"@smooth_direction_field_facets"``). + +:param mesh: Input surface mesh (modified in place with the new attribute). +:param nrosy: Symmetry order of the direction field (1 = vector field, 2 = line field, + 4 = cross field, default: 4). +:param beta: Stabilization weight for the VEM projection term in the connection Laplacian + (vertex-based path only, default: 1). +:param output_element_type: Where to store the result — ``AttributeElement.Vertex`` + (default) for per-vertex output, or ``AttributeElement.Facet`` for per-facet output. +:param alignment_attribute: Name of an alignment vector attribute (zero = unconstrained). + Must match ``output_element_type``. If empty, the unconstrained smoothest field is computed. +:param direction_field_attribute: Output attribute name. Pass ``""`` (the default) to use + the canonical name. + +:return: Attribute ID of the output direction field.)"); // ---- hodge_decomposition_1_form ---- constexpr polyddg::HodgeDecompositionOptions default_hd_1form_opts{}; diff --git a/modules/polyddg/python/tests/test_polyddg.py b/modules/polyddg/python/tests/test_polyddg.py index 4d7d7cfd..c7ea3e97 100644 --- a/modules/polyddg/python/tests/test_polyddg.py +++ b/modules/polyddg/python/tests/test_polyddg.py @@ -302,3 +302,81 @@ def test_hodge_decomposition_1_form_harmonic_vanishes_genus_0(self, octahedron): omega_harmonic = np.array(octahedron.attribute("@test_1form_harmonic_g0").data) assert np.linalg.norm(omega_harmonic) < 1e-10 + + +class TestSmoothDirectionField: + """Tests for compute_smooth_direction_field (vertex and facet variants).""" + + def test_vertex_output_default(self, octahedron): + """Default (Vertex) path: output is per-vertex unit tangent vectors.""" + ops = lagrange.polyddg.DifferentialOperators(octahedron) + attr_id = lagrange.polyddg.compute_smooth_direction_field(octahedron, ops) + + assert octahedron.has_attribute("@smooth_direction_field") + data = np.array(octahedron.attribute(attr_id).data).reshape(-1, 3) + assert data.shape == (octahedron.num_vertices, 3) + norms = np.linalg.norm(data, axis=1) + assert np.allclose(norms, 1.0, atol=1e-10) + + def test_facet_output_element_type(self, octahedron): + """Facet path: output is per-facet unit tangent vectors.""" + ops = lagrange.polyddg.DifferentialOperators(octahedron) + attr_id = lagrange.polyddg.compute_smooth_direction_field( + octahedron, + ops, + output_element_type=lagrange.AttributeElement.Facet, + ) + + assert octahedron.has_attribute("@smooth_direction_field_facets") + data = np.array(octahedron.attribute(attr_id).data).reshape(-1, 3) + assert data.shape == (octahedron.num_facets, 3) + norms = np.linalg.norm(data, axis=1) + assert np.allclose(norms, 1.0, atol=1e-10) + + def test_no_ops_overload_vertex(self, octahedron): + """Convenience overload (no ops arg) produces same result as explicit ops.""" + ops = lagrange.polyddg.DifferentialOperators(octahedron) + + attr_id_with_ops = lagrange.polyddg.compute_smooth_direction_field( + octahedron, + ops, + direction_field_attribute="@sdf_with_ops", + ) + data_with_ops = np.array(octahedron.attribute(attr_id_with_ops).data).reshape(-1, 3) + + attr_id_no_ops = lagrange.polyddg.compute_smooth_direction_field( + octahedron, + direction_field_attribute="@sdf_no_ops", + ) + data_no_ops = np.array(octahedron.attribute(attr_id_no_ops).data).reshape(-1, 3) + + assert data_with_ops.shape == data_no_ops.shape + norms = np.linalg.norm(data_no_ops, axis=1) + assert np.allclose(norms, 1.0, atol=1e-10) + + def test_no_ops_overload_facet(self, octahedron): + """Convenience overload with output_element_type=Facet produces unit facet vectors.""" + attr_id = lagrange.polyddg.compute_smooth_direction_field( + octahedron, + output_element_type=lagrange.AttributeElement.Facet, + ) + + data = np.array(octahedron.attribute(attr_id).data).reshape(-1, 3) + assert data.shape == (octahedron.num_facets, 3) + norms = np.linalg.norm(data, axis=1) + assert np.allclose(norms, 1.0, atol=1e-10) + + def test_determinism(self, octahedron): + """Two calls with same mesh produce identical results.""" + ops = lagrange.polyddg.DifferentialOperators(octahedron) + + id1 = lagrange.polyddg.compute_smooth_direction_field( + octahedron, ops, direction_field_attribute="@sdf_det1" + ) + id2 = lagrange.polyddg.compute_smooth_direction_field( + octahedron, ops, direction_field_attribute="@sdf_det2" + ) + d1 = np.array(octahedron.attribute(id1).data) + d2 = np.array(octahedron.attribute(id2).data) + # Fields may differ by global sign flip; compare abs dot products. + assert np.allclose(np.abs(d1), np.abs(d2), atol=1e-10) or np.allclose(d1, -d2, atol=1e-10) diff --git a/modules/polyddg/src/compute_smooth_direction_field.cpp b/modules/polyddg/src/compute_smooth_direction_field.cpp index adde54fe..da36c33e 100644 --- a/modules/polyddg/src/compute_smooth_direction_field.cpp +++ b/modules/polyddg/src/compute_smooth_direction_field.cpp @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include @@ -29,6 +31,287 @@ namespace lagrange::polyddg { using solver::SolverLDLT; +// ============================================================================= +// solve_connection_laplacian — shared solve kernel for both vertex and facet paths +// ============================================================================= + +// Solves for the smoothest (or alignment-constrained) n-rosy field given a +// regularized Laplacian L_reg = L + εM and mass matrix M. +// +// When has_constraints is true, solves (L_reg) x = M q and normalizes in M-norm. +// When has_constraints is false, finds the smallest generalized eigenvector of +// L_reg x = σ M x via Spectra, falling back to inverse power iteration. +// +// fn_name is used only in runtime-assert/warning messages. +template +static void solve_connection_laplacian( + const Eigen::SparseMatrix& L_reg, + const Eigen::SparseMatrix& M, + const Eigen::Matrix& q, + bool has_constraints, + Eigen::Index size, + std::string_view fn_name, + Eigen::Matrix& x) +{ + if (has_constraints) { + SolverLDLT> slv(L_reg); + la_runtime_assert( + slv.info() == Eigen::Success, + lagrange::format("{}: factorization of L + eps*M failed", fn_name)); + + x = slv.solve((M * q).eval()); + la_runtime_assert( + slv.info() == Eigen::Success, + lagrange::format("{}: constrained solve failed", fn_name)); + + const Eigen::Matrix Mx = M * x; + const Scalar x_norm_M = std::sqrt(x.dot(Mx)); + la_runtime_assert(x_norm_M > Scalar(0), lagrange::format("{}: solution is zero", fn_name)); + x /= x_norm_M; + } else { + bool solved = false; + { + auto result = solver::generalized_selfadjoint_eigen_smallest(L_reg, M, 1); + if (result.is_successful() && result.num_converged >= 1) { + x = result.eigenvectors.col(0); + solved = true; + } else { + logger().warn( + "{}: Spectra eigen solver did not converge, falling back to inverse power " + "iteration.", + fn_name); + } + } + + if (!solved) { + SolverLDLT> slv(L_reg); + la_runtime_assert( + slv.info() == Eigen::Success, + lagrange::format("{}: Cholesky factorization of L + eps*M failed", fn_name)); + + x = Eigen::Matrix::Ones(size); + x.normalize(); + constexpr int max_iter = 20; + for (int iter = 0; iter < max_iter; ++iter) { + x = M * x; + x = slv.solve(x); + x.normalize(); + } + } + } +} + +// ============================================================================= +// compute_smooth_direction_field_on_facets (face-based connection Laplacian) +// ============================================================================= + +template +AttributeId compute_smooth_direction_field_on_facets( + SurfaceMesh& mesh, + const DifferentialOperators& ops, + SmoothDirectionFieldOptions options) +{ + la_runtime_assert( + options.nrosy >= 1, + "compute_smooth_direction_field_on_facets: nrosy must be >= 1."); + + const Index num_facets = mesh.get_num_facets(); + const Index num_edges = mesh.get_num_edges(); + const Index n = static_cast(options.nrosy); + const int n_int = static_cast(options.nrosy); + + // ---- 1. Edge-to-face mapping ---- + // For each edge store the local vertex index of edge.v0 in each adjacent face. + // edge_faces[eid][0/1] = {fid, lv_v0} where lv_v0 is the local index of the canonical + // edge endpoint v0 inside face fid. Slot fid == kInvalid means no face assigned yet. + const Index kInvalid = invalid(); + struct FaceSlot + { + Index fid; + Index lv_v0; + }; + std::vector> edge_faces( + num_edges, + {FaceSlot{kInvalid, 0}, FaceSlot{kInvalid, 0}}); + + for (Index fid = 0; fid < num_facets; ++fid) { + const Index ns = mesh.get_facet_size(fid); + for (Index lv = 0; lv < ns; ++lv) { + const Index eid = mesh.get_edge(fid, lv); + auto [v0, v1] = mesh.get_edge_vertices(eid); + const Index vid_at_lv = mesh.get_facet_vertex(fid, lv); + // Edge lv runs from vid_at_lv → next vertex; find which is v0. + const Index lv_v0 = (v0 == vid_at_lv) ? lv : (lv + 1) % ns; + + auto& slot = edge_faces[eid]; + if (slot[0].fid == kInvalid) { + slot[0] = {fid, lv_v0}; + } else { + la_runtime_assert( + slot[1].fid == kInvalid, + "compute_smooth_direction_field_on_facets: non-manifold edge detected (more " + "than 2 incident faces). Only edge-manifold meshes are supported."); + slot[1] = {fid, lv_v0}; + } + } + } + + // ---- 2. Face-based n-fold connection Laplacian L_n (2F × 2F) ---- + // For interior edge e = (f0, f1): + // R_{f0→f1}^n = levi_civita_nrosy(f1, lv0_in_f1, n) · levi_civita_nrosy(f0, lv0_in_f0, + // n)^T + // + // Energy contribution: w_e · ||R_{f0→f1}^n u_{f0} − u_{f1}||² + // gives diagonal blocks +w_e I₂ and off-diagonal blocks ∓w_e R_{f0→f1}^{n,T} / R_{f0→f1}^n. + std::vector> L_triplets; + L_triplets.reserve(8 * num_edges + 4 * num_facets); + + for (Index eid = 0; eid < num_edges; ++eid) { + const auto& s0 = edge_faces[eid][0]; + const auto& s1 = edge_faces[eid][1]; + if (s0.fid == kInvalid || s1.fid == kInvalid) continue; // boundary edge + + const Index f0 = s0.fid, lv0_in_f0 = s0.lv_v0; + const Index f1 = s1.fid, lv0_in_f1 = s1.lv_v0; + + // n-fold Levi-Civita transport: f0's frame → f1's frame, via shared vertex v0. + // Eager-evaluate to a concrete matrix: levi_civita_nrosy returns by value, so the + // product expression would otherwise hold references to destroyed temporaries. + const Eigen::Matrix R01 = + ops.levi_civita_nrosy(f1, lv0_in_f1, n) * + ops.levi_civita_nrosy(f0, lv0_in_f0, n).transpose(); + + // Weight = primal edge length. + auto [v0, v1] = mesh.get_edge_vertices(eid); + auto p0 = mesh.get_position(v0); + auto p1 = mesh.get_position(v1); + const Scalar w_e = std::sqrt( + (p1[0] - p0[0]) * (p1[0] - p0[0]) + (p1[1] - p0[1]) * (p1[1] - p0[1]) + + (p1[2] - p0[2]) * (p1[2] - p0[2])); + + const Eigen::Index ef0 = static_cast(f0); + const Eigen::Index ef1 = static_cast(f1); + + // Diagonal blocks: +w_e I₂ + for (Eigen::Index d = 0; d < 2; ++d) { + L_triplets.emplace_back(2 * ef0 + d, 2 * ef0 + d, w_e); + L_triplets.emplace_back(2 * ef1 + d, 2 * ef1 + d, w_e); + } + + // Off-diagonal blocks: + // L[f0, f1] -= w_e · R01^T (R01^T[i,j] = R01[j,i]) + // L[f1, f0] -= w_e · R01 + for (Eigen::Index i = 0; i < 2; ++i) { + for (Eigen::Index j = 0; j < 2; ++j) { + L_triplets.emplace_back(2 * ef0 + i, 2 * ef1 + j, -w_e * R01(j, i)); // R01^T + L_triplets.emplace_back(2 * ef1 + i, 2 * ef0 + j, -w_e * R01(i, j)); + } + } + } + + Eigen::SparseMatrix Ln( + static_cast(2 * num_facets), + static_cast(2 * num_facets)); + Ln.setFromTriplets(L_triplets.begin(), L_triplets.end()); + + // ---- 3. Mass matrix M (2F × 2F, face areas on diagonal) ---- + auto M2 = ops.star2(); + std::vector> M_triplets; + M_triplets.reserve(2 * num_facets); + for (Eigen::Index fid = 0; fid < static_cast(num_facets); ++fid) { + const Scalar a = M2.coeff(fid, fid); + M_triplets.emplace_back(2 * fid, 2 * fid, a); + M_triplets.emplace_back(2 * fid + 1, 2 * fid + 1, a); + } + Eigen::SparseMatrix M( + static_cast(2 * num_facets), + static_cast(2 * num_facets)); + M.setFromTriplets(M_triplets.begin(), M_triplets.end()); + + constexpr Scalar eps = Scalar(1e-8); + Eigen::SparseMatrix L_reg = Ln + eps * M; + + Eigen::Matrix x(static_cast(2 * num_facets)); + bool has_constraints = !options.alignment_attribute.empty(); + Eigen::Matrix q; + + if (has_constraints) { + // --- Build alignment rhs q --- + // Reads per-facet prescribed 3-D tangent vectors (zero = unconstrained), + // encodes them in n-fold space for the rhs M q. + const auto alignment_id = internal::find_attribute( + mesh, + options.alignment_attribute, + AttributeElement::Facet, + AttributeUsage::Vector, + 3); + la_runtime_assert( + alignment_id != invalid_attribute_id(), + "compute_smooth_direction_field_on_facets: alignment attribute not found or does " + "not " + "match expected properties (must be a Vector Facet attribute with 3 channels)."); + + auto align_data = attribute_matrix_view(mesh, alignment_id); + + q.resize(static_cast(2 * num_facets)); + q.setZero(); + + for (Index fid = 0; fid < num_facets; ++fid) { + Eigen::Matrix v3 = align_data.row(fid).transpose(); + if (v3.squaredNorm() < Scalar(1e-20)) continue; + + Eigen::Matrix B = ops.facet_basis(fid); + Eigen::Matrix v2 = (B.transpose() * v3).stableNormalized(); + q.template segment<2>(static_cast(2 * fid)) = nrosy_encode(v2, n_int); + } + + if (q.squaredNorm() == Scalar(0)) { + logger().warn( + "compute_smooth_direction_field_on_facets: all alignment vectors are zero or " + "near-zero; falling back to unconstrained solve."); + has_constraints = false; + } + } + + solve_connection_laplacian( + L_reg, + M, + q, + has_constraints, + static_cast(2 * num_facets), + "compute_smooth_direction_field_on_facets", + x); + + // ---- 4. Decode and store as per-facet 3-D tangent vector ---- + const std::string_view out_name = + (options.direction_field_attribute && !options.direction_field_attribute->empty()) + ? *options.direction_field_attribute + : "@smooth_direction_field_facets"; + const auto attr_id = internal::find_or_create_attribute( + mesh, + out_name, + AttributeElement::Facet, + AttributeUsage::Vector, + 3, + internal::ResetToDefault::No); + + auto out_data = attribute_matrix_ref(mesh, attr_id); + + for (Index fid = 0; fid < num_facets; ++fid) { + Eigen::Matrix u2 = x.template segment<2>(static_cast(2 * fid)); + if (n > 1) u2 = nrosy_decode(u2, n_int); + Eigen::Matrix B = ops.facet_basis(fid); + out_data.row(fid) = (B * u2).stableNormalized().transpose(); + } + + return attr_id; +} + + +// ============================================================================= +// compute_smooth_direction_field (vertex-based, Knöppel et al. 2013) +// ============================================================================= + template AttributeId compute_smooth_direction_field( SurfaceMesh& mesh, @@ -37,6 +320,18 @@ AttributeId compute_smooth_direction_field( { la_runtime_assert(options.nrosy >= 1, "compute_smooth_direction_field: nrosy must be >= 1."); + if (options.output_element_type == AttributeElement::Facet) { + if (options.lambda != SmoothDirectionFieldOptions{}.lambda) { + logger().warn( + "compute_smooth_direction_field: lambda is ignored for Facet output (vertex-based " + "path only)."); + } + return compute_smooth_direction_field_on_facets(mesh, ops, std::move(options)); + } + la_runtime_assert( + options.output_element_type == AttributeElement::Vertex, + "compute_smooth_direction_field: output_element_type must be Vertex or Facet."); + const Index num_vertices = mesh.get_num_vertices(); const Index n = static_cast(options.nrosy); const int n_int = static_cast(options.nrosy); @@ -50,84 +345,31 @@ AttributeId compute_smooth_direction_field( auto M0 = ops.star0(); std::vector> M_triplets; M_triplets.reserve(num_vertices * 2); - for (Index vid = 0; vid < num_vertices; ++vid) { - const Scalar m = M0.coeff(static_cast(vid), static_cast(vid)); - M_triplets.emplace_back( - static_cast(vid * 2), - static_cast(vid * 2), - m); - M_triplets.emplace_back( - static_cast(vid * 2 + 1), - static_cast(vid * 2 + 1), - m); + for (Eigen::Index vid = 0; vid < static_cast(num_vertices); ++vid) { + const Scalar m = M0.coeff(vid, vid); + M_triplets.emplace_back(2 * vid, 2 * vid, m); + M_triplets.emplace_back(2 * vid + 1, 2 * vid + 1, m); } Eigen::SparseMatrix M( static_cast(num_vertices * 2), static_cast(num_vertices * 2)); M.setFromTriplets(M_triplets.begin(), M_triplets.end()); - // --- Compute the smallest eigenvector/eigenvalue of L u = σ M u --- - // - // This is needed for both the unconstrained case (the result IS the smoothest field) - // and the constrained case (σ_min sets the spectral shift for alignment). - Eigen::Matrix x(static_cast(num_vertices * 2)); - Scalar sigma_min = Scalar(0); - { - constexpr Scalar eps = Scalar(1e-8); - Eigen::SparseMatrix L_reg = L + eps * M; - bool solved = false; - - { - auto result = solver::generalized_selfadjoint_eigen_smallest(L_reg, M, 1); - if (result.is_successful() && result.num_converged >= 1) { - x = result.eigenvectors.col(0); - sigma_min = result.eigenvalues(0) - eps; - solved = true; - } else { - logger().warn( - "compute_smooth_direction_field: Spectra eigen solver did not converge, " - "falling back to inverse power iteration."); - } - } - - if (!solved) { - // Fallback: inverse power iteration. - SolverLDLT> solver(L_reg); - la_runtime_assert( - solver.info() == Eigen::Success, - "compute_smooth_direction_field: Cholesky factorization of L + eps*M failed"); - - x = Eigen::Matrix::Ones( - static_cast(num_vertices * 2)); - x.normalize(); - - constexpr int max_iter = 20; - for (int iter = 0; iter < max_iter; ++iter) { - x = M * x; - x = solver.solve(x); - x.normalize(); - } - // Estimate σ_min via Rayleigh quotient: σ = (x^T L x) / (x^T M x). - sigma_min = x.dot(L * x) / x.dot(M * x); - } - } + // The system matrix L is positive semi-definite. Add a small multiple of M to make + // it strictly positive definite for Cholesky / LDLT (Knöppel et al. 2013, Algorithm 1 + // setup: A ← A + εM with ε = 1e-8). + constexpr Scalar eps = Scalar(1e-8); + Eigen::SparseMatrix L_reg = L + eps * M; - const bool has_constraints = !options.alignment_attribute.empty(); + Eigen::Matrix x(static_cast(num_vertices * 2)); + bool has_constraints = !options.alignment_attribute.empty(); + Eigen::Matrix q; if (has_constraints) { - // --- Constrained solve: (L - α*M + ε*M) u = M*q --- + // --- Build alignment rhs q (Knöppel et al. 2013, Eq. 16 / Algorithm 3) --- // - // Following fieldgen (Knöppel et al. 2013), the spectral shift α = σ_min makes - // (L - α*M) singular along the smoothest mode, maximally biasing the solution - // toward the prescribed field q. The small ε*M regularization prevents exact - // singularity. alignment_weight scales the shift (1.0 = full fieldgen shift). - la_runtime_assert( - options.alignment_weight > 0 && options.alignment_weight <= 1.0, - "compute_smooth_direction_field: alignment_weight must be in (0, 1]."); - - const Scalar alpha = - static_cast(options.alignment_weight) * std::max(sigma_min, Scalar(0)); - constexpr Scalar eps = Scalar(1e-8); + // Prescribed unit tangents at constrained vertices in n-rosy encoded space, + // zero elsewhere. const auto alignment_id = internal::find_attribute( mesh, @@ -142,7 +384,7 @@ AttributeId compute_smooth_direction_field( auto align_data = attribute_matrix_view(mesh, alignment_id); - Eigen::Matrix q(static_cast(num_vertices * 2)); + q.resize(static_cast(num_vertices * 2)); q.setZero(); for (Index vid = 0; vid < num_vertices; ++vid) { @@ -157,32 +399,31 @@ AttributeId compute_smooth_direction_field( q.template segment<2>(static_cast(vid * 2)) = nrosy_encode(v2, n_int); } - // M-normalize q so ||q||_M = 1 (following fieldgen). - Scalar norm_q = std::sqrt(q.dot(M * q)); - la_runtime_assert( - norm_q > Scalar(1e-10), - "compute_smooth_direction_field: all alignment vectors are zero or near-zero"); - q /= norm_q; - - // Assemble and factor the shifted system matrix: L - α*M + ε*M. - Eigen::SparseMatrix L_shifted = L - (alpha - eps) * M; - SolverLDLT> solver(L_shifted); - la_runtime_assert( - solver.info() == Eigen::Success, - "compute_smooth_direction_field: factorization of L - alpha*M failed."); - - Eigen::Matrix Mq = M * q; - x = solver.solve(Mq); - la_runtime_assert( - solver.info() == Eigen::Success, - "compute_smooth_direction_field: constrained solve failed"); + if (q.squaredNorm() == Scalar(0)) { + logger().warn( + "compute_smooth_direction_field: all alignment vectors are zero or near-zero; " + "falling back to unconstrained solve."); + has_constraints = false; + } } - // else: x already holds the unconstrained smallest eigenvector from above. + + solve_connection_laplacian( + L_reg, + M, + q, + has_constraints, + static_cast(num_vertices * 2), + "compute_smooth_direction_field", + x); // Create or reuse the output attribute (3-D vector per vertex). + const std::string_view out_name = + (options.direction_field_attribute && !options.direction_field_attribute->empty()) + ? *options.direction_field_attribute + : "@smooth_direction_field"; const auto direction_field_id = internal::find_or_create_attribute( mesh, - options.direction_field_attribute, + out_name, AttributeElement::Vertex, AttributeUsage::Vector, 3, @@ -206,11 +447,24 @@ AttributeId compute_smooth_direction_field( return direction_field_id; } +template +AttributeId compute_smooth_direction_field( + SurfaceMesh& mesh, + SmoothDirectionFieldOptions options) +{ + DifferentialOperators ops(mesh); + return compute_smooth_direction_field(mesh, ops, std::move(options)); +} + #define LA_X_compute_smooth_direction_field(_, Scalar, Index) \ template LA_POLYDDG_API AttributeId compute_smooth_direction_field( \ SurfaceMesh&, \ const DifferentialOperators&, \ + SmoothDirectionFieldOptions); \ + template LA_POLYDDG_API AttributeId compute_smooth_direction_field( \ + SurfaceMesh&, \ SmoothDirectionFieldOptions); LA_SURFACE_MESH_X(compute_smooth_direction_field, 0) + } // namespace lagrange::polyddg diff --git a/modules/polyddg/tests/test_compute_smooth_direction_field.cpp b/modules/polyddg/tests/test_compute_smooth_direction_field.cpp index 89f27ca3..964f4304 100644 --- a/modules/polyddg/tests/test_compute_smooth_direction_field.cpp +++ b/modules/polyddg/tests/test_compute_smooth_direction_field.cpp @@ -178,6 +178,61 @@ TEST_CASE("compute_smooth_direction_field", "[polyddg]") } } + SECTION("sphere: alignment constraint is respected (regression)") + { + // Regression test for the alignment-flip bug. A sphere has σ_min ≈ 4, and the + // earlier implementation set the spectral shift to σ_min — exactly the boundary + // disallowed by Knöppel et al. 2013 Algorithm 3 (λ_t ∈ (−∞, λ_0) strictly). + // Numerical error then drove the smoothest-mode coefficient negative under + // LDLT, producing cos(4·Δθ) ≈ −1 (worst-case anti-aligned). The current + // implementation hard-codes λ_t = 0 (paper-recommended default), avoiding the + // boundary entirely. + primitive::IcosahedronOptions ico_opts; + ico_opts.radius = 1.0; + auto base_ico = primitive::generate_icosahedron(ico_opts); + primitive::SubdividedSphereOptions subdiv_opts; + subdiv_opts.radius = 1.0; + subdiv_opts.subdiv_level = 3; + auto sphere = primitive::generate_subdivided_sphere(base_ico, subdiv_opts); + polyddg::DifferentialOperators ops(sphere); + + const Index constrained_vid = 0; + auto p = sphere.get_position(constrained_vid); + Eigen::Matrix pv(p[0], p[1], p[2]); + Eigen::Matrix e(0, 0, 1); + Eigen::Matrix tan = e - e.dot(pv.normalized()) * pv.normalized(); + if (tan.norm() < 1e-6) { + e = Eigen::Matrix(1, 0, 0); + tan = e - e.dot(pv.normalized()) * pv.normalized(); + } + tan.normalize(); + + auto align_id = internal::find_or_create_attribute( + sphere, + "@sphere_alignment", + AttributeElement::Vertex, + AttributeUsage::Vector, + 3, + internal::ResetToDefault::Yes); + auto align_data = attribute_matrix_ref(sphere, align_id); + align_data.setZero(); + align_data.row(constrained_vid) = tan.transpose(); + + polyddg::SmoothDirectionFieldOptions opts; + opts.nrosy = 4; + opts.alignment_attribute = "@sphere_alignment"; + auto result = polyddg::compute_smooth_direction_field(sphere, ops, opts); + auto data = attribute_matrix_view(sphere, result); + + auto B = ops.vertex_basis(constrained_vid); + Eigen::Matrix out_2d = B.transpose() * data.row(constrained_vid).transpose(); + Eigen::Matrix ref_2d = B.transpose() * tan; + Scalar out_angle = std::atan2(out_2d(1), out_2d(0)); + Scalar ref_angle = std::atan2(ref_2d(1), ref_2d(0)); + Scalar cos4_diff = std::cos(4.0 * (out_angle - ref_angle)); + REQUIRE(cos4_diff > 0.9); + } + SECTION("torus: alignment constraint is respected") { primitive::TorusOptions torus_opts; @@ -211,7 +266,6 @@ TEST_CASE("compute_smooth_direction_field", "[polyddg]") polyddg::SmoothDirectionFieldOptions opts; opts.nrosy = 1; opts.alignment_attribute = "@test_alignment"; - opts.alignment_weight = 1.0; auto result = polyddg::compute_smooth_direction_field(torus, ops, opts); auto data = attribute_matrix_view(torus, result); @@ -265,7 +319,6 @@ TEST_CASE("compute_smooth_direction_field", "[polyddg]") polyddg::SmoothDirectionFieldOptions opts; opts.nrosy = 4; opts.alignment_attribute = "@test_alignment_4rosy"; - opts.alignment_weight = 1.0; auto result = polyddg::compute_smooth_direction_field(torus, ops, opts); auto data = attribute_matrix_view(torus, result); @@ -338,3 +391,222 @@ TEST_CASE("compute_smooth_direction_field", "[polyddg]") } } } + +TEST_CASE("compute_smooth_direction_field_on_facets", "[polyddg]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SECTION("output dimensions and unit length — triangle mesh") + { + SurfaceMesh mesh; + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_vertex({0.0, 1.0, 0.0}); + mesh.add_vertex({0.0, 0.0, 1.0}); + mesh.add_triangle(0, 1, 2); + + polyddg::DifferentialOperators ops(mesh); + polyddg::SmoothDirectionFieldOptions opts; + opts.nrosy = 4; + opts.output_element_type = AttributeElement::Facet; + auto result = polyddg::compute_smooth_direction_field(mesh, ops, opts); + + REQUIRE(result != invalid_attribute_id()); + auto data = attribute_matrix_view(mesh, result); + REQUIRE(data.rows() == static_cast(mesh.get_num_facets())); + REQUIRE(data.cols() == 3); + for (Index fid = 0; fid < mesh.get_num_facets(); ++fid) { + REQUIRE_THAT(data.row(fid).norm(), Catch::Matchers::WithinAbs(1.0, 1e-10)); + } + } + + SECTION("output dimensions and unit length — pyramid mesh") + { + SurfaceMesh mesh; + mesh.add_vertex({0.0, 0.0, 0.0}); + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_vertex({1.0, 1.0, 0.0}); + mesh.add_vertex({0.0, 1.0, 0.0}); + mesh.add_vertex({0.5, 0.5, 1.0}); + mesh.add_triangle(0, 1, 4); + mesh.add_triangle(1, 2, 4); + mesh.add_triangle(2, 3, 4); + mesh.add_triangle(3, 0, 4); + mesh.add_quad(0, 3, 2, 1); + + polyddg::DifferentialOperators ops(mesh); + polyddg::SmoothDirectionFieldOptions opts; + opts.nrosy = 4; + opts.output_element_type = AttributeElement::Facet; + auto result = polyddg::compute_smooth_direction_field(mesh, ops, opts); + + REQUIRE(result != invalid_attribute_id()); + auto data = attribute_matrix_view(mesh, result); + REQUIRE(data.rows() == static_cast(mesh.get_num_facets())); + REQUIRE(data.cols() == 3); + for (Index fid = 0; fid < mesh.get_num_facets(); ++fid) { + REQUIRE_THAT(data.row(fid).norm(), Catch::Matchers::WithinAbs(1.0, 1e-10)); + } + } + + SECTION("sphere: tangent to surface") + { + primitive::IcosahedronOptions ico_opts; + ico_opts.radius = 1.0; + auto base_ico = primitive::generate_icosahedron(ico_opts); + primitive::SubdividedSphereOptions subdiv_opts; + subdiv_opts.radius = 1.0; + subdiv_opts.subdiv_level = 1; + auto sphere = primitive::generate_subdivided_sphere(base_ico, subdiv_opts); + + polyddg::DifferentialOperators ops(sphere); + polyddg::SmoothDirectionFieldOptions opts; + opts.nrosy = 4; + opts.output_element_type = AttributeElement::Facet; + auto result = polyddg::compute_smooth_direction_field(sphere, ops, opts); + + auto data = attribute_matrix_view(sphere, result); + const auto va_id = ops.get_vector_area_attribute_id(); + const auto va_view = attribute_matrix_view(sphere, va_id); + + Scalar max_dot = 0; + for (Index fid = 0; fid < sphere.get_num_facets(); ++fid) { + Eigen::Matrix normal = va_view.row(fid).stableNormalized(); + max_dot = std::max(max_dot, std::abs(data.row(fid).dot(normal))); + } + REQUIRE(max_dot < 1e-10); + } + + SECTION("result is deterministic") + { + SurfaceMesh mesh; + mesh.add_vertex({0.0, 0.0, 0.0}); + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_vertex({1.0, 1.0, 0.0}); + mesh.add_vertex({0.0, 1.0, 0.0}); + mesh.add_vertex({0.5, 0.5, 1.0}); + mesh.add_triangle(0, 1, 4); + mesh.add_triangle(1, 2, 4); + mesh.add_triangle(2, 3, 4); + mesh.add_triangle(3, 0, 4); + mesh.add_quad(0, 3, 2, 1); + + polyddg::DifferentialOperators ops(mesh); + polyddg::SmoothDirectionFieldOptions opts; + opts.nrosy = 4; + opts.output_element_type = AttributeElement::Facet; + opts.direction_field_attribute = "@sdf_facets_first"; + auto result1 = polyddg::compute_smooth_direction_field(mesh, ops, opts); + + opts.direction_field_attribute = "@sdf_facets_second"; + auto result2 = polyddg::compute_smooth_direction_field(mesh, ops, opts); + + auto data1 = attribute_matrix_view(mesh, result1); + auto data2 = attribute_matrix_view(mesh, result2); + + Scalar max_encoded_diff = 0; + for (Index fid = 0; fid < mesh.get_num_facets(); ++fid) { + auto B = ops.facet_basis(fid); + Eigen::Matrix u1 = B.transpose() * data1.row(fid).transpose(); + Eigen::Matrix u2 = B.transpose() * data2.row(fid).transpose(); + Scalar a1 = 4.0 * std::atan2(u1(1), u1(0)); + Scalar a2 = 4.0 * std::atan2(u2(1), u2(0)); + max_encoded_diff = std::max(max_encoded_diff, 1.0 - std::cos(a1 - a2)); + } + REQUIRE_THAT(max_encoded_diff, Catch::Matchers::WithinAbs(0.0, 1e-8)); + } + + SECTION("torus: field is tangent and unit length") + { + primitive::TorusOptions torus_opts; + torus_opts.major_radius = 3.0; + torus_opts.minor_radius = 1.0; + torus_opts.ring_segments = 30; + torus_opts.pipe_segments = 20; + auto torus = primitive::generate_torus(torus_opts); + + polyddg::DifferentialOperators ops(torus); + polyddg::SmoothDirectionFieldOptions opts; + opts.nrosy = 4; + opts.output_element_type = AttributeElement::Facet; + auto result = polyddg::compute_smooth_direction_field(torus, ops, opts); + + auto data = attribute_matrix_view(torus, result); + const auto va_id = ops.get_vector_area_attribute_id(); + const auto va_view = attribute_matrix_view(torus, va_id); + + for (Index fid = 0; fid < torus.get_num_facets(); ++fid) { + REQUIRE_THAT(data.row(fid).norm(), Catch::Matchers::WithinAbs(1.0, 1e-10)); + Eigen::Matrix normal = va_view.row(fid).stableNormalized(); + Scalar ndot = std::abs(data.row(fid).dot(normal)); + REQUIRE_THAT(ndot, Catch::Matchers::WithinAbs(0.0, 1e-8)); + } + } + + SECTION("torus: alignment constraint is respected") + { + primitive::TorusOptions torus_opts; + torus_opts.major_radius = 3.0; + torus_opts.minor_radius = 1.0; + torus_opts.ring_segments = 30; + torus_opts.pipe_segments = 20; + auto torus = primitive::generate_torus(torus_opts); + polyddg::DifferentialOperators ops(torus); + + const Index constrained_fid = 0; + + const auto centroid_id = ops.get_centroid_attribute_id(); + const auto centroid_view = attribute_matrix_view(torus, centroid_id); + Eigen::Matrix c = centroid_view.row(constrained_fid); + Scalar angle_u = std::atan2(c(1), c(0)); + Eigen::Matrix prescribed_dir(-std::sin(angle_u), std::cos(angle_u), 0); + + auto align_id = internal::find_or_create_attribute( + torus, + "@test_facet_alignment", + AttributeElement::Facet, + AttributeUsage::Vector, + 3, + internal::ResetToDefault::Yes); + auto align_data = attribute_matrix_ref(torus, align_id); + align_data.setZero(); + align_data.row(constrained_fid) = prescribed_dir.transpose(); + + polyddg::SmoothDirectionFieldOptions opts; + opts.nrosy = 4; + opts.output_element_type = AttributeElement::Facet; + opts.alignment_attribute = "@test_facet_alignment"; + auto result = polyddg::compute_smooth_direction_field(torus, ops, opts); + auto data = attribute_matrix_view(torus, result); + + auto B = ops.facet_basis(constrained_fid); + Eigen::Matrix out_2d = B.transpose() * data.row(constrained_fid).transpose(); + Eigen::Matrix ref_2d = B.transpose() * prescribed_dir; + Scalar out_angle = std::atan2(out_2d(1), out_2d(0)); + Scalar ref_angle = std::atan2(ref_2d(1), ref_2d(0)); + Scalar cos4_diff = std::cos(4.0 * (out_angle - ref_angle)); + // Loosened from 0.9: meta-build (Eigen 3.4.1) lands at ~0.8886 here, while + // CMake (Eigen 5.0.1) gets ~0.95. 0.85 still asserts ~<16° angular error. + REQUIRE(cos4_diff > 0.85); + } + + SECTION("convenience overload (no ops argument)") + { + SurfaceMesh mesh; + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_vertex({0.0, 1.0, 0.0}); + mesh.add_vertex({0.0, 0.0, 1.0}); + mesh.add_triangle(0, 1, 2); + + polyddg::SmoothDirectionFieldOptions opts; + opts.nrosy = 4; + opts.output_element_type = AttributeElement::Facet; + auto result = polyddg::compute_smooth_direction_field(mesh, opts); + + REQUIRE(result != invalid_attribute_id()); + auto data = attribute_matrix_view(mesh, result); + REQUIRE(data.rows() == static_cast(mesh.get_num_facets())); + REQUIRE(data.cols() == 3); + } +} diff --git a/modules/polyscope/include/lagrange/polyscope/set_transform.h b/modules/polyscope/include/lagrange/polyscope/set_transform.h new file mode 100644 index 00000000..428f343e --- /dev/null +++ b/modules/polyscope/include/lagrange/polyscope/set_transform.h @@ -0,0 +1,38 @@ +/* + * 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 + +// clang-format off +#include +#include +#include +// clang-format on + +namespace lagrange::polyscope { + +/// +/// Apply an affine transform to a Polyscope structure via @c setTransform(). The transform is +/// applied at render time — vertex positions are not modified. +/// +/// @param[in,out] structure Polyscope structure to set the transform on. +/// @param[in] transform Affine transform. +/// +/// @tparam Scalar Scalar type of the transform. +/// +template +void set_transform( + ::polyscope::Structure& structure, + const Eigen::Transform& transform); + +} // namespace lagrange::polyscope diff --git a/modules/polyscope/src/set_transform.cpp b/modules/polyscope/src/set_transform.cpp new file mode 100644 index 00000000..378cf2c9 --- /dev/null +++ b/modules/polyscope/src/set_transform.cpp @@ -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. + */ +#include + +#include + +namespace lagrange::polyscope { + +template +void set_transform( + ::polyscope::Structure& structure, + const Eigen::Transform& transform) +{ + const auto& mat = transform.matrix(); + glm::mat4x4 m; + for (int col = 0; col < 4; ++col) { + for (int row = 0; row < 4; ++row) { + m[col][row] = static_cast(mat(row, col)); + } + } + structure.setTransform(m); +} + +template LA_POLYSCOPE_API void set_transform( + ::polyscope::Structure& structure, + const Eigen::Transform& transform); +template LA_POLYSCOPE_API void set_transform( + ::polyscope::Structure& structure, + const Eigen::Transform& transform); + +} // namespace lagrange::polyscope diff --git a/modules/primitive/src/generate_disc.cpp b/modules/primitive/src/generate_disc.cpp index b733e7eb..4ffb57b4 100644 --- a/modules/primitive/src/generate_disc.cpp +++ b/modules/primitive/src/generate_disc.cpp @@ -85,50 +85,56 @@ SurfaceMesh generate_disc(DiscOptions setting) } // Generate UV coordinates - auto uv_attr_id = mesh.template create_attribute( - setting.uv_attribute_name, - AttributeElement::Indexed, - 2, - AttributeUsage::UV); - auto& uv_attr = mesh.template ref_indexed_attribute(uv_attr_id); - auto& uv_values = uv_attr.values(); - auto& uv_indices = uv_attr.indices(); - uv_values.resize_elements(vertices_per_ring * num_rings + 1); - - if (!setting.fixed_uv) { - matrix_ref(uv_values) = vertices.template leftCols<2>(); - vector_ref(uv_indices) = - attribute_vector_view(mesh, mesh.attr_id_corner_to_vertex()); - } else { - // Always map UVs to a complete disc - auto uvs = matrix_ref(uv_values); - uvs.row(0).setZero(); - for (size_t l = 0; l < num_rings; l++) { - Scalar r = setting.radius * static_cast(l + 1) / static_cast(num_rings); - size_t offset = l * vertices_per_ring + 1; // +1 for the center vertex - for (size_t i = 0; i < vertices_per_ring; ++i) { - Scalar t = static_cast(i) / static_cast(setting.radial_sections); - Scalar angle = 2 * lagrange::internal::pi * t; - uvs.row(offset + i) << r * std::cos(angle), r * std::sin(angle); + if (!setting.uv_attribute_name.empty()) { + auto uv_attr_id = mesh.template create_attribute( + setting.uv_attribute_name, + AttributeElement::Indexed, + 2, + AttributeUsage::UV); + auto& uv_attr = mesh.template ref_indexed_attribute(uv_attr_id); + auto& uv_values = uv_attr.values(); + auto& uv_indices = uv_attr.indices(); + uv_values.resize_elements(vertices_per_ring * num_rings + 1); + + if (!setting.fixed_uv) { + matrix_ref(uv_values) = vertices.template leftCols<2>(); + vector_ref(uv_indices) = + attribute_vector_view(mesh, mesh.attr_id_corner_to_vertex()); + } else { + // Always map UVs to a complete disc + auto uvs = matrix_ref(uv_values); + uvs.row(0).setZero(); + for (size_t l = 0; l < num_rings; l++) { + Scalar r = + setting.radius * static_cast(l + 1) / static_cast(num_rings); + size_t offset = l * vertices_per_ring + 1; // +1 for the center vertex + for (size_t i = 0; i < vertices_per_ring; ++i) { + Scalar t = + static_cast(i) / static_cast(setting.radial_sections); + Scalar angle = 2 * lagrange::internal::pi * t; + uvs.row(offset + i) << r * std::cos(angle), r * std::sin(angle); + } } + vector_ref(uv_indices) = + attribute_vector_view(mesh, mesh.attr_id_corner_to_vertex()); } - vector_ref(uv_indices) = - attribute_vector_view(mesh, mesh.attr_id_corner_to_vertex()); } // Generate normals - auto normal_attr_id = mesh.template create_attribute( - setting.normal_attribute_name, - AttributeElement::Indexed, - 3, - AttributeUsage::Normal); - auto& normal_attr = mesh.template ref_indexed_attribute(normal_attr_id); - auto& normal_values = normal_attr.values(); - auto& normal_indices = normal_attr.indices(); - normal_values.resize_elements(1); - - matrix_ref(normal_values).row(0) << 0, 0, 1; - vector_ref(normal_indices).setZero(); // All facets share the same normal + if (!setting.normal_attribute_name.empty()) { + auto normal_attr_id = mesh.template create_attribute( + setting.normal_attribute_name, + AttributeElement::Indexed, + 3, + AttributeUsage::Normal); + auto& normal_attr = mesh.template ref_indexed_attribute(normal_attr_id); + auto& normal_values = normal_attr.values(); + auto& normal_indices = normal_attr.indices(); + normal_values.resize_elements(1); + + matrix_ref(normal_values).row(0) << 0, 0, 1; + vector_ref(normal_indices).setZero(); // All facets share the same normal + } if (setting.triangulate) { TriangulationOptions triangulation_options; diff --git a/modules/primitive/src/generate_rounded_cone.cpp b/modules/primitive/src/generate_rounded_cone.cpp index f873fd25..4baa022a 100644 --- a/modules/primitive/src/generate_rounded_cone.cpp +++ b/modules/primitive/src/generate_rounded_cone.cpp @@ -230,7 +230,7 @@ SurfaceMesh generate_rounded_cone(RoundedConeOptions setting) sweep_setting, sweep_options); transform_mesh(bottom_bevel, transform); - if (setting.fixed_uv) { + if (setting.fixed_uv && !setting.uv_attribute_name.empty()) { Scalar max_v = 0.5 * (bottom_bevel_arc_length / total_length); normalize_uv(bottom_bevel, {0, 0}, {0.5, max_v}); } @@ -248,7 +248,7 @@ SurfaceMesh generate_rounded_cone(RoundedConeOptions setting) sweep_setting, sweep_options); transform_mesh(side, transform); - if (setting.fixed_uv) { + if (setting.fixed_uv && !setting.uv_attribute_name.empty()) { Scalar min_v = 0.5 * (bottom_bevel_arc_length / total_length); Scalar max_v = 0.5 * ((bottom_bevel_arc_length + side_length) / total_length); normalize_uv(side, {0, min_v}, {0.5, max_v}); @@ -262,7 +262,7 @@ SurfaceMesh generate_rounded_cone(RoundedConeOptions setting) sweep_setting, sweep_options); transform_mesh(top_bevel, transform); - if (setting.fixed_uv) { + if (setting.fixed_uv && !setting.uv_attribute_name.empty()) { Scalar min_v = 0.5 * ((bottom_bevel_arc_length + side_length) / total_length); normalize_uv(top_bevel, {0, min_v}, {0.5, 0.5}); } @@ -284,6 +284,8 @@ SurfaceMesh generate_rounded_cone(RoundedConeOptions setting) disc_setting.radial_sections = setting.radial_sections; disc_setting.fixed_uv = setting.fixed_uv; disc_setting.triangulate = setting.triangulate; + disc_setting.uv_attribute_name = setting.uv_attribute_name; + disc_setting.normal_attribute_name = setting.normal_attribute_name; auto disc = generate_disc(disc_setting); AffineTransform transform; @@ -295,7 +297,7 @@ SurfaceMesh generate_rounded_cone(RoundedConeOptions setting) transform.translate(Eigen::Matrix(0, 0, setting.height)); auto top_cap = transformed_mesh(disc, transform); - if (setting.fixed_uv) { + if (setting.fixed_uv && !setting.uv_attribute_name.empty()) { normalize_uv( top_cap, {setting.uv_padding, 0.5 + setting.uv_padding}, @@ -316,6 +318,8 @@ SurfaceMesh generate_rounded_cone(RoundedConeOptions setting) disc_setting.radial_sections = setting.radial_sections; disc_setting.fixed_uv = setting.fixed_uv; disc_setting.triangulate = setting.triangulate; + disc_setting.uv_attribute_name = setting.uv_attribute_name; + disc_setting.normal_attribute_name = setting.normal_attribute_name; auto disc = generate_disc(disc_setting); AffineTransform transform; @@ -326,7 +330,7 @@ SurfaceMesh generate_rounded_cone(RoundedConeOptions setting) Eigen::Matrix::UnitX())); auto bottom_cap = transformed_mesh(disc, transform); - if (setting.fixed_uv) { + if (setting.fixed_uv && !setting.uv_attribute_name.empty()) { normalize_uv( bottom_cap, {0.5 + setting.uv_padding, 0.5 + setting.uv_padding}, @@ -378,7 +382,7 @@ SurfaceMesh generate_rounded_cone(RoundedConeOptions setting) auto cross_section_begin = transformed_mesh(flipped_profile_mesh, transform_begin); auto cross_section_end = transformed_mesh(profile_mesh, transform_end); - if (setting.fixed_uv) { + if (setting.fixed_uv && !setting.uv_attribute_name.empty()) { normalize_uv( cross_section_begin, {0.5 + setting.uv_padding, setting.uv_padding}, @@ -412,23 +416,26 @@ SurfaceMesh generate_rounded_cone(RoundedConeOptions setting) auto cone_vertices = extract_cone_vertices(mesh, static_cast(setting.dist_threshold)); // Weld indexed normals - WeldOptions attr_weld_options; - attr_weld_options.epsilon_abs = 1; // Disable distance-based check - attr_weld_options.angle_abs = setting.angle_threshold; - attr_weld_options.exclude_vertices = {cone_vertices.data(), cone_vertices.size()}; - weld_indexed_attribute( - mesh, - mesh.get_attribute_id(setting.normal_attribute_name), - attr_weld_options); + if (!setting.normal_attribute_name.empty()) { + WeldOptions attr_weld_options; + attr_weld_options.epsilon_abs = 1; // Disable distance-based check + attr_weld_options.angle_abs = setting.angle_threshold; + attr_weld_options.exclude_vertices = {cone_vertices.data(), cone_vertices.size()}; + weld_indexed_attribute( + mesh, + mesh.get_attribute_id(setting.normal_attribute_name), + attr_weld_options); + } if (setting.triangulate) { remove_degenerate_facets(mesh); } - if (!setting.fixed_uv) { + if (!setting.fixed_uv && !setting.uv_attribute_name.empty()) { packing::RepackOptions repack_options; repack_options.margin = setting.uv_padding; - packing::repack_uv_charts(mesh); + repack_options.uv_attribute_name = setting.uv_attribute_name; + packing::repack_uv_charts(mesh, repack_options); } // Translate the mesh so that the origin is at the center of the cone. diff --git a/modules/primitive/src/generate_sphere.cpp b/modules/primitive/src/generate_sphere.cpp index f2d01763..f6dc2772 100644 --- a/modules/primitive/src/generate_sphere.cpp +++ b/modules/primitive/src/generate_sphere.cpp @@ -94,10 +94,12 @@ SurfaceMesh generate_sphere(SphereOptions setting) sweep_options); add_semantic_label(side, setting.semantic_label_attribute_name, SemanticLabel::Side); - if (setting.fixed_uv) { - normalize_uv(side, {0, 0}, {1, 0.5}); - } else { - normalize_uv(side, {1 - t_end, 0}, {1 - t_begin, 0.5}); + if (setting.uv_attribute_name != "") { + if (setting.fixed_uv) { + normalize_uv(side, {0, 0}, {1, 0.5}); + } else { + normalize_uv(side, {1 - t_end, 0}, {1 - t_begin, 0.5}); + } } parts.push_back(std::move(side)); @@ -115,6 +117,8 @@ SurfaceMesh generate_sphere(SphereOptions setting) static_cast(-lagrange::internal::pi / 2); disc_setting.end_angle = static_cast(lagrange::internal::pi / 2); disc_setting.radial_sections = setting.num_longitude_sections; + disc_setting.uv_attribute_name = setting.uv_attribute_name; + disc_setting.normal_attribute_name = setting.normal_attribute_name; auto cross_section_end = generate_disc(disc_setting); transform_mesh(cross_section_end, transform_end); @@ -130,14 +134,16 @@ SurfaceMesh generate_sphere(SphereOptions setting) Eigen::Matrix::UnitY())); transform_mesh(cross_section_begin, transform_begin); - normalize_uv( - cross_section_end, - {0.5, 0.5 + setting.uv_padding}, - {0.75 - setting.uv_padding, 1 - setting.uv_padding}); - normalize_uv( - cross_section_begin, - {0.25 + setting.uv_padding, 0.5 + setting.uv_padding}, - {0.5, 1 - setting.uv_padding}); + if (!setting.uv_attribute_name.empty()) { + normalize_uv( + cross_section_end, + {0.5, 0.5 + setting.uv_padding}, + {0.75 - setting.uv_padding, 1 - setting.uv_padding}); + normalize_uv( + cross_section_begin, + {0.25 + setting.uv_padding, 0.5 + setting.uv_padding}, + {0.5, 1 - setting.uv_padding}); + } add_semantic_label( cross_section_begin, @@ -160,13 +166,15 @@ SurfaceMesh generate_sphere(SphereOptions setting) bvh::weld_vertices(mesh, weld_options); // Weld indexed normals - WeldOptions attr_weld_options; - attr_weld_options.epsilon_abs = 1; // Disable distance-based check - attr_weld_options.angle_abs = setting.angle_threshold; - weld_indexed_attribute( - mesh, - mesh.get_attribute_id(setting.normal_attribute_name), - attr_weld_options); + if (setting.normal_attribute_name != "") { + WeldOptions attr_weld_options; + attr_weld_options.epsilon_abs = 1; // Disable distance-based check + attr_weld_options.angle_abs = setting.angle_threshold; + weld_indexed_attribute( + mesh, + mesh.get_attribute_id(setting.normal_attribute_name), + attr_weld_options); + } if (setting.triangulate) { remove_degenerate_facets(mesh); diff --git a/modules/primitive/src/primitive_utils.h b/modules/primitive/src/primitive_utils.h index d16a1e85..f280160f 100644 --- a/modules/primitive/src/primitive_utils.h +++ b/modules/primitive/src/primitive_utils.h @@ -70,6 +70,9 @@ void add_semantic_label( std::string_view name, const SemanticLabel label) { + if (name.empty()) { + return; + } mesh.template create_attribute( name, AttributeElement::Facet, @@ -172,36 +175,40 @@ SurfaceMesh boundary_to_mesh( mesh.add_polygon({indices.data(), indices.size()}); // Generate UV coordinates - auto uv_attr_id = mesh.template create_attribute( - uv_attribute_name, - AttributeElement::Indexed, - 2, - AttributeUsage::UV); - auto& uv_attr = mesh.template ref_indexed_attribute(uv_attr_id); - auto& uv_values = uv_attr.values(); - auto& uv_indices = uv_attr.indices(); - uv_values.resize_elements(num_vertices); - auto uv_indices_ref = uv_indices.ref_all(); - - matrix_ref(uv_values) = vertices.template leftCols<2>(); - if (flipped) { - matrix_ref(uv_values).col(0) *= -1; // Flip UVs if the mesh is flipped + if (!uv_attribute_name.empty()) { + auto uv_attr_id = mesh.template create_attribute( + uv_attribute_name, + AttributeElement::Indexed, + 2, + AttributeUsage::UV); + auto& uv_attr = mesh.template ref_indexed_attribute(uv_attr_id); + auto& uv_values = uv_attr.values(); + auto& uv_indices = uv_attr.indices(); + uv_values.resize_elements(num_vertices); + auto uv_indices_ref = uv_indices.ref_all(); + + matrix_ref(uv_values) = vertices.template leftCols<2>(); + if (flipped) { + matrix_ref(uv_values).col(0) *= -1; // Flip UVs if the mesh is flipped + } + std::copy(indices.begin(), indices.end(), uv_indices_ref.begin()); } - std::copy(indices.begin(), indices.end(), uv_indices_ref.begin()); // Generate normals - auto normal_attr_id = mesh.template create_attribute( - normal_attribute_name, - AttributeElement::Indexed, - 3, - AttributeUsage::Normal); - auto& normal_attr = mesh.template ref_indexed_attribute(normal_attr_id); - auto& normal_values = normal_attr.values(); - auto& normal_indices = normal_attr.indices(); - normal_values.resize_elements(1); - - matrix_ref(normal_values).row(0) << 0, 0, (flipped ? -1 : 1); - vector_ref(normal_indices).setZero(); // All facets share the same normal + if (!normal_attribute_name.empty()) { + auto normal_attr_id = mesh.template create_attribute( + normal_attribute_name, + AttributeElement::Indexed, + 3, + AttributeUsage::Normal); + auto& normal_attr = mesh.template ref_indexed_attribute(normal_attr_id); + auto& normal_values = normal_attr.values(); + auto& normal_indices = normal_attr.indices(); + normal_values.resize_elements(1); + + matrix_ref(normal_values).row(0) << 0, 0, (flipped ? -1 : 1); + vector_ref(normal_indices).setZero(); // All facets share the same normal + } return mesh; } diff --git a/modules/primitive/tests/test_rounded_cone.cpp b/modules/primitive/tests/test_rounded_cone.cpp index df4dad82..0b7ff12d 100644 --- a/modules/primitive/tests/test_rounded_cone.cpp +++ b/modules/primitive/tests/test_rounded_cone.cpp @@ -11,6 +11,7 @@ */ #include #include +#include #include #include #include @@ -143,6 +144,102 @@ TEST_CASE("generate_rounded_cone", "[primitive][surface]") } } +TEST_CASE("generate_rounded_cone: empty attribute names skip output", "[primitive][surface]") +{ + using namespace lagrange; + using Scalar = float; + using Index = uint32_t; + + primitive::RoundedConeOptions setting; + setting.radius_top = 1.0f; + setting.radius_bottom = 1.0f; + setting.height = 2.0f; + setting.with_top_cap = false; + setting.with_bottom_cap = false; + setting.triangulate = true; + setting.normal_attribute_name = ""; + setting.uv_attribute_name = ""; + setting.semantic_label_attribute_name = ""; + + auto mesh = primitive::generate_rounded_cone(setting); + + REQUIRE(mesh.get_num_vertices() > 0); + REQUIRE(mesh.get_num_facets() > 0); + REQUIRE_FALSE(mesh.has_attribute("@normal")); + REQUIRE_FALSE(mesh.has_attribute("@uv")); + REQUIRE_FALSE(mesh.has_attribute("@semantic_label")); + REQUIRE_FALSE(find_matching_attribute(mesh, AttributeUsage::UV).has_value()); + REQUIRE_FALSE(find_matching_attribute(mesh, AttributeUsage::Normal).has_value()); +} + +TEST_CASE("generate_rounded_cone: custom attribute names", "[primitive][surface]") +{ + // Ensure that non-default UV/normal/semantic-label attribute names are honored + // across all sub-meshes (side sweep, caps, cross sections). + using namespace lagrange; + using Scalar = float; + using Index = uint32_t; + + primitive::RoundedConeOptions setting; + setting.radius_top = 1.0f; + setting.radius_bottom = 1.0f; + setting.height = 2.0f; + setting.with_top_cap = true; + setting.with_bottom_cap = true; + setting.with_cross_section = true; + setting.start_sweep_angle = 0.0f; + setting.end_sweep_angle = static_cast(3); // Open sweep to exercise cross sections + setting.triangulate = true; + setting.uv_attribute_name = "my_uv"; + setting.normal_attribute_name = "my_normal"; + setting.semantic_label_attribute_name = "my_semantic_label"; + + auto mesh = primitive::generate_rounded_cone(setting); + + REQUIRE(mesh.get_num_vertices() > 0); + REQUIRE(mesh.get_num_facets() > 0); + REQUIRE(mesh.has_attribute("my_uv")); + REQUIRE(mesh.has_attribute("my_normal")); + REQUIRE(mesh.has_attribute("my_semantic_label")); + // Default names should not have been created. + REQUIRE_FALSE(mesh.has_attribute("@uv")); + REQUIRE_FALSE(mesh.has_attribute("@normal")); + REQUIRE_FALSE(mesh.has_attribute("@semantic_label")); +} + +TEST_CASE( + "generate_rounded_cone: empty attribute names skip output (caps + open sweep)", + "[primitive][surface]") +{ + // Regression: ensure that the cap (disc) and cross-section paths also honor + // empty UV/normal attribute names, not just the side sweep. + using namespace lagrange; + using Scalar = float; + using Index = uint32_t; + + primitive::RoundedConeOptions setting; + setting.radius_top = 1.0f; + setting.radius_bottom = 1.0f; + setting.height = 2.0f; + setting.with_top_cap = true; + setting.with_bottom_cap = true; + setting.with_cross_section = true; + setting.start_sweep_angle = 0.0f; + setting.end_sweep_angle = static_cast(3); // Open sweep (less than 2*pi) + setting.triangulate = true; + setting.normal_attribute_name = ""; + setting.uv_attribute_name = ""; + setting.semantic_label_attribute_name = ""; + + auto mesh = primitive::generate_rounded_cone(setting); + + REQUIRE(mesh.get_num_vertices() > 0); + REQUIRE(mesh.get_num_facets() > 0); + REQUIRE_FALSE(mesh.has_attribute("")); + REQUIRE_FALSE(find_matching_attribute(mesh, AttributeUsage::UV).has_value()); + REQUIRE_FALSE(find_matching_attribute(mesh, AttributeUsage::Normal).has_value()); +} + #ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS TEST_CASE("RoundedCone", "[primitive][rounded_cone]" LA_SLOW_DEBUG_FLAG) diff --git a/modules/primitive/tests/test_sphere.cpp b/modules/primitive/tests/test_sphere.cpp index a9a9787b..96190224 100644 --- a/modules/primitive/tests/test_sphere.cpp +++ b/modules/primitive/tests/test_sphere.cpp @@ -11,6 +11,7 @@ */ #include #include +#include #include #include #include @@ -63,6 +64,45 @@ TEST_CASE("generate_sphere", "[primitive][surface]") REQUIRE(mesh.get_num_facets() == 0); } + SECTION("no uv attribute") + { + setting.uv_attribute_name = ""; + auto mesh = lagrange::primitive::generate_sphere(setting); + primitive_test_utils::validate_primitive(mesh); + primitive_test_utils::check_degeneracy(mesh); + REQUIRE(!find_matching_attribute(mesh, AttributeUsage::UV).has_value()); + } + + SECTION("no optional attributes") + { + setting.uv_attribute_name = ""; + setting.normal_attribute_name = ""; + setting.semantic_label_attribute_name = ""; + auto mesh = lagrange::primitive::generate_sphere(setting); + primitive_test_utils::validate_primitive(mesh); + primitive_test_utils::check_degeneracy(mesh); + REQUIRE(!find_matching_attribute(mesh, AttributeUsage::UV).has_value()); + REQUIRE(!find_matching_attribute(mesh, AttributeUsage::Normal).has_value()); + REQUIRE(!mesh.has_attribute("")); + } + + SECTION("no optional attributes with open sweep") + { + // Open sweep exercises the cross-section disc path, which should also honor + // empty attribute names. + setting.start_sweep_angle = 0.0; + setting.end_sweep_angle = static_cast(3); + setting.uv_attribute_name = ""; + setting.normal_attribute_name = ""; + setting.semantic_label_attribute_name = ""; + auto mesh = lagrange::primitive::generate_sphere(setting); + primitive_test_utils::validate_primitive(mesh); + primitive_test_utils::check_degeneracy(mesh); + REQUIRE(!find_matching_attribute(mesh, AttributeUsage::UV).has_value()); + REQUIRE(!find_matching_attribute(mesh, AttributeUsage::Normal).has_value()); + REQUIRE(!mesh.has_attribute("")); + } + SECTION("fixed vs non-fixed UV") { setting.fixed_uv = false; diff --git a/modules/python/lagrange/scripts/__init__.py b/modules/python/lagrange/scripts/__init__.py new file mode 100644 index 00000000..1e9a77c1 --- /dev/null +++ b/modules/python/lagrange/scripts/__init__.py @@ -0,0 +1,11 @@ +# +# Copyright 2021 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. +# diff --git a/modules/python/lagrange/scripts/meshstat.py b/modules/python/lagrange/scripts/meshstat.py new file mode 100644 index 00000000..7eace675 --- /dev/null +++ b/modules/python/lagrange/scripts/meshstat.py @@ -0,0 +1,614 @@ +# +# 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. +# +"""Print basic information about a mesh file.""" + +from __future__ import annotations + +import argparse +import json +import logging +import pathlib +import platform + +import colorama # type: ignore +import lagrange +import numpy as np + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Display helpers (colorized output for human-readable summary) +# --------------------------------------------------------------------------- + + +def _colored(text: str, color: str) -> str: + return f"{color}{text}{colorama.Style.RESET_ALL}" + + +def print_header(message: str) -> None: + print(_colored(message, colorama.Fore.YELLOW + colorama.Style.BRIGHT)) + + +def print_section(message: str) -> None: + print(_colored(f"{message:_^55}", colorama.Fore.GREEN)) + + +def print_property(name: str, value, expected=None) -> None: + line = f"{name:-<48}: {value}" + if expected is not None and value != expected: + line = _colored(line, colorama.Fore.RED) + print(line) + + +def print_bad(message: str) -> None: + print(_colored(message, colorama.Fore.RED)) + + +# --------------------------------------------------------------------------- +# Stats and JSON helpers +# --------------------------------------------------------------------------- + + +def compute_stats(values: np.ndarray, percentiles=(1, 10, 25, 75, 90, 99)) -> dict: + """Return summary statistics for a 1-D numpy array, ignoring non-finite values.""" + arr = np.asarray(values).ravel() + finite = arr[np.isfinite(arr)] if arr.size else arr + if finite.size == 0: + return { + "count": int(arr.size), + "num_invalid": int(arr.size), + "min": None, + "max": None, + "mean": None, + "median": None, + "std": None, + "percentiles": {}, + } + return { + "count": int(arr.size), + "num_invalid": int(arr.size - finite.size), + "min": float(finite.min()), + "max": float(finite.max()), + "mean": float(finite.mean()), + "median": float(np.median(finite)), + "std": float(finite.std()), + "percentiles": {str(p): float(np.percentile(finite, p)) for p in percentiles}, + } + + +def to_jsonable(value): + """Convert numpy scalars/arrays/Path objects to JSON-serializable types.""" + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, np.generic): + return value.item() + if isinstance(value, pathlib.PurePath): + return str(value) + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + +def save_info(mesh_file: str, info: dict) -> pathlib.Path: + info_file = pathlib.Path(mesh_file).with_suffix(".json") + with open(info_file, "w") as fout: + json.dump(info, fout, indent=4, sort_keys=True, default=to_jsonable) + return info_file + + +# --------------------------------------------------------------------------- +# Basic and extended sections +# --------------------------------------------------------------------------- + + +def _facet_type(mesh) -> tuple[str, dict | None]: + if mesh.is_regular: + if mesh.vertex_per_facet == 3: + return "triangles", None + if mesh.vertex_per_facet == 4: + return "quads", None + return f"polygons ({mesh.vertex_per_facet})", None + + counts = {"two_gons": 0, "triangles": 0, "quads": 0, "polygons": 0} + for fid in range(mesh.num_facets): + f_size = mesh.get_facet_size(fid) + if f_size == 2: + counts["two_gons"] += 1 + elif f_size == 3: + counts["triangles"] += 1 + elif f_size == 4: + counts["quads"] += 1 + else: + counts["polygons"] += 1 + return "hybrid", counts + + +def collect_basic_info(mesh, info: dict) -> None: + """Populate ``info["basic"]`` with mesh shape/bbox metadata. + + Initializes mesh edges if needed so ``num_edges`` is reported correctly + (``SurfaceMesh.num_edges`` returns 0 until ``initialize_edges`` has been + called). + """ + if not mesh.has_edges: + mesh.initialize_edges() + facet_type, facet_counts = _facet_type(mesh) + + basic: dict = { + "dim": int(mesh.dimension), + "num_vertices": int(mesh.num_vertices), + "num_facets": int(mesh.num_facets), + "num_edges": int(mesh.num_edges), + "num_corners": int(mesh.num_corners), + "facet_type": facet_type, + } + if mesh.num_vertices > 0: + bbox_min = np.amin(mesh.vertices, axis=0) + bbox_max = np.amax(mesh.vertices, axis=0) + bbox_extent = bbox_max - bbox_min + basic["bbox_min"] = [float(x) for x in bbox_min] + basic["bbox_max"] = [float(x) for x in bbox_max] + basic["bbox_extent"] = [float(x) for x in bbox_extent] + basic["bbox_diagonal"] = float(np.linalg.norm(bbox_extent)) + basic["max_extent"] = float(np.max(bbox_extent)) if bbox_extent.size else 0.0 + else: + basic["bbox_min"] = None + basic["bbox_max"] = None + basic["bbox_extent"] = None + basic["bbox_diagonal"] = 0.0 + basic["max_extent"] = 0.0 + if facet_counts is not None: + basic["facet_counts"] = facet_counts + info["basic"] = basic + + +def print_basic_info(mesh, info: dict) -> None: + collect_basic_info(mesh, info) + basic = info["basic"] + print_section("Basic information") + print(f"dim: {basic['dim']}") + print( + f"#v: {basic['num_vertices']:<10}#f: {basic['num_facets']:<10}" + f"#e: {basic['num_edges']:<10}#c: {basic['num_corners']:<10}" + ) + if basic["bbox_min"] is None: + print("bbox: (empty mesh)") + elif basic["dim"] == 3: + bmin = basic["bbox_min"] + bmax = basic["bbox_max"] + print(f"bbox min: [{bmin[0]:>10.3f} {bmin[1]:>10.3f} {bmin[2]:>10.3f}]") + print(f"bbox max: [{bmax[0]:>10.3f} {bmax[1]:>10.3f} {bmax[2]:>10.3f}]") + elif basic["dim"] == 2: + bmin = basic["bbox_min"] + bmax = basic["bbox_max"] + print(f"bbox min: [{bmin[0]:>10.3f} {bmin[1]:>10.3f}]") + print(f"bbox max: [{bmax[0]:>10.3f} {bmax[1]:>10.3f}]") + else: + print_bad(f"Unsupported dimension: {basic['dim']}") + if basic["facet_type"] == "hybrid": + print_bad("facet type: hybrid") + counts = basic.get("facet_counts", {}) + if counts.get("two_gons", 0) > 0: + print_bad(f" # 2-gons: {counts['two_gons']}") + if counts.get("triangles", 0) > 0: + print(f" # triangles: {counts['triangles']}") + if counts.get("quads", 0) > 0: + print(f" # quads: {counts['quads']}") + if counts.get("polygons", 0) > 0: + print(f" # polygons (n>4): {counts['polygons']}") + else: + print(f"facet type: {basic['facet_type']}") + + +def collect_extended_info(mesh, info: dict) -> None: + """Populate ``info["extended"]`` with topology/manifoldness checks.""" + extended: dict = {} + + extended["num_components"] = int(lagrange.compute_components(mesh)) + + bd_edges = lagrange.extract_boundary_edges(mesh) + extended["num_boundary_edges"] = int(len(bd_edges)) + extended["closed"] = extended["num_boundary_edges"] == 0 + if not extended["closed"]: + extended["num_boundary_loops"] = int(len(lagrange.extract_boundary_loops(mesh))) + else: + extended["num_boundary_loops"] = 0 + + edge_manifold = bool(lagrange.is_edge_manifold(mesh)) + vertex_manifold = bool(lagrange.is_vertex_manifold(mesh)) + extended["edge_manifold"] = edge_manifold + extended["vertex_manifold"] = vertex_manifold + extended["manifold"] = edge_manifold and vertex_manifold + + if not vertex_manifold: + attr_id = lagrange.compute_vertex_is_manifold(mesh) + extended["nonmanifold_vertices"] = int(np.sum(mesh.attribute(attr_id).data == 0)) + else: + extended["nonmanifold_vertices"] = 0 + + if not edge_manifold: + attr_id = lagrange.compute_edge_is_manifold(mesh) + extended["nonmanifold_edges"] = int(np.sum(mesh.attribute(attr_id).data == 0)) + else: + extended["nonmanifold_edges"] = 0 + + is_orientable = bool(lagrange.is_oriented(mesh)) + extended["orientable"] = is_orientable + if not is_orientable: + attr_id = lagrange.compute_edge_is_oriented(mesh) + extended["nonoriented_edges"] = int(np.sum(mesh.attribute(attr_id).data == 0)) + else: + extended["nonoriented_edges"] = 0 + + extended["num_degenerate_facets"] = int(len(lagrange.detect_degenerate_facets(mesh))) + + valence_id = lagrange.compute_vertex_valence(mesh) + extended["num_isolated_vertices"] = int(np.sum(mesh.attribute(valence_id).data == 0)) + + if mesh.dimension == 3: + if mesh.is_triangle_mesh: + mesh_to_check = mesh + else: + mesh_to_check = mesh.clone() + lagrange.triangulate_polygonal_facets(mesh_to_check) + extended["num_intersecting_pairs"] = int( + len(lagrange.bvh.compute_intersecting_pairs(mesh_to_check)) + ) + + info["extended"] = extended + + +def print_extra_info(mesh, info: dict) -> None: + collect_extended_info(mesh, info) + extended = info["extended"] + print_property("num components", extended["num_components"]) + print_property("closed", extended["closed"], True) + if not extended["closed"]: + print_property("num boundary edges", extended["num_boundary_edges"], 0) + print_property("num boundary loops", extended["num_boundary_loops"], 0) + print_property("manifold", extended["manifold"], True) + if not extended["vertex_manifold"]: + print_property("non-manifold vertices", extended["nonmanifold_vertices"], 0) + else: + print_property("vertex manifold", True, True) + if not extended["edge_manifold"]: + print_property("non-manifold edges", extended["nonmanifold_edges"], 0) + else: + print_property("edge manifold", True, True) + if not extended["orientable"]: + print_property("non-oriented edges", extended["nonoriented_edges"], 0) + else: + print_property("orientable", True, True) + print_property("num degenerate facets", extended["num_degenerate_facets"], 0) + print_property("num isolated vertices", extended["num_isolated_vertices"], 0) + if "num_intersecting_pairs" in extended: + print_property("num intersecting pairs", extended["num_intersecting_pairs"], 0) + + +# --------------------------------------------------------------------------- +# Attributes section +# --------------------------------------------------------------------------- + + +def _usage_to_str(usage) -> str: + return str(usage).split(".")[-1] + + +def _element_to_str(element) -> str: + return str(element).split(".")[-1] + + +def _dtype_to_str(dtype) -> str: + try: + return str(np.dtype(dtype)) + except TypeError: + return str(dtype) + + +def collect_attributes(mesh, info: dict) -> None: + """Populate ``info["attributes"]`` with the list of user-visible attributes.""" + entries: list = [] + for attr_id in mesh.get_matching_attribute_ids(): + name = mesh.get_attribute_name(attr_id) + if name.startswith("@"): + continue + is_indexed = mesh.is_attribute_indexed(attr_id) + if is_indexed: + attr = mesh.indexed_attribute(attr_id) + dtype_str = _dtype_to_str(attr.values.dtype) + else: + attr = mesh.attribute(attr_id) + dtype_str = _dtype_to_str(attr.dtype) + entries.append( + { + "name": name, + "id": int(attr_id), + "usage": _usage_to_str(attr.usage), + "element": _element_to_str(attr.element_type), + "channels": int(attr.num_channels), + "dtype": dtype_str, + "indexed": bool(is_indexed), + } + ) + info["attributes"] = entries + + +def print_attributes(mesh, info: dict) -> None: + collect_attributes(mesh, info) + for entry in info["attributes"]: + name = entry["name"] + print(f"Attribute {colorama.Fore.GREEN}{name}{colorama.Style.RESET_ALL} ({entry['dtype']})") + print( + f" id:{entry['id']:<5}usage: {entry['usage']:<10}" + f"elem: {entry['element']:<10}channels: {entry['channels']}" + ) + + +# --------------------------------------------------------------------------- +# UV section +# --------------------------------------------------------------------------- + + +def _safe_attribute_name(name: str) -> str: + return "".join(ch if ch.isalnum() else "_" for ch in name) + + +def _normalize_uv_attribute(mesh, uv_attr_id: int) -> tuple[int, str]: + """Ensure the UV attribute is indexed and float64. Returns (id, name).""" + if not mesh.is_attribute_indexed(uv_attr_id): + uv_attr_id = lagrange.map_attribute_in_place( + mesh, uv_attr_id, lagrange.AttributeElement.Indexed + ) + if mesh.indexed_attribute(uv_attr_id).values.dtype != np.float64: + uv_attr_id = lagrange.cast_attribute(mesh, uv_attr_id, np.float64) + return int(uv_attr_id), mesh.get_attribute_name(uv_attr_id) + + +def collect_uv_info(mesh, info: dict, metrics: list) -> None: + """Populate ``info["uv"]`` with per-UV-attribute metrics (charts/flips/overlap/seams/distortion). + + Assumes ``mesh`` is already a triangle mesh. + """ + info.setdefault("uv", {}) + + uv_ids = list(mesh.get_matching_attribute_ids(usage=lagrange.AttributeUsage.UV)) + if not uv_ids: + logger.warning("No UV attributes on mesh; skipping UV section.") + return + + edge_lengths_id: int | None = None + max_extent = float(info.get("basic", {}).get("max_extent", 0.0)) + + for uv_attr_id in uv_ids: + original_name = mesh.get_attribute_name(uv_attr_id) + safe = _safe_attribute_name(original_name) + norm_id, compute_name = _normalize_uv_attribute(mesh, uv_attr_id) + + num_facets = int(mesh.num_facets) + entry: dict = {"num_facets_evaluated": num_facets} + + # Charts. Use `get_unique_attribute_name` so re-running on the same mesh + # (or two UV attributes whose names sanitize to the same `safe` string) + # does not collide with an existing attribute. + chart_attr_name = lagrange.get_unique_attribute_name( + mesh, f"@meshstat_{safe}_chart_id", emit_warning=False + ) + entry["num_charts"] = int( + lagrange.compute_uv_charts( + mesh, + uv_attribute_name=compute_name, + output_attribute_name=chart_attr_name, + ) + ) + + # Orientation (flipped + degenerate facets) + orient = lagrange.compute_uv_orientation(mesh, uv_attribute_name=compute_name) + entry["num_flipped_facets"] = int(orient.negative) + entry["num_degenerate_facets"] = int(orient.degenerate) + entry["fraction_flipped_facets"] = ( + float(orient.negative) / num_facets if num_facets > 0 else 0.0 + ) + + # Overlap + overlap_result = lagrange.bvh.compute_uv_overlap( + mesh, + uv_attribute_name=compute_name, + compute_overlap_area=True, + compute_overlapping_pairs=True, + compute_overlap_coloring=False, + ) + entry["overlap"] = { + "has_overlap": bool(overlap_result.has_overlap), + "overlap_area": float(overlap_result.overlap_area) + if overlap_result.overlap_area is not None + else 0.0, + "num_overlapping_pairs": int(len(overlap_result.overlapping_pairs)), + } + + # Seams (need 3D edge lengths once). Boundary edges are also counted as seams. + seam_attr_name = lagrange.get_unique_attribute_name( + mesh, f"@meshstat_{safe}_seam_edges", emit_warning=False + ) + seam_id = lagrange.compute_seam_edges( + mesh, + norm_id, + output_attribute_name=seam_attr_name, + include_boundary_edges=True, + ) + if edge_lengths_id is None: + edge_lengths_attr_name = lagrange.get_unique_attribute_name( + mesh, "@meshstat_edge_lengths", emit_warning=False + ) + edge_lengths_id = lagrange.compute_edge_lengths( + mesh, output_attribute_name=edge_lengths_attr_name + ) + seam_mask = np.asarray(mesh.attribute(seam_id).data) != 0 + edge_lengths = np.asarray(mesh.attribute(edge_lengths_id).data) + total_seam_length = float(edge_lengths[seam_mask].sum()) if seam_mask.any() else 0.0 + entry["seams"] = { + "num_seam_edges": int(seam_mask.sum()), + "total_length_3d": total_seam_length, + "relative_length_3d": (total_seam_length / max_extent if max_extent > 0 else None), + } + + # Distortion (per metric) + distortion: dict = {} + for metric in metrics: + metric_name = str(metric).split(".")[-1] + out_attr = lagrange.get_unique_attribute_name( + mesh, f"@meshstat_{safe}_uv_distortion_{metric_name}", emit_warning=False + ) + attr_id = lagrange.compute_uv_distortion(mesh, compute_name, out_attr, metric) + values = np.asarray(mesh.attribute(attr_id).data, dtype=np.float64) + distortion[metric_name] = compute_stats(values) + entry["distortion"] = distortion + + info["uv"][original_name] = entry + + +def print_uv_info(info: dict) -> None: + if not info.get("uv"): + return + print_section("UV information") + for name, entry in info["uv"].items(): + print_property(f"{name}: num charts", entry["num_charts"]) + print_property(f"{name}: num flipped facets", entry["num_flipped_facets"], 0) + print_property(f"{name}: num degenerate facets", entry["num_degenerate_facets"], 0) + overlap = entry["overlap"] + print_property(f"{name}: has overlap", overlap["has_overlap"], False) + print_property(f"{name}: overlap area", overlap["overlap_area"], 0.0) + seams = entry["seams"] + print_property(f"{name}: num seam edges", seams["num_seam_edges"]) + print_property(f"{name}: seam length (3D)", f"{seams['total_length_3d']:.6f}") + rel = seams["relative_length_3d"] + print_property( + f"{name}: seam length (relative)", + f"{rel:.6f}" if rel is not None else "n/a", + ) + for metric_name, stats in entry["distortion"].items(): + if stats["min"] is None: + line = " no finite values" + else: + line = ( + f" min={stats['min']:.4g} mean={stats['mean']:.4g} " + f"median={stats['median']:.4g} p99={stats['percentiles']['99']:.4g} " + f"max={stats['max']:.4g}" + ) + print(f"{name}: {metric_name} distortion") + print(line) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +_DISTORTION_METRIC_NAMES = [ + "Dirichlet", + "InverseDirichlet", + "SymmetricDirichlet", + "AreaRatio", + "MIPS", +] + + +def parse_args(argv: list | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Print basic information about a mesh file.") + parser.add_argument( + "--extended", + "-x", + action="store_true", + help="check for extended information such as number of components, manifoldness and more", + ) + parser.add_argument( + "--attribute", + "-a", + action="store_true", + help="print attribute information", + ) + parser.add_argument( + "--uv", + "-u", + action="store_true", + help="run the comprehensive UV statistics section", + ) + parser.add_argument( + "--uv-metric", + action="append", + choices=_DISTORTION_METRIC_NAMES, + help=( + "distortion metric(s) to evaluate when --uv is set " + "(repeatable; defaults to MIPS and SymmetricDirichlet)" + ), + ) + parser.add_argument( + "--export", + "-e", + action="store_true", + help="export stats into a .json file next to the input mesh", + ) + parser.add_argument( + "--stitched", + "-s", + action="store_true", + help=( + "weld coincident vertices on load (useful for glTF meshes where " + "chart borders are vertex-duplicated, which otherwise hides seams)" + ), + ) + parser.add_argument("input_mesh", help="input mesh file") + return parser.parse_args(argv) + + +def run(args: argparse.Namespace) -> int: + metric_names = args.uv_metric or ["MIPS", "SymmetricDirichlet"] + metrics = [getattr(lagrange.DistortionMetric, name) for name in metric_names] + + mesh = lagrange.io.load_mesh(args.input_mesh, quiet=True, stitch_vertices=args.stitched) + mesh.initialize_edges() + + info: dict = {"file": str(args.input_mesh)} + + header = f"Summary of {args.input_mesh}" + print_header(f"{header:=^55}") + print_basic_info(mesh, info) + + if args.extended: + print_extra_info(mesh, info) + + if args.attribute: + print_attributes(mesh, info) + + if args.uv: + if not mesh.is_triangle_mesh: + logger.warning("Triangulating in place for UV stats.") + if mesh.has_edges: + mesh.clear_edges() + lagrange.triangulate_polygonal_facets(mesh) + collect_uv_info(mesh, info, metrics) + print_uv_info(info) + + if args.export: + out = save_info(args.input_mesh, info) + print(f"Exported stats to {out}") + + return 0 + + +def main(argv: list | None = None) -> int: + if platform.system() == "Windows": + colorama.just_fix_windows_console() + args = parse_args(argv) + return run(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/modules/raycasting/examples/CMakeLists.txt b/modules/raycasting/examples/CMakeLists.txt index b006f530..3bef74d1 100644 --- a/modules/raycasting/examples/CMakeLists.txt +++ b/modules/raycasting/examples/CMakeLists.txt @@ -19,3 +19,6 @@ target_link_libraries(uv_image lagrange::raycasting lagrange::io lagrange::image lagrange_add_example(picking_demo picking_demo.cpp) target_link_libraries(picking_demo lagrange::raycasting lagrange::io lagrange::polyscope CLI11::CLI11) + +lagrange_add_example(remove_occluded remove_occluded.cpp) +target_link_libraries(remove_occluded lagrange::raycasting lagrange::io lagrange::polyscope CLI11::CLI11) diff --git a/modules/raycasting/examples/batch_cull_instances.py b/modules/raycasting/examples/batch_cull_instances.py new file mode 100644 index 00000000..fecd6617 --- /dev/null +++ b/modules/raycasting/examples/batch_cull_instances.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 + +# +# 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. +# +""" +Run instance-level occlusion culling on every asset in a folder. For each input scene the +fully-occluded instances are dropped and the result is saved to a mirror output directory. + +Uses the lagrange.raycasting.remove_occluded_instances Python binding directly — no CLI +subprocess, no analytics. Intended as a simple bulk-processing tool. + +Usage: + uv run python batch_cull_instances.py [--output-dir DIR] + [--num-rays N] [--batch-size N] [--until-converged] + [--extensions .obj .glb] [--output-extension .obj] [--recursive] +""" + +import argparse +import contextlib +import logging +import sys +import time +import traceback +from pathlib import Path + +import lagrange +import lagrange.io +import lagrange.raycasting + +# lagrange routes its C++ spdlog through Python's `logging` module (sink installed at module +# import). Without basicConfig(level=INFO) we'd only see WARNING+ — i.e., we'd miss every +# per-batch "X/Y instances visible" line and the "All instances visible, stopping early" +# notice. Configure root logging with a terse format so progress is actually visible. +logging.basicConfig( + level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S" +) + + +@contextlib.contextmanager +def _suppress_lagrange_below(level: int): + """Temporarily raise the `lagrange` logger threshold — useful around loader calls that + emit chatty warnings (e.g. the glTF stitch_vertices nag) without silencing the rest of + the run. Restores the previous level on exit.""" + lg = logging.getLogger("lagrange") + prev = lg.level + lg.setLevel(level) + try: + yield + finally: + lg.setLevel(prev) + + +def find_assets(input_dir: Path, extensions: list[str], recursive: bool) -> list[Path]: + assets: list[Path] = [] + for ext in extensions: + pattern = f"*{ext}" + assets.extend(input_dir.rglob(pattern) if recursive else input_dir.glob(pattern)) + return sorted(assets) + + +def process_asset( + asset: Path, + output: Path, + num_rays: int, + batch_size: int, + until_converged: bool, +) -> bool: + try: + with _suppress_lagrange_below(logging.ERROR): + scene = lagrange.io.load_simple_scene(str(asset)) + except Exception as exc: + print(f" load failed: {exc}", file=sys.stderr) + return False + + n_meshes_in = scene.num_meshes + n_instances_in = scene.total_num_instances + if n_instances_in == 0: + print(" empty scene; skipping") + return False + + try: + result = lagrange.raycasting.remove_occluded_instances( + scene, + num_rays=num_rays, + batch_size=batch_size, + until_converged=until_converged, + ) + except Exception: + traceback.print_exc() + return False + + n_meshes_out = result.num_meshes + n_instances_out = result.total_num_instances + print( + f" meshes: {n_meshes_in} → {n_meshes_out}, instances: {n_instances_in} → {n_instances_out}" + ) + + output.parent.mkdir(parents=True, exist_ok=True) + try: + lagrange.io.save_simple_scene(str(output), result) + except Exception as exc: + print(f" save failed: {exc}", file=sys.stderr) + return False + return True + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Bulk instance-level occlusion culling on a folder of assets." + ) + parser.add_argument("input_dir", type=Path, help="Directory containing input assets.") + + # Defaults mirror OccludedInstanceEstimateOptions in + # modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h. + parser.add_argument("--num-rays", type=int, default=1_600_000_000, help="Total rays per asset.") + parser.add_argument("--batch-size", type=int, default=20_000_000, help="Rays per batch.") + parser.add_argument( + "--until-converged", + action="store_true", + help="Stop early when a batch finds no new visible instances.", + ) + + parser.add_argument( + "--output-dir", + type=Path, + default=None, + help="Output directory (default: /culled).", + ) + parser.add_argument( + "--extensions", + nargs="+", + default=[".obj", ".glb", ".gltf", ".fbx"], + help="Input extensions to process.", + ) + parser.add_argument( + "--output-extension", + default=".obj", + help="Extension for output files (default: .obj).", + ) + parser.add_argument( + "--recursive", + action="store_true", + help="Recurse into subdirectories of input_dir.", + ) + + args = parser.parse_args() + + if not args.input_dir.is_dir(): + print(f"Input dir not found: {args.input_dir}", file=sys.stderr) + return 1 + + output_dir = args.output_dir or (args.input_dir / "culled") + output_dir.mkdir(parents=True, exist_ok=True) + + assets = find_assets(args.input_dir, args.extensions, args.recursive) + if not assets: + print("No assets matched.", file=sys.stderr) + return 1 + + print( + f"Found {len(assets)} asset(s). Output → {output_dir}. " + f"Rays/asset: {args.num_rays:,} (batch {args.batch_size:,})." + ) + + succeeded = 0 + total_start = time.time() + for i, asset in enumerate(assets, 1): + rel = asset.relative_to(args.input_dir) + out_path = output_dir / rel.with_suffix(args.output_extension) + print(f"[{i}/{len(assets)}] {rel}") + start = time.time() + ok = process_asset( + asset, + out_path, + args.num_rays, + args.batch_size, + args.until_converged, + ) + elapsed = time.time() - start + if ok: + succeeded += 1 + print(f" saved {out_path} ({elapsed:.1f}s)") + else: + print(f" FAILED ({elapsed:.1f}s)", file=sys.stderr) + + total_elapsed = time.time() - total_start + print( + f"\nDone: {succeeded}/{len(assets)} succeeded in {total_elapsed:.1f}s " + f"(avg {total_elapsed / max(len(assets), 1):.1f}s/asset)." + ) + return 0 if succeeded == len(assets) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/modules/raycasting/examples/remove_occluded.cpp b/modules/raycasting/examples/remove_occluded.cpp new file mode 100644 index 00000000..3dbb533d --- /dev/null +++ b/modules/raycasting/examples/remove_occluded.cpp @@ -0,0 +1,327 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +// clang-format off +#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; + +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; + int log_level = 2; + bool vis = false; + bool until_converged = false; + Mode mode = Mode::Meshes; +}; + +static std::atomic_bool g_cancel{false}; + +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) +{ + 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}; +} + +// --------------------------------------------------------------------------- +// Meshes (instance-level) mode +// --------------------------------------------------------------------------- + +void visualize_instances(const SceneType& scene, const InstanceSampler& sampler) +{ + polyscope::init(); + + auto* visible_group = polyscope::createGroup("Visible"); + auto* occluded_group = polyscope::createGroup("Occluded"); + + 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)); + } + } + + Index global = 0; + 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); + 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]}); + ps_mesh->addToGroup(*visible_group); + } + ++global; + } + } + + polyscope::show(); +} + +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::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); + 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; + lagrange::logger().info( + "Found {} occluded instances out of {}", + num_occluded, + sampler.num_instances()); + + if (args.vis) { + visualize_instances(scene, sampler); + } + + 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); + }); + lagrange::io::save_simple_scene(output, result); + + return 0; +} + +// --------------------------------------------------------------------------- +// Facets (facet-level) mode — either normal+adaptive cycles or brute-force +// --------------------------------------------------------------------------- + +void visualize_facets(const SceneType& scene, const FacetSampler& sampler) +{ + polyscope::init(); + + // Heat scale: normalize across all visible facets. + uint64_t min_rays = std::numeric_limits::max(); + uint64_t max_rays = 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)); + } + } + } + const float log_max = std::log1p(static_cast(max_rays - min_rays)); + + 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 = + "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); + } + + polyscope::show(); +} + +int run_facets_mode(const Args& args) +{ + lagrange::logger().info("Loading input scene: {}", args.input); + auto scene = lagrange::io::load_simple_scene(args.input); + + FacetSampler sampler(scene); + 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); + 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); + + if (args.vis) { + visualize_facets(scene, sampler); + } + + // One output mesh per input instance — see remove_occluded_facets(). + 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); }); + if (filtered.get_num_facets() == 0) continue; + auto instance = scene.get_instance(info.mesh_index, info.instance_index); + result.add_mesh(std::move(filtered)); + instance.mesh_index = result.get_num_meshes() - 1; + result.add_instance(std::move(instance)); + } + lagrange::io::save_simple_scene(output, result); + + return 0; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +int main(int argc, char** argv) +{ + Args args; + + CLI::App app{argv[0]}; + app.option_defaults()->always_capture_default(); + app.add_option("input", args.input, "Input scene or mesh.") + ->required() + ->check(CLI::ExistingFile); + app.add_option("output", args.output, "Output scene or mesh. Default: output.obj"); + app.add_option( + "--num-rays,-n", + args.num_rays, + "Total number of rays. 0 = progressive mode (Ctrl+C to stop)."); + 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)."); + 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."); + + std::string mode_str = "meshes"; + app.add_option("--mode,-m", mode_str, "Granularity: meshes | facets | facets_bruteforce.") + ->check(CLI::IsMember({"meshes", "facets", "facets_bruteforce"})); + + CLI11_PARSE(app, argc, argv); + + if (mode_str == "meshes") { + args.mode = Mode::Meshes; + } else if (mode_str == "facets") { + args.mode = Mode::Facets; + } else { + args.mode = Mode::FacetsBruteforce; + } + + lagrange::logger().set_level(static_cast(args.log_level)); + + if (args.mode == Mode::Meshes) { + return run_meshes_mode(args); + } + return run_facets_mode(args); +} diff --git a/modules/raycasting/include/lagrange/raycasting/RayCaster.h b/modules/raycasting/include/lagrange/raycasting/RayCaster.h index b1bcbf59..67ef2d78 100644 --- a/modules/raycasting/include/lagrange/raycasting/RayCaster.h +++ b/modules/raycasting/include/lagrange/raycasting/RayCaster.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,11 @@ namespace lagrange::raycasting { +namespace internal { +struct RayCasterOBBAccess; +} // namespace internal + + /// /// Shared base struct for ray and closest point hits. /// @@ -769,6 +775,37 @@ class RayCaster private: /// @cond LA_INTERNAL_DOCS + friend struct internal::RayCasterOBBAccess; + + /// + /// Oriented bounding box used by the raycaster's internal overlap query. Constructed and + /// consumed via the `internal::RayCasterOBBAccess` friend. + /// + struct OrientedBox + { + Eigen::Vector3f center = Eigen::Vector3f::Zero(); + Eigen::Matrix3f axes = Eigen::Matrix3f::Identity(); ///< Column i = i-th axis (unit). + Eigen::Vector3f half_extents = Eigen::Vector3f::Zero(); + }; + + void overlap_obb_internal( + const OrientedBox& obb, + function_ref + callback) const; + + /// + /// Packet variant of overlap_obb_internal: queries up to 16 OBBs in a single SIMD + /// rtcPointQuery16 dispatch. The callback receives the originating lane index in addition to + /// the hit mesh/instance/facet indices, so callers can route hits back to the OBB that + /// produced them. + /// + void overlap_obb16_internal( + span obbs, + std::variant active, + function_ref< + bool(uint32_t lane, uint32_t mesh_index, uint32_t instance_index, uint32_t facet_index)> + callback) const; + struct Impl; value_ptr m_impl; /// @endcond diff --git a/modules/raycasting/include/lagrange/raycasting/embree_closest_point.h b/modules/raycasting/include/lagrange/raycasting/embree_closest_point.h index 65bd2561..3b12c0af 100644 --- a/modules/raycasting/include/lagrange/raycasting/embree_closest_point.h +++ b/modules/raycasting/include/lagrange/raycasting/embree_closest_point.h @@ -1,5 +1,5 @@ /* - * Copyright 2025 Adobe. All rights reserved. + * Copyright 2020 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/raycasting/include/lagrange/raycasting/internal/RayCasterOBBAccess.h b/modules/raycasting/include/lagrange/raycasting/internal/RayCasterOBBAccess.h new file mode 100644 index 00000000..fbe5e813 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/internal/RayCasterOBBAccess.h @@ -0,0 +1,50 @@ +/* + * 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::raycasting::internal { + +/// Friend accessor to RayCaster's OBB overlap queries. Re-exposes the otherwise-private +/// `OrientedBox` type through a using-alias so callers can build OBB packets directly. +struct RayCasterOBBAccess +{ + using OrientedBox = RayCaster::OrientedBox; + + static void overlap_obb( + const RayCaster& rc, + const OrientedBox& obb, + lagrange::function_ref callback) + { + rc.overlap_obb_internal(obb, callback); + } + + /// Packet-of-16 OBB overlap query. The callback receives the originating lane index so hits + /// can be routed back to the OBB that produced them. + static void overlap_obb16( + const RayCaster& rc, + span obbs, + std::variant active, + lagrange::function_ref< + bool(uint32_t lane, uint32_t mesh_index, uint32_t instance_index, uint32_t facet_index)> + callback) + { + rc.overlap_obb16_internal(obbs, active, callback); + } +}; + +} // namespace lagrange::raycasting::internal diff --git a/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h b/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h new file mode 100644 index 00000000..7c43dc55 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h @@ -0,0 +1,216 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace lagrange::raycasting { + +/// +/// @addtogroup group-raycasting +/// @{ +/// + +/// +/// 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; +}; + +/// +/// Stateful algorithm for finding occluded facets across every instance of a scene. Each +/// instance is gated independently with its own world-space facets. +/// +/// 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. +/// +/// @note Only 3D scenes are supported. Meshes must be triangle meshes. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +class LA_RAYCASTING_API OccludedFacetSampler +{ +public: + /// + /// Maps a global facet index back to (mesh, instance, local facet). + /// + struct InstanceInfo + { + Index mesh_index; + Index instance_index; + /// Global index of this instance's first facet in the flat arrays (is_visible, etc.). + uint64_t facet_offset; + /// Number of facets in this instance's mesh. + Index num_facets; + }; + + /// + /// Build the ray caster and per-instance world-space facet data. + /// + /// @param[in] scene Scene to process. Every referenced mesh must be a triangle mesh. + /// @param[in] options Sampler options. + /// @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 OccludedFacetSampler( + const scene::SimpleScene& scene, + const OccludedFacetSamplerOptions& options = {}, + function_ref is_occluder = [](Index, Index) { + return true; + }); + + ~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); + + /// 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 has been marked visible so far. + bool is_visible(uint64_t global_facet_index) const; + + /// Rays cast so far for the facet. + 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; + + /// Per-instance metadata for mapping global facet indices to (mesh, instance, local facet). + span instances() const; + +private: + struct Impl; + value_ptr m_impl; +}; + +/// +/// Loop options for @ref estimate_occluded_facets() 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. + 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; +}; + +/// +/// 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. +/// +/// @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); + +/// +/// Options for remove_occluded_facets(). +/// +struct RemoveOccludedFacetsOptions +{ + /// Estimate-loop options forwarded to @ref estimate_occluded_facets(). + OccludedFacetEstimateOptions estimate_options = {}; + + /// Options forwarded to the underlying @ref OccludedFacetSampler. + OccludedFacetSamplerOptions sampler_options = {}; +}; + +/// +/// 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. +/// +/// @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] cancel Optional cancellation flag. +/// +/// @return The filtered scene. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +LA_RAYCASTING_API scene::SimpleScene remove_occluded_facets( + const scene::SimpleScene& scene, + const RemoveOccludedFacetsOptions& options, + ProgressCallback& progress, + function_ref is_occluder = + [](Index, Index) { return true; }, + const std::atomic_bool* cancel = nullptr); + +/// @} + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h b/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h new file mode 100644 index 00000000..e74e6ea4 --- /dev/null +++ b/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h @@ -0,0 +1,171 @@ +/* + * 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 + +namespace lagrange::raycasting { + +/// +/// @addtogroup group-raycasting +/// @{ +/// + +/// +/// 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. +/// +/// @note Only 3D scenes are supported. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +class LA_RAYCASTING_API OccludedInstanceSampler +{ +public: + /// + /// Build the ray caster and per-instance precomputation. + /// + /// @param[in] scene Scene to process. + /// @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, + function_ref is_occluder = [](Index, Index) { + return true; + }); + + ~OccludedInstanceSampler(); + OccludedInstanceSampler(OccludedInstanceSampler&&) noexcept; + OccludedInstanceSampler& operator=(OccludedInstanceSampler&&) noexcept; + + /// Run a batch distributing @p num_rays across instances not yet marked visible. + void run_batch(uint64_t num_rays); + + /// Whether the instance has been marked visible so far. + bool is_visible(Index global_index) const; + + /// Rays cast so far for the instance. + uint64_t num_rays_cast(Index global_index) const; + + /// Total number of instances. + Index num_instances() const; + +private: + struct Impl; + value_ptr m_impl; +}; + +/// +/// 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. + uint64_t num_rays = 1600000000ULL; + + /// Rays per batch. Cancellation, progress, and convergence are checked at batch boundaries. + uint64_t batch_size = 20000000ULL; + + /// Stop early when a batch finds no new visible instances. + 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. +/// +template +LA_RAYCASTING_API void estimate_occluded_instances( + OccludedInstanceSampler& sampler, + const OccludedInstanceEstimateOptions& options, + ProgressCallback& progress, + const std::atomic_bool* cancel = nullptr); + +/// +/// @overload +/// +/// Convenience wrapper that builds an @ref OccludedInstanceSampler internally and reports each +/// occluded instance via @p callback. +/// +/// @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] cancel Optional cancellation flag. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +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); + +/// +/// Remove fully-occluded mesh instances. Convenience wrapper around +/// @ref estimate_occluded_instances + @ref 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] 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 OccludedInstanceEstimateOptions& options, + ProgressCallback& progress, + function_ref is_occluder = + [](Index, Index) { return true; }, + const std::atomic_bool* cancel = nullptr); + +/// @} + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/python/src/raycasting.cpp b/modules/raycasting/python/src/raycasting.cpp index ec941b37..e3927028 100644 --- a/modules/raycasting/python/src/raycasting.cpp +++ b/modules/raycasting/python/src/raycasting.cpp @@ -21,8 +21,15 @@ #include #include #include +#include +#include #include +#include +#include +#include +#include +#include #include namespace nb = nanobind; @@ -729,6 +736,171 @@ The local feature size is stored as a per-vertex attribute on the mesh. :param ray_caster: Optional pre-built :class:`RayCaster` for caching. :return: Attribute id of the newly added LFS attribute. :rtype: int)"); + + // ========================================================================= + // Occluded-facet / occluded-instance samplers + // ========================================================================= + + using SimpleScene3D = scene::SimpleScene; + using IsOccluderFn = std::function; + constexpr raycasting::OccludedFacetEstimateOptions facet_defaults{}; + constexpr raycasting::OccludedFacetSamplerOptions facet_sampler_defaults{}; + constexpr raycasting::OccludedInstanceEstimateOptions instance_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, + 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); + ProgressCallback progress; + if (is_occluder) { + return raycasting::remove_occluded_facets( + scene, + options, + progress, + *is_occluder); + } + return raycasting::remove_occluded_facets(scene, options, progress); + }, + "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, + "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. + +: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 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 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 to None (every instance is an + occluder). +:return: Scene with occluded facets removed.)"); + + m.def( + "remove_occluded_instances", + [](const SimpleScene3D& scene, + uint64_t num_rays, + uint64_t batch_size, + bool until_converged, + std::optional is_occluder) { + raycasting::OccludedInstanceEstimateOptions options; + options.num_rays = num_rays; + options.batch_size = batch_size; + options.until_converged = until_converged; + 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); + }, + "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, + "is_occluder"_a = nb::none(), + R"(Remove fully-occluded mesh instances from a scene. + +: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`` returning + whether an instance should block rays. Non-occluders are still tested + for visibility but do not contribute to the ray-caster scene. Defaults + to None (every instance is an occluder). + +:return: Scene with occluded instances removed.)"); + + m.def( + "estimate_occluded_instances", + [](const SimpleScene3D& scene, + uint64_t num_rays, + uint64_t batch_size, + bool until_converged, + std::optional is_occluder) { + raycasting::OccludedInstanceEstimateOptions options; + options.num_rays = num_rays; + options.batch_size = batch_size; + options.until_converged = until_converged; + ProgressCallback progress; + 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); + } + return occluded; + }, + "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, + "is_occluder"_a = nb::none(), + R"(Find mesh instances that are fully occluded by other geometry. + +: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 + :py:func:`remove_occluded_instances`. + +:return: List of ``(mesh_index, instance_index)`` pairs for occluded instances.)"); } } // namespace lagrange::python diff --git a/modules/raycasting/python/tests/conftest.py b/modules/raycasting/python/tests/conftest.py new file mode 100644 index 00000000..fac0fad1 --- /dev/null +++ b/modules/raycasting/python/tests/conftest.py @@ -0,0 +1,67 @@ +# +# 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 + + +def _centered_cube(size): + """Cube of given side length centered at origin, outward-winding triangles.""" + s = size / 2 + vertices = np.array( + [ + [-s, -s, -s], + [+s, -s, -s], + [+s, +s, -s], + [-s, +s, -s], + [-s, -s, +s], + [+s, -s, +s], + [+s, +s, +s], + [-s, +s, +s], + ], + dtype=float, + ) + facets = np.array( + [ + [0, 3, 2], + [2, 1, 0], # z = -s + [4, 5, 6], + [6, 7, 4], # z = +s + [1, 2, 6], + [6, 5, 1], # x = +s + [4, 7, 3], + [3, 0, 4], # x = -s + [2, 3, 7], + [7, 6, 2], # y = +s + [0, 1, 5], + [5, 4, 0], # y = -s + ], + dtype=np.uint32, + ) + mesh = lagrange.SurfaceMesh() + mesh.vertices = vertices + mesh.facets = facets + return mesh + + +@pytest.fixture(params=[(2.0, 0.5)]) +def nested_cubes_scene(request): + """Outer cube fully enclosing an inner cube, both centered at origin.""" + outer_size, inner_size = request.param + scene = lagrange.scene.SimpleScene3D() + outer_id = scene.add_mesh(_centered_cube(outer_size)) + inner_id = scene.add_mesh(_centered_cube(inner_size)) + for mi in (outer_id, inner_id): + instance = lagrange.scene.MeshInstance3D() + instance.mesh_index = mi + scene.add_instance(instance) + return scene diff --git a/modules/raycasting/python/tests/test_remove_occluded.py b/modules/raycasting/python/tests/test_remove_occluded.py new file mode 100644 index 00000000..ed564b54 --- /dev/null +++ b/modules/raycasting/python/tests/test_remove_occluded.py @@ -0,0 +1,143 @@ +# +# 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 TestRemoveOccluded: + def test_remove_occluded_facets_single_cube(self, cube_triangular): + """A single cube has no occluded facets — all 12 should remain.""" + scene = lagrange.scene.mesh_to_simple_scene(cube_triangular) + result = lagrange.raycasting.remove_occluded_facets(scene, num_rays=100000) + 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.""" + scene = lagrange.scene.mesh_to_simple_scene(cube_triangular) + result = lagrange.raycasting.remove_occluded_facets( + scene, num_rays=100000, brute_force=True + ) + assert result.get_mesh(0).num_facets == 12 + + def test_remove_occluded_instances_single_cube(self, cube_triangular): + """A lone instance is not occluded — should remain.""" + scene = lagrange.scene.mesh_to_simple_scene(cube_triangular) + result = lagrange.raycasting.remove_occluded_instances(scene, num_rays=100000) + assert result.num_meshes == 1 + assert result.total_num_instances == 1 + + def test_estimate_occluded_instances_single_cube(self, cube_triangular): + """A lone instance is not occluded — list should be empty.""" + scene = lagrange.scene.mesh_to_simple_scene(cube_triangular) + occluded = lagrange.raycasting.estimate_occluded_instances(scene, num_rays=100000) + assert occluded == [] + + def test_keyword_only_arguments(self, cube_triangular): + """Optional arguments must be keyword-only.""" + scene = lagrange.scene.mesh_to_simple_scene(cube_triangular) + lagrange.raycasting.remove_occluded_facets(scene, num_rays=10000) + with pytest.raises(TypeError): + lagrange.raycasting.remove_occluded_facets(scene, 10000) + + +# --------------------------------------------------------------------------- +# Nested cubes — minimal occlusion sanity test. +# Outer cube fully encloses inner cube. Inner instance must be culled, +# inner facets must all disappear after facet-level filtering. +# --------------------------------------------------------------------------- + + +class TestNestedCubes: + 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( + nested_cubes_scene, num_rays=200_000, batch_size=20_000 + ) + # Mesh 1 (inner cube), instance 0 — that's the only occluded one. + assert occluded == [(1, 0)] + + def test_remove_occluded_instances(self, nested_cubes_scene): + """remove_occluded_instances drops the inner instance, keeps the outer.""" + result = lagrange.raycasting.remove_occluded_instances( + nested_cubes_scene, num_rays=200_000, batch_size=20_000 + ) + assert result.num_meshes == 1 + assert result.total_num_instances == 1 + # The surviving mesh is the outer cube (extent 2 → coords reach ±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(self, nested_cubes_scene): + """All facets of the inner cube are occluded → inner mesh dropped entirely.""" + result = lagrange.raycasting.remove_occluded_facets( + nested_cubes_scene, num_rays=2_000_000, batch_size=200_000 + ) + # Outer cube survives with all 12 facets; inner cube's mesh is dropped + # because every one of its facets failed to escape. + 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) + + +# --------------------------------------------------------------------------- +# Non-occluder instance flag — same nested-cube setup, but mark the OUTER cube +# as a non-occluder. Rays from the inner cube now pass through the outer cube +# unobstructed and escape to infinity, so the inner cube is no longer culled. +# --------------------------------------------------------------------------- + + +class TestNonOccluderInstances: + def test_default_outer_occludes_inner(self, nested_cubes_scene): + """Baseline: with default occluder semantics, inner cube is culled.""" + occluded = lagrange.raycasting.estimate_occluded_instances( + nested_cubes_scene, num_rays=200_000, batch_size=20_000 + ) + assert occluded == [(1, 0)] # inner cube only + + def test_outer_marked_non_occluder_keeps_inner(self, nested_cubes_scene): + """Marking the outer cube as a non-occluder via the `is_occluder` predicate lets + the inner cube's rays escape; no instance should be reported as occluded.""" + occluded = lagrange.raycasting.estimate_occluded_instances( + nested_cubes_scene, + num_rays=200_000, + batch_size=20_000, + is_occluder=lambda mi, ii: (mi, ii) != (0, 0), # outer cube → non-occluder + ) + assert occluded == [] + + def test_remove_outer_marked_non_occluder_keeps_both(self, nested_cubes_scene): + """Same but via remove_occluded_instances: both instances survive.""" + result = lagrange.raycasting.remove_occluded_instances( + nested_cubes_scene, + num_rays=200_000, + batch_size=20_000, + is_occluder=lambda mi, ii: (mi, ii) != (0, 0), + ) + assert result.num_meshes == 2 + assert result.total_num_instances == 2 + + def test_is_occluder_invocation_count(self, nested_cubes_scene): + """is_occluder should be called exactly once per instance during sampler ctor.""" + calls: list[tuple[int, int]] = [] + + def predicate(mi: int, ii: int) -> bool: + calls.append((mi, ii)) + return True + + lagrange.raycasting.estimate_occluded_instances( + nested_cubes_scene, num_rays=10_000, batch_size=5_000, is_occluder=predicate + ) + assert sorted(calls) == [(0, 0), (1, 0)] diff --git a/modules/raycasting/src/RayCaster.cpp b/modules/raycasting/src/RayCaster.cpp index fe46b49c..efde716b 100644 --- a/modules/raycasting/src/RayCaster.cpp +++ b/modules/raycasting/src/RayCaster.cpp @@ -338,6 +338,121 @@ bool embree_closest_point_callback(RTCPointQueryFunctionArguments* args) return false; } +// ============================================================================ +// OBB overlap helpers +// ============================================================================ + +/// Standard 15-axis separating axis test for OBB vs AABB overlap. +/// Reference: Real-Time Collision Detection, Ch. 4.4.1. +bool obb_overlaps_aabb( + const Eigen::Vector3f& obb_center, + const Eigen::Matrix3f& obb_axes, + const Eigen::Vector3f& obb_half_extents, + const Eigen::Vector3f& aabb_min, + const Eigen::Vector3f& aabb_max) +{ + Eigen::Vector3f aabb_center = (aabb_min + aabb_max) * 0.5f; + Eigen::Vector3f aabb_half = (aabb_max - aabb_min) * 0.5f; + Eigen::Vector3f t = obb_center - aabb_center; + + constexpr float eps = 1e-6f; + Eigen::Matrix3f abs_axes = obb_axes.cwiseAbs().array() + eps; + + // 3 AABB face normals (world axes) + for (int i = 0; i < 3; ++i) { + float ra = aabb_half[i]; + float rb = obb_half_extents.dot(abs_axes.row(i).transpose()); + if (std::abs(t[i]) > ra + rb) return false; + } + + // 3 OBB face normals + for (int i = 0; i < 3; ++i) { + float ra = aabb_half.dot(abs_axes.col(i)); + float rb = obb_half_extents[i]; + if (std::abs(t.dot(obb_axes.col(i))) > ra + rb) return false; + } + + // 9 edge-edge cross products: AABB axis i x OBB axis j + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + int i1 = (i + 1) % 3, i2 = (i + 2) % 3; + int j1 = (j + 1) % 3, j2 = (j + 2) % 3; + float ra = aabb_half[i1] * abs_axes(i2, j) + aabb_half[i2] * abs_axes(i1, j); + float rb = + obb_half_extents[j1] * abs_axes(i, j2) + obb_half_extents[j2] * abs_axes(i, j1); + float proj = t[i2] * obb_axes(i1, j) - t[i1] * obb_axes(i2, j); + if (std::abs(proj) > ra + rb) return false; + } + } + + return true; +} + +struct OBBOverlapUserData +{ + Eigen::Vector3f obb_center; + Eigen::Matrix3f obb_axes; + Eigen::Vector3f obb_half_extents; + const SimpleScene32f* scene = nullptr; + const std::vector* instance_indices = nullptr; + std::function callback; + bool stopped = false; +}; + +bool embree_obb_overlap_callback(RTCPointQueryFunctionArguments* args) +{ + auto* data = reinterpret_cast(args->userPtr); + if (data->stopped) return false; + + const unsigned int prim_id = args->primID; + RTCPointQueryContext* context = args->context; + la_debug_assert(context->instStackSize > 0); + const unsigned int stack_ptr = context->instStackSize - 1; + unsigned int inst_geom_id = context->instID[stack_ptr]; + + auto& indices = (*data->instance_indices)[inst_geom_id]; + uint32_t mesh_idx = indices.mesh_index; + + const auto& mesh = data->scene->get_mesh(mesh_idx); + auto facets_data = mesh.get_corner_to_vertex().get_all(); + auto positions_data = mesh.get_vertex_to_position().get_all(); + + uint32_t i0 = facets_data[prim_id * 3 + 0]; + uint32_t i1 = facets_data[prim_id * 3 + 1]; + uint32_t i2 = facets_data[prim_id * 3 + 2]; + + Eigen::Vector3f v0( + positions_data[i0 * 3], + positions_data[i0 * 3 + 1], + positions_data[i0 * 3 + 2]); + Eigen::Vector3f v1( + positions_data[i1 * 3], + positions_data[i1 * 3 + 1], + positions_data[i1 * 3 + 2]); + Eigen::Vector3f v2( + positions_data[i2 * 3], + positions_data[i2 * 3 + 1], + positions_data[i2 * 3 + 2]); + + // Compute triangle AABB + Eigen::Vector3f tri_min = v0.cwiseMin(v1).cwiseMin(v2); + Eigen::Vector3f tri_max = v0.cwiseMax(v1).cwiseMax(v2); + + if (obb_overlaps_aabb( + data->obb_center, + data->obb_axes, + data->obb_half_extents, + tri_min, + tri_max)) { + bool keep_going = data->callback(mesh_idx, indices.instance_index, prim_id); + if (!keep_going) { + data->stopped = true; + } + } + + return false; // Never shrink the query radius +} + // ============================================================================ // Impl // ============================================================================ @@ -1532,6 +1647,122 @@ uint32_t RayCaster::occluded16( return occludedN<16>(*m_impl, origins, directions, active, tmin, tmax); } +// ============================================================================ +// OBB overlap query +// ============================================================================ + +void RayCaster::overlap_obb_internal( + const OrientedBox& obb, + function_ref callback) const +{ + m_impl->check_no_pending_updates(); + + la_runtime_assert( + m_impl->m_instance_indices.size() == 1, + "OBB overlap only supports raycasters with a single instance."); + la_runtime_assert( + m_impl->m_instance_indices[0].mesh_index == 0, + "OBB overlap only supports raycasters containing a single mesh."); + la_runtime_assert( + m_impl->m_instance_indices[0].instance_index == 0, + "OBB overlap only supports raycasters with an identity instance."); + + float sphere_radius = obb.half_extents.norm(); + + RTCPointQuery query; + query.x = obb.center.x(); + query.y = obb.center.y(); + query.z = obb.center.z(); + query.radius = sphere_radius; + query.time = 0.f; + + OBBOverlapUserData data; + data.obb_center = obb.center; + data.obb_axes = obb.axes; + data.obb_half_extents = obb.half_extents; + data.scene = &m_impl->m_scene; + data.instance_indices = &m_impl->m_instance_indices; + data.callback = callback; + + RTCPointQueryContext context; + rtcInitPointQueryContext(&context); + rtcPointQuery( + m_impl->m_world_scene, + &query, + &context, + &embree_obb_overlap_callback, + reinterpret_cast(&data)); + check_errors_debug(m_impl->m_device); +} + +void RayCaster::overlap_obb16_internal( + span obbs, + std::variant active, + function_ref< + bool(uint32_t lane, uint32_t mesh_index, uint32_t instance_index, uint32_t facet_index)> + callback) const +{ + m_impl->check_no_pending_updates(); + la_runtime_assert(obbs.size() <= 16, "overlap_obb16 packet size must be <= 16."); + + la_runtime_assert( + m_impl->m_instance_indices.size() == 1, + "OBB overlap only supports raycasters with a single instance."); + la_runtime_assert( + m_impl->m_instance_indices[0].mesh_index == 0, + "OBB overlap only supports raycasters containing a single mesh."); + la_runtime_assert( + m_impl->m_instance_indices[0].instance_index == 0, + "OBB overlap only supports raycasters with an identity instance."); + + constexpr size_t N = 16; + alignas(64) std::array active_mask; + resolve_active_mask(N)>(active, active_mask); + + // Lanes past the end of `obbs` cannot be active even if the caller's mask says so. + for (size_t i = obbs.size(); i < N; ++i) { + active_mask[i] = 0; + } + + std::array lane_data; + std::array user_ptrs; + + RTCPointQuery16 query{}; + for (size_t i = 0; i < N; ++i) { + if (active_mask[i] != 0) { + const auto& obb = obbs[i]; + query.x[i] = obb.center.x(); + query.y[i] = obb.center.y(); + query.z[i] = obb.center.z(); + query.radius[i] = obb.half_extents.norm(); + + lane_data[i].obb_center = obb.center; + lane_data[i].obb_axes = obb.axes; + lane_data[i].obb_half_extents = obb.half_extents; + lane_data[i].scene = &m_impl->m_scene; + lane_data[i].instance_indices = &m_impl->m_instance_indices; + const uint32_t lane = static_cast(i); + lane_data[i].callback = [&callback, lane](uint32_t m, uint32_t inst, uint32_t f) { + return callback(lane, m, inst, f); + }; + user_ptrs[i] = &lane_data[i]; + } else { + user_ptrs[i] = nullptr; + } + } + + RTCPointQueryContext context; + rtcInitPointQueryContext(&context); + rtcPointQuery16( + active_mask.data(), + m_impl->m_world_scene, + &query, + &context, + &embree_obb_overlap_callback, + user_ptrs.data()); + check_errors_debug(m_impl->m_device); +} + // ============================================================================ // Explicit template instantiation // ============================================================================ diff --git a/modules/raycasting/src/compute_local_feature_size.cpp b/modules/raycasting/src/compute_local_feature_size.cpp index a0848f09..cf1b36d3 100644 --- a/modules/raycasting/src/compute_local_feature_size.cpp +++ b/modules/raycasting/src/compute_local_feature_size.cpp @@ -127,13 +127,13 @@ AttributeId compute_local_feature_size( const Index num_vertices = mesh.get_num_vertices(); // Create output attribute - AttributeId lfs_id = internal::find_or_create_attribute( + AttributeId lfs_id = ::lagrange::internal::find_or_create_attribute( mesh, options.output_attribute_name, Vertex, AttributeUsage::Scalar, 1, - internal::ResetToDefault::Yes); + ::lagrange::internal::ResetToDefault::Yes); if (num_vertices == 0) { // Nothing to do for empty mesh diff --git a/modules/raycasting/src/occluded_sampler_common.h b/modules/raycasting/src/occluded_sampler_common.h new file mode 100644 index 00000000..7202abb0 --- /dev/null +++ b/modules/raycasting/src/occluded_sampler_common.h @@ -0,0 +1,286 @@ +/* + * 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 +#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, +}; + +// See https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/ +template +Eigen::Array4 generate_vec4(const size_t i) +{ + constexpr double phi_1 = 1.1673039782614186842560459; + constexpr double phi_2 = phi_1 * phi_1; + constexpr double phi_3 = phi_2 * phi_1; + constexpr double phi_4 = phi_2 * phi_2; + // +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(); +} + +template +struct RaySampler +{ + struct Sample + { + Eigen::RowVector2 bary; + Eigen::RowVector3 direction; + }; + + explicit RaySampler(const Eigen::RowVector3& normal) + : m_normal(normal) + { + Eigen::Vector3 x_col, y_col; + lagrange::orthogonal_frame(Eigen::Vector3(normal.transpose()), x_col, y_col); + m_x_axis = x_col.transpose(); + m_y_axis = y_col.transpose(); + } + + // Atomic member blocks the implicit move ctor; load explicitly so RaySampler can live in + // a std::vector. + RaySampler(RaySampler&& other) noexcept + : m_normal(other.m_normal) + , m_x_axis(other.m_x_axis) + , m_y_axis(other.m_y_axis) + , m_sample_index(other.m_sample_index.load(std::memory_order_relaxed)) + {} + RaySampler(const RaySampler&) = delete; + RaySampler& operator=(const RaySampler&) = delete; + RaySampler& operator=(RaySampler&&) = delete; + + const Eigen::RowVector3& get_normal() const { return m_normal; } + + /// 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()() + { + const auto vec4 = + generate_vec4(m_sample_index.fetch_add(1, std::memory_order_relaxed)); + const Scalar phi = 2 * static_cast(lagrange::internal::pi) * vec4[0]; + const Scalar r2 = vec4[1]; + const Scalar r = std::sqrt(r2); + const Scalar x = r * std::cos(phi); + const Scalar y = r * std::sin(phi); + const Scalar z = std::sqrt(Scalar(1) - r2); + Sample result; + result.direction = x * m_x_axis + y * m_y_axis + z * m_normal; + result.bary = vec4.template tail<2>(); + if (result.bary[0] + result.bary[1] > 1) { + // Intentional: direction flip pairs with the bary fold for bi-hemisphere coverage. + result.direction = -result.direction; + result.bary = Eigen::RowVector2::Ones() - result.bary; + } + return result; + } + + Sample jitter(Scalar jitter_sigma) + { + 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 Scalar phi = 2 * static_cast(lagrange::internal::pi) * vec4[1]; + Sample result; + result.direction = r * (std::cos(phi) * m_x_axis + std::sin(phi) * m_y_axis); + 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; + } + return result; + } + +private: + const Eigen::RowVector3 m_normal; + Eigen::RowVector3 m_x_axis; + Eigen::RowVector3 m_y_axis; + 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) +{ + 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(); +} + +/// 16-ray SIMD packet for `RayCaster::occluded16`. `push` widens to float on entry. +struct RayPacket16 +{ + static constexpr size_t capacity = 16; + + RayCaster::Point16f origins; + RayCaster::Direction16f directions; + RayCaster::Float16 tmins; + size_t count = 0; + + template + void push(const Origin& origin, const Direction& direction, float tmin = 1e-6f) + { + origins.row(count) = origin.template cast(); + directions.row(count) = direction.template cast(); + tmins[count] = tmin; + ++count; + } + + bool full() const { return count == capacity; } + bool empty() const { return count == 0; } + void clear() { count = 0; } + + uint32_t cast(const RayCaster& caster) const + { + return caster.occluded16(origins, directions, count, tmins); + } + + /// Low `count` bits set — masks off undefined slots in a partial packet's cast mask. + uint32_t occupied_mask() const { return (uint32_t{1} << count) - 1; } +}; + +/// Per-instance precomputation shared by both samplers. +template +struct InstanceData +{ + Index mesh_index; + Eigen::Transform transform; + std::vector facet_areas; // world-space + std::vector> ray_samplers; // world-space normals +}; + +/// Per-instance data for OccludedFacetSampler; gates each facet individually. +template +struct FacetInstanceData : InstanceData +{ + /// Per-facet ray-distribution weight = cbrt(area). cbrt softens per-facet because each + /// facet is its own Bernoulli trial. + 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; +}; + +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()); + return std::accumulate(r.begin(), r.end(), Scalar(0), [&](Scalar s, size_t i) { + return s + derived().instance_active_weight(i); + }); + } + + void run_batch(uint64_t num_rays) + { + const Scalar total = compute_total_active_weight(); + if (total <= 0) return; + + const auto t_start = std::chrono::steady_clock::now(); + + for (auto i : lagrange::range(num_instances())) { + const Scalar inst_weight = derived().instance_active_weight(i); + if (inst_weight <= 0) continue; + + const uint64_t inst_rays = static_cast( + 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().end_batch(); + + const auto t_end = std::chrono::steady_clock::now(); + const auto batch_ms = + std::chrono::duration_cast(t_end - t_start).count(); + lagrange::logger().info("Batch: {} ms", batch_ms); + } +}; + +/// Sum `is_visible(i)` and `num_rays_cast(i)` over `i in [0, count)`. +template +std::pair sum_progress(const Sampler& sampler, Index count) +{ + uint64_t num_visible = 0; + uint64_t total_rays = 0; + for (Index i = 0; i < count; ++i) { + if (sampler.is_visible(i)) ++num_visible; + total_rays += sampler.num_rays_cast(i); + } + return {num_visible, total_rays}; +} + +} // namespace lagrange::raycasting::detail diff --git a/modules/raycasting/src/project_directional.cpp b/modules/raycasting/src/project_directional.cpp index 17395e56..5414be51 100644 --- a/modules/raycasting/src/project_directional.cpp +++ b/modules/raycasting/src/project_directional.cpp @@ -85,13 +85,13 @@ void project_directional( uniform_dir = arg.normalized(); } else if constexpr (std::is_same_v) { direction_attr_id = arg; - auto res = internal::check_attribute( + auto res = ::lagrange::internal::check_attribute( target, direction_attr_id, AttributeElement::Vertex, AttributeUsage::Normal, 3, - internal::ShouldBeWritable::No); + ::lagrange::internal::ShouldBeWritable::No); if (!res.success) { throw Error(format("Invalid direction attribute: {}", res.msg)); } diff --git a/modules/raycasting/src/remove_occluded_facets.cpp b/modules/raycasting/src/remove_occluded_facets.cpp new file mode 100644 index 00000000..64c74565 --- /dev/null +++ b/modules/raycasting/src/remove_occluded_facets.cpp @@ -0,0 +1,516 @@ +/* + * 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 "occluded_sampler_common.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace lagrange::raycasting { + +using namespace detail; + +template +struct OccludedFacetSampler::Impl + : ImplBase::Impl, Scalar, Index> +{ + 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 + { + Index neighbor; + Eigen::Vector3 direction; + }; + + explicit Impl(uint64_t total_facets) + : m_num_escaped_rays_per_facet(total_facets) + , m_staging_escaped_rays_per_facet(total_facets) + , m_num_rays_cast(total_facets) + {} + + 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; + std::vector m_num_rays_cast; + Scalar m_jitter_sigma; + uint8_t m_visibility_threshold; + SamplingMode m_current_mode = SamplingMode::Normal; + /// 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; + + 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) + { + 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 uint64_t extras = instance_rays > N ? instance_rays - N : 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)) { + cumsum += inst.facet_weights[active[k]] * rays_per_weight; + const uint64_t target = static_cast(std::llround(cumsum)); + budgets[k] = 1 + (target - allocated); + allocated = target; + chunk_rays += budgets[k]; + if (chunk_rays >= target_rays_per_chunk) { + chunk_ends.push_back(k + 1); + chunk_rays = 0; + } + } + if (chunk_ends.empty() || chunk_ends.back() < N) chunk_ends.push_back(N); + } + + // 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()); + std::sort(sorted.begin(), sorted.end()); + return std::adjacent_find(sorted.begin(), sorted.end()) == sorted.end(); + }(), + "active_local_facets must contain unique indices"); + + 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 + + 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 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(); + } + } + 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; + + 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; + } + 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) { + 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); + + slot_k[packet.count] = k; // count escapes against the facet under test + packet.push(origin, dir); + ++rays_done; + + if (packet.full()) flush(); + } + m_num_rays_cast[global_f] += rays_done; + } + flush(); + }); + } + + void end_batch() + { + 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; + } + + 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; + }), + inst.active_local_facets.end()); + m_instance_active_weights[i] = std::accumulate( + inst.active_local_facets.begin(), + inst.active_local_facets.end(), + Scalar(0), + [&](Scalar s, Index lf) { return s + inst.facet_weights[lf]; }); + } + } +}; + +template +OccludedFacetSampler::OccludedFacetSampler( + const scene::SimpleScene& scene, + const OccludedFacetSamplerOptions& options, + function_ref is_occluder) +{ + la_runtime_assert( + options.visibility_threshold >= 1, + "OccludedFacetSamplerOptions::visibility_threshold must be >= 1"); + la_runtime_assert( + options.jitter_sigma >= 0, + "OccludedFacetSamplerOptions::jitter_sigma must be non-negative"); + la_runtime_assert(scene.compute_num_instances() > 0, "scene has no instances"); + + std::vector infos; + infos.reserve(scene.compute_num_instances()); + uint64_t total_facets = 0; + for (auto mi : lagrange::range(scene.get_num_meshes())) { + const auto& mesh = scene.get_mesh(mi); + la_runtime_assert(mesh.is_triangle_mesh(), "OccludedFacetSampler requires triangle meshes"); + const Index nf = mesh.get_num_facets(); + for (auto ii : lagrange::range(scene.get_num_instances(mi))) { + infos.push_back({mi, ii, total_facets, nf}); + total_facets += nf; + } + } + la_runtime_assert(total_facets > 0, "scene contains no facets"); + + m_impl = make_value_ptr(total_facets); + m_impl->m_scene = scene; + 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_instances.resize(m_impl->m_instance_infos.size()); + m_impl->m_instance_active_weights.resize(m_impl->m_instance_infos.size()); + + 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); + 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); + + 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) { + 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_instance_active_weights[i] = total_weight; + } + + // 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)); + m_impl->m_ray_caster.commit_updates(); +} + +template +OccludedFacetSampler::~OccludedFacetSampler() = default; + +template +OccludedFacetSampler::OccludedFacetSampler(OccludedFacetSampler&&) noexcept = + default; + +template +OccludedFacetSampler& OccludedFacetSampler::operator=( + OccludedFacetSampler&&) noexcept = default; + +template +void OccludedFacetSampler::run_normal_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) +{ + m_impl->m_current_mode = SamplingMode::BruteForce; + m_impl->run_batch(num_rays); +} + +template +bool OccludedFacetSampler::is_visible(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; +} + +template +uint64_t OccludedFacetSampler::num_rays_cast(uint64_t global_facet_index) const +{ + la_runtime_assert(global_facet_index < m_impl->m_num_rays_cast.size()); + return m_impl->m_num_rays_cast[global_facet_index]; +} + +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; }); +} + +template +span::InstanceInfo> +OccludedFacetSampler::instances() const +{ + return {m_impl->m_instance_infos.data(), m_impl->m_instance_infos.size()}; +} + +template +void estimate_occluded_facets( + 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"); + + 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); + 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; + while (true) { + run_cycle(); + + // `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. + 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 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) + : 1.f); + progress.update(fraction); + + if (num_visible == total_facets) { + lagrange::logger().info("All facets visible, stopping early"); + break; + } + if (options.until_converged && num_visible == prev_visible) { + lagrange::logger().info("Converged: no new visible facets in last cycle, stopping"); + break; + } + prev_visible = num_visible; + + 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; + } +} + +template +scene::SimpleScene remove_occluded_facets( + const scene::SimpleScene& scene, + const RemoveOccludedFacetsOptions& options, + ProgressCallback& progress, + function_ref is_occluder, + const std::atomic_bool* cancel) +{ + 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; + + auto scene_instance = scene.get_instance(info.mesh_index, info.instance_index); + result.add_mesh(std::move(filtered)); + scene_instance.mesh_index = result.get_num_meshes() - 1; + result.add_instance(std::move(scene_instance)); + } + return result; +} + +// clang-format off +#define LA_X_estimate_occluded_facets(_, Scalar, Index) \ + template LA_RAYCASTING_API void estimate_occluded_facets( \ + OccludedFacetSampler&, \ + const OccludedFacetEstimateOptions&, \ + ProgressCallback&, \ + const std::atomic_bool*); +LA_SURFACE_MESH_X(estimate_occluded_facets, 0) + +#define LA_X_remove_occluded_facets(_, Scalar, Index) \ + template LA_RAYCASTING_API scene::SimpleScene remove_occluded_facets( \ + const scene::SimpleScene&, \ + const RemoveOccludedFacetsOptions&, \ + ProgressCallback&, \ + function_ref, \ + const std::atomic_bool*); +LA_SURFACE_MESH_X(remove_occluded_facets, 0) + +#define LA_X_OccludedFacetSampler(_, Scalar, Index) \ + template class LA_RAYCASTING_API OccludedFacetSampler; +LA_SURFACE_MESH_X(OccludedFacetSampler, 0) +// clang-format on + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/src/remove_occluded_instances.cpp b/modules/raycasting/src/remove_occluded_instances.cpp new file mode 100644 index 00000000..07f381fa --- /dev/null +++ b/modules/raycasting/src/remove_occluded_instances.cpp @@ -0,0 +1,363 @@ +/* + * 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 "occluded_sampler_common.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace lagrange::raycasting { + +using namespace detail; + +template +struct OccludedInstanceSampler::Impl + : ImplBase::Impl, Scalar, Index> +{ + 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); + } + + 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_instance_weights; + std::vector> m_is_visible; + std::vector> m_num_rays_cast; + + Scalar instance_active_weight(size_t i) const + { + return m_is_visible[i].load(std::memory_order_relaxed) ? 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) + { + auto& inst = m_instances[i]; + const size_t N = inst.facet_areas.size(); + if (N == 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. + 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; + { + 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)) { + 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; + } + } + } + const uint64_t total_rays = boundary[N]; + const size_t num_packets = packet_start_facet.size(); + 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); + } + + 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); + }); + } + + void end_batch() {} +}; + +template +OccludedInstanceSampler::OccludedInstanceSampler( + const scene::SimpleScene& scene, + function_ref is_occluder) +{ + 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_instances.resize(total); + m_impl->m_instance_weights.resize(total); + 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); + 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); + 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))); + ++global; + } + } + + 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)); + m_impl->m_ray_caster.commit_updates(); +} + +template +OccludedInstanceSampler::~OccludedInstanceSampler() = default; + +template +OccludedInstanceSampler::OccludedInstanceSampler( + OccludedInstanceSampler&&) noexcept = default; + +template +OccludedInstanceSampler& OccludedInstanceSampler::operator=( + OccludedInstanceSampler&&) noexcept = default; + +template +void OccludedInstanceSampler::run_batch(uint64_t num_rays) +{ + m_impl->run_batch(num_rays); +} + +template +bool OccludedInstanceSampler::is_visible(Index global_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); +} + +template +uint64_t OccludedInstanceSampler::num_rays_cast(Index global_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); +} + +template +Index OccludedInstanceSampler::num_instances() const +{ + return static_cast(m_impl->m_instances.size()); +} + +template +void estimate_occluded_instances( + OccludedInstanceSampler& sampler, + const OccludedInstanceEstimateOptions& 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_instances 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; + 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); + lagrange::logger().info( + "{}/{} instances visible ({} rays so far)", + num_visible, + total_instances, + total_rays); + const float fraction = + options.num_rays > 0 + ? std::min( + 1.f, + static_cast(total_rays) / static_cast(options.num_rays)) + : (total_instances > 0 + ? static_cast(num_visible) / static_cast(total_instances) + : 1.f); + progress.update(fraction); + + if (static_cast(num_visible) == total_instances) { + lagrange::logger().info("All instances visible, stopping early"); + break; + } + if (options.until_converged && static_cast(num_visible) == prev_visible) { + lagrange::logger().info("Converged: no new visible instances in last batch, stopping"); + break; + } + prev_visible = static_cast(num_visible); + + 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; + } +} + +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) +{ + OccludedInstanceSampler sampler(scene, is_occluder); + estimate_occluded_instances(sampler, options, progress, cancel); + + Index global = 0; + for (auto mi : lagrange::range(scene.get_num_meshes())) { + for (auto ii : lagrange::range(scene.get_num_instances(mi))) { + if (!sampler.is_visible(global)) callback(mi, ii); + ++global; + } + } +} + +template +scene::SimpleScene remove_occluded_instances( + const scene::SimpleScene& scene, + const OccludedInstanceEstimateOptions& options, + ProgressCallback& progress, + function_ref is_occluder, + const std::atomic_bool* cancel) +{ + std::unordered_set, lagrange::OrderedPairHash>> + occluded; + estimate_occluded_instances( + scene, + [&](Index mi, Index ii) { occluded.emplace(mi, ii); }, + options, + progress, + is_occluder, + cancel); + + auto result = scene::filter_instances(scene, [&](Index mi, Index ii) { + return occluded.find({mi, ii}) == occluded.end(); + }); + + lagrange::logger().info( + "Filtered scene: {} meshes, {} instances", + result.get_num_meshes(), + result.compute_num_instances()); + return result; +} + +// clang-format off +#define LA_X_OccludedInstanceSampler(_, Scalar, Index) \ + template class LA_RAYCASTING_API 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&, \ + ProgressCallback&, \ + const std::atomic_bool*); +LA_SURFACE_MESH_X(estimate_occluded_instances, 0) + +#define LA_X_estimate_occluded_instances_scene(_, Scalar, Index) \ + template LA_RAYCASTING_API void estimate_occluded_instances( \ + const scene::SimpleScene&, \ + function_ref, \ + const OccludedInstanceEstimateOptions&, \ + ProgressCallback&, \ + function_ref, \ + const std::atomic_bool*); +LA_SURFACE_MESH_X(estimate_occluded_instances_scene, 0) + +#define LA_X_remove_occluded_instances(_, 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) +// clang-format on + +} // namespace lagrange::raycasting diff --git a/modules/raycasting/tests/test_raycaster_obb.cpp b/modules/raycasting/tests/test_raycaster_obb.cpp new file mode 100644 index 00000000..3b4407a4 --- /dev/null +++ b/modules/raycasting/tests/test_raycaster_obb.cpp @@ -0,0 +1,285 @@ +/* + * 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 + +namespace { + +/// Flat 5x5 vertex grid in z=0 with two triangles per cell (32 facets). +lagrange::SurfaceMesh make_flat_grid() +{ + using Mesh = lagrange::SurfaceMesh; + Mesh mesh; + + constexpr int N = 5; + constexpr double step = 2.0 / (N - 1); + + for (int j = 0; j < N; ++j) { + for (int i = 0; i < N; ++i) { + mesh.add_vertex({-1.0 + i * step, -1.0 + j * step, 0.0}); + } + } + for (int j = 0; j < N - 1; ++j) { + for (int i = 0; i < N - 1; ++i) { + uint32_t v0 = static_cast(j * N + i); + uint32_t v1 = v0 + 1; + uint32_t v2 = v0 + N; + uint32_t v3 = v2 + 1; + mesh.add_triangle(v0, v1, v2); + mesh.add_triangle(v1, v3, v2); + } + } + return mesh; +} + +lagrange::raycasting::RayCaster make_raycaster_from_flat_grid( + lagrange::SurfaceMesh& mesh) +{ + lagrange::raycasting::RayCaster rc; + rc.add_mesh(mesh); + rc.commit_updates(); + return rc; +} + +void overlap_obb_helper( + const lagrange::raycasting::RayCaster& rc, + const Eigen::Vector3f& center, + const Eigen::Matrix3f& axes, + const Eigen::Vector3f& half_extents, + lagrange::function_ref callback) +{ + lagrange::raycasting::internal::RayCasterOBBAccess::OrientedBox obb; + obb.center = center; + obb.axes = axes; + obb.half_extents = half_extents; + lagrange::raycasting::internal::RayCasterOBBAccess::overlap_obb(rc, obb, callback); +} + +} // namespace + +TEST_CASE("OBB overlap: enclosing OBB returns all facets", "[raycasting][obb]") +{ + auto mesh = make_flat_grid(); + auto rc = make_raycaster_from_flat_grid(mesh); + + std::set found; + overlap_obb_helper( + rc, + Eigen::Vector3f(0, 0, 0), + Eigen::Matrix3f::Identity(), + Eigen::Vector3f(2, 2, 2), + [&](uint32_t, uint32_t, uint32_t facet_index) -> bool { + found.insert(facet_index); + return true; + }); + + REQUIRE(found.size() == mesh.get_num_facets()); +} + +TEST_CASE("OBB overlap: small OBB near center returns subset", "[raycasting][obb]") +{ + auto mesh = make_flat_grid(); + auto rc = make_raycaster_from_flat_grid(mesh); + + std::set found; + overlap_obb_helper( + rc, + Eigen::Vector3f(0, 0, 0), + Eigen::Matrix3f::Identity(), + Eigen::Vector3f(0.3f, 0.3f, 0.1f), + [&](uint32_t, uint32_t, uint32_t facet_index) -> bool { + found.insert(facet_index); + return true; + }); + + REQUIRE(found.size() > 0); + REQUIRE(found.size() < mesh.get_num_facets()); +} + +TEST_CASE("OBB overlap: far away OBB returns nothing", "[raycasting][obb]") +{ + auto mesh = make_flat_grid(); + auto rc = make_raycaster_from_flat_grid(mesh); + + std::set found; + overlap_obb_helper( + rc, + Eigen::Vector3f(100, 100, 100), + Eigen::Matrix3f::Identity(), + Eigen::Vector3f(0.5f, 0.5f, 0.5f), + [&](uint32_t, uint32_t, uint32_t facet_index) -> bool { + found.insert(facet_index); + return true; + }); + + REQUIRE(found.empty()); +} + +TEST_CASE("OBB overlap: rotated OBB finds facets", "[raycasting][obb]") +{ + auto mesh = make_flat_grid(); + auto rc = make_raycaster_from_flat_grid(mesh); + + // 45-degree rotation around Z + float c = std::cos(3.14159265f / 4.0f); + float s = std::sin(3.14159265f / 4.0f); + Eigen::Matrix3f rot; + rot << c, -s, 0, s, c, 0, 0, 0, 1; + + std::set found; + overlap_obb_helper( + rc, + Eigen::Vector3f(0, 0, 0), + rot, + Eigen::Vector3f(0.5f, 0.5f, 0.1f), + [&](uint32_t, uint32_t, uint32_t facet_index) -> bool { + found.insert(facet_index); + return true; + }); + + REQUIRE(found.size() > 0); +} + +TEST_CASE("OBB overlap: early stop returns exactly 1 facet", "[raycasting][obb]") +{ + auto mesh = make_flat_grid(); + auto rc = make_raycaster_from_flat_grid(mesh); + + std::vector found; + overlap_obb_helper( + rc, + Eigen::Vector3f(0, 0, 0), + Eigen::Matrix3f::Identity(), + Eigen::Vector3f(2, 2, 2), + [&](uint32_t, uint32_t, uint32_t facet_index) -> bool { + found.push_back(facet_index); + return false; // stop after first + }); + + REQUIRE(found.size() == 1); +} + +// ============================================================================ +// 16-lane packet OBB overlap tests +// ============================================================================ + +namespace { + +using OrientedBox = lagrange::raycasting::internal::RayCasterOBBAccess::OrientedBox; + +OrientedBox make_aabb(const Eigen::Vector3f& center, const Eigen::Vector3f& half_extents) +{ + OrientedBox obb; + obb.center = center; + obb.axes = Eigen::Matrix3f::Identity(); + obb.half_extents = half_extents; + return obb; +} + +} // namespace + +TEST_CASE("OBB overlap16: per-lane hit routing matches scalar path", "[raycasting][obb]") +{ + auto mesh = make_flat_grid(); + auto rc = make_raycaster_from_flat_grid(mesh); + + // Three lanes: enclosing, small near origin, far away. Remaining 13 lanes inactive. + std::vector obbs = { + make_aabb({0, 0, 0}, {2, 2, 2}), + make_aabb({0, 0, 0}, {0.3f, 0.3f, 0.1f}), + make_aabb({100, 100, 100}, {0.5f, 0.5f, 0.5f}), + }; + + std::array, 16> hits_per_lane; + lagrange::raycasting::internal::RayCasterOBBAccess::overlap_obb16( + rc, + lagrange::span(obbs.data(), obbs.size()), + obbs.size(), + [&](uint32_t lane, uint32_t, uint32_t, uint32_t facet_index) -> bool { + hits_per_lane[lane].insert(facet_index); + return true; + }); + + REQUIRE(hits_per_lane[0].size() == mesh.get_num_facets()); + REQUIRE(hits_per_lane[1].size() > 0); + REQUIRE(hits_per_lane[1].size() < mesh.get_num_facets()); + REQUIRE(hits_per_lane[2].empty()); + for (size_t i = 3; i < 16; ++i) { + REQUIRE(hits_per_lane[i].empty()); + } +} + +TEST_CASE("OBB overlap16: explicit mask disables selected lanes", "[raycasting][obb]") +{ + auto mesh = make_flat_grid(); + auto rc = make_raycaster_from_flat_grid(mesh); + + // Three enclosing OBBs, but only lanes 0 and 2 are enabled by the mask. + std::vector obbs = { + make_aabb({0, 0, 0}, {2, 2, 2}), + make_aabb({0, 0, 0}, {2, 2, 2}), + make_aabb({0, 0, 0}, {2, 2, 2}), + }; + + lagrange::raycasting::RayCaster::Mask16 mask = lagrange::raycasting::RayCaster::Mask16::Zero(); + mask[0] = true; + mask[2] = true; + + std::array hits_per_lane{}; + lagrange::raycasting::internal::RayCasterOBBAccess::overlap_obb16( + rc, + lagrange::span(obbs.data(), obbs.size()), + mask, + [&](uint32_t lane, uint32_t, uint32_t, uint32_t) -> bool { + ++hits_per_lane[lane]; + return true; + }); + + REQUIRE(hits_per_lane[0] == mesh.get_num_facets()); + REQUIRE(hits_per_lane[1] == 0); // masked off + REQUIRE(hits_per_lane[2] == mesh.get_num_facets()); +} + +TEST_CASE("OBB overlap16: early stop on one lane does not affect others", "[raycasting][obb]") +{ + auto mesh = make_flat_grid(); + auto rc = make_raycaster_from_flat_grid(mesh); + + // Two enclosing OBBs; lane 0 stops after the first hit, lane 1 collects everything. + std::vector obbs = { + make_aabb({0, 0, 0}, {2, 2, 2}), + make_aabb({0, 0, 0}, {2, 2, 2}), + }; + + std::array hits_per_lane{}; + lagrange::raycasting::internal::RayCasterOBBAccess::overlap_obb16( + rc, + lagrange::span(obbs.data(), obbs.size()), + obbs.size(), + [&](uint32_t lane, uint32_t, uint32_t, uint32_t) -> bool { + ++hits_per_lane[lane]; + return lane != 0; // stop lane 0 immediately; let lane 1 keep going + }); + + REQUIRE(hits_per_lane[0] == 1); + REQUIRE(hits_per_lane[1] == mesh.get_num_facets()); +} diff --git a/modules/scene/include/lagrange/scene/filter_instances.h b/modules/scene/include/lagrange/scene/filter_instances.h new file mode 100644 index 00000000..16eef965 --- /dev/null +++ b/modules/scene/include/lagrange/scene/filter_instances.h @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +namespace lagrange::scene { + +namespace detail { +// Stand-in for C++20 std::type_identity_t: makes the templated parameter a non-deduced +// context so `Index` is deduced from `scene` only, not from the callable. +template +struct type_identity +{ + using type = T; +}; +template +using type_identity_t = typename type_identity::type; +} // namespace detail + +/// +/// Build a new SimpleScene keeping only instances for which `keep(mesh_index, instance_index)` +/// returns true. Meshes with no remaining instances are dropped; mesh indices are compacted. +/// +/// @param[in] scene Input scene. +/// @param[in] keep Predicate. +/// +/// @return The filtered scene. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// @tparam Dimension Spatial dimension of the scene. +/// +template +LA_SCENE_API SimpleScene filter_instances( + const SimpleScene& scene, + function_ref, detail::type_identity_t)> keep); + +} // namespace lagrange::scene diff --git a/modules/scene/include/lagrange/scene/internal/shared_utils.h b/modules/scene/include/lagrange/scene/internal/shared_utils.h index 34b2b476..cc571060 100644 --- a/modules/scene/include/lagrange/scene/internal/shared_utils.h +++ b/modules/scene/include/lagrange/scene/internal/shared_utils.h @@ -12,6 +12,7 @@ #pragma once #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include +#include namespace lagrange::scene::internal { @@ -209,4 +211,27 @@ std::tuple, std::optional> single_mesh_from return {mesh, image}; } +// Extract camera view + projection transforms for every camera referenced by a node in the scene. +template +std::vector camera_transforms_from_scene(const Scene& scene) +{ + using ElementId = scene::ElementId; + std::vector cameras; + for (ElementId node_id = 0; node_id < scene.nodes.size(); ++node_id) { + const auto& node = scene.nodes[node_id]; + if (!node.cameras.empty()) { + auto world_from_node = utils::compute_global_node_transform(scene, node_id); + for (auto camera_id : node.cameras) { + const auto& scene_camera = scene.cameras[camera_id]; + CameraTransforms camera; + camera.view = utils::camera_view_transform(scene_camera, world_from_node); + camera.projection = utils::camera_projection_transform(scene_camera); + cameras.push_back(camera); + } + } + } + + return cameras; +} + } // namespace lagrange::scene::internal diff --git a/modules/scene/python/src/bind_scene.h b/modules/scene/python/src/bind_scene.h index 34c2efa3..588986c1 100644 --- a/modules/scene/python/src/bind_scene.h +++ b/modules/scene/python/src/bind_scene.h @@ -12,12 +12,14 @@ #pragma once #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -680,6 +682,18 @@ void bind_scene(nb::module_& m) :returns: The global transform of the target node, which is the combination of transforms from this node all the way to the root. )"); + m.def( + "camera_transforms_from_scene", + [](const SceneType& scene) { + return scene::internal::camera_transforms_from_scene(scene); + }, + "scene"_a, + R"(Extract view and projection transforms for every camera referenced by a node in the scene. + +:param scene: The input scene. + +:returns: A list of CameraTransforms, one per camera instance in the scene.)"); + m.def( "scene_to_mesh", [](const SceneType& scene, diff --git a/modules/scene/python/src/bind_simple_scene.h b/modules/scene/python/src/bind_simple_scene.h index f40e0b56..9fac01c1 100644 --- a/modules/scene/python/src/bind_simple_scene.h +++ b/modules/scene/python/src/bind_simple_scene.h @@ -13,11 +13,14 @@ #include #include +#include +#include #include #include #include +#include #include namespace lagrange::python { @@ -235,6 +238,41 @@ input tensors are supported for setting the transform.)"); :param meshes: Input meshes to convert. :return: Simple scene containing the input meshes.)"); + + m.def( + "compute_mesh_weights", + [](const SimpleScene3D& scene, scene::FacetAllocationStrategy facet_allocation_strategy) { + return scene::compute_mesh_weights(scene, facet_allocation_strategy); + }, + "scene"_a, + "facet_allocation_strategy"_a = scene::FacetAllocationStrategy::EvenSplit, + R"(Computes mesh weights of a scene. + +:param scene: Input scene. Must contain at least one mesh. For + ``RelativeToMeshArea``, if the scene contains no instances (or only + degenerate transforms) the total transformed area is zero and all returned + weights are zero. For ``RelativeToNumFacets`` the total facet count must be + positive, otherwise the returned weights will contain non-finite values. +:param facet_allocation_strategy: Strategy used to compute the weights distribution. Defaults to + ``FacetAllocationStrategy.EvenSplit``. ``FacetAllocationStrategy.Synchronized`` + is not supported by this function and will raise :class:`RuntimeError`. + +:return: Weights for each mesh of the scene that sum to unity, each in [0, 1].)"); + + m.def( + "filter_instances", + [](const SimpleScene3D& s, std::function keep) { + return lagrange::scene::filter_instances(s, keep); + }, + "scene"_a, + "keep"_a, + R"(Build a new scene keeping only instances for which ``keep(mesh_index, instance_index)`` +returns True. Meshes with no remaining instances are dropped; mesh indices are compacted. + +:param scene: Input scene. +:param keep: Callable ``(mesh_index, instance_index) -> bool``. + +:return: Filtered scene.)"); } } // namespace lagrange::python diff --git a/modules/scene/python/src/scene.cpp b/modules/scene/python/src/scene.cpp index 89485b91..93c772f1 100644 --- a/modules/scene/python/src/scene.cpp +++ b/modules/scene/python/src/scene.cpp @@ -23,8 +23,6 @@ void populate_scene_module(nb::module_& m) using Scalar = double; using Index = uint32_t; - bind_simple_scene(m); - nb::enum_( m, "FacetAllocationStrategy", @@ -85,6 +83,8 @@ void populate_scene_module(nb::module_& m) &lagrange::scene::RemeshingOptions::per_instance_importance, "Optional per-instance weights/importance. Must be > 0."); + bind_simple_scene(m); + bind_scene(m); } diff --git a/modules/scene/python/tests/test_simple_scene.py b/modules/scene/python/tests/test_simple_scene.py index eb31b3ae..259e865f 100644 --- a/modules/scene/python/tests/test_simple_scene.py +++ b/modules/scene/python/tests/test_simple_scene.py @@ -11,6 +11,7 @@ # import lagrange import numpy as np +import pytest class TestSimpleScene: @@ -81,3 +82,78 @@ def test_scene_convert(self, single_triangle): assert np.all(mesh2.vertices == mesh2_alt.vertices) and np.all( mesh2.facets == mesh2_alt.facets ) + + +class TestComputeMeshWeights: + def make_scene(self): + """Scene with two meshes: m1 has 1 facet (area 0.5), m2 has 2 facets (area 4.0).""" + m1 = lagrange.SurfaceMesh() + m1.vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float64) + m1.add_triangle(0, 1, 2) + + m2 = lagrange.SurfaceMesh() + m2.vertices = np.array([[0, 0, 0], [2, 0, 0], [0, 2, 0], [2, 2, 0]], dtype=np.float64) + m2.add_triangle(0, 1, 2) + m2.add_triangle(1, 3, 2) + + scene = lagrange.scene.SimpleScene3D() + idx0 = scene.add_mesh(m1) + idx1 = scene.add_mesh(m2) + + inst0 = lagrange.scene.MeshInstance3D() + inst0.mesh_index = idx0 + scene.add_instance(inst0) + + inst1 = lagrange.scene.MeshInstance3D() + inst1.mesh_index = idx1 + scene.add_instance(inst1) + + return scene + + def test_even_split(self): + scene = self.make_scene() + weights = lagrange.scene.compute_mesh_weights( + scene, lagrange.scene.FacetAllocationStrategy.EvenSplit + ) + assert len(weights) == 2 + assert sum(weights) == pytest.approx(1.0, abs=1e-10) + assert weights[0] == pytest.approx(0.5, abs=1e-10) + assert weights[1] == pytest.approx(0.5, abs=1e-10) + + def test_default_strategy_is_even_split(self): + scene = self.make_scene() + weights = lagrange.scene.compute_mesh_weights(scene) + assert len(weights) == 2 + assert weights[0] == pytest.approx(0.5, abs=1e-10) + assert weights[1] == pytest.approx(0.5, abs=1e-10) + + def test_relative_to_num_facets(self): + scene = self.make_scene() + weights = lagrange.scene.compute_mesh_weights( + scene, lagrange.scene.FacetAllocationStrategy.RelativeToNumFacets + ) + assert len(weights) == 2 + assert sum(weights) == pytest.approx(1.0, abs=1e-10) + # m1 has 1 facet, m2 has 2 → weights 1/3 and 2/3 + assert weights[0] == pytest.approx(1.0 / 3.0, abs=1e-10) + assert weights[1] == pytest.approx(2.0 / 3.0, abs=1e-10) + + def test_relative_to_mesh_area(self): + scene = self.make_scene() + weights = lagrange.scene.compute_mesh_weights( + scene, lagrange.scene.FacetAllocationStrategy.RelativeToMeshArea + ) + assert len(weights) == 2 + assert sum(weights) == pytest.approx(1.0, abs=1e-10) + # m1: right triangle legs 1,1 → area 0.5 + # m2: two right triangles legs 2,2 each → area 4.0 + total = 4.5 + assert weights[0] == pytest.approx(0.5 / total, abs=1e-10) + assert weights[1] == pytest.approx(4.0 / total, abs=1e-10) + + def test_synchronized_raises(self): + scene = self.make_scene() + with pytest.raises(RuntimeError): + lagrange.scene.compute_mesh_weights( + scene, lagrange.scene.FacetAllocationStrategy.Synchronized + ) diff --git a/modules/scene/src/compute_mesh_weights.cpp b/modules/scene/src/compute_mesh_weights.cpp index 10d6b55c..42b1b4c0 100644 --- a/modules/scene/src/compute_mesh_weights.cpp +++ b/modules/scene/src/compute_mesh_weights.cpp @@ -63,8 +63,9 @@ std::vector compute_mesh_weights( switch (facet_allocation_strategy) { case FacetAllocationStrategy::EvenSplit: { - const auto weight = 1. / static_cast(scene.get_num_meshes()); - weights.resize(scene.get_num_meshes(), weight); + const Index num = scene.get_num_meshes(); + const auto weight = 1. / static_cast(num ? num : 1); + weights.resize(num, weight); break; } case FacetAllocationStrategy::RelativeToMeshArea: { @@ -73,7 +74,12 @@ std::vector compute_mesh_weights( static_cast(compute_mesh_max_surface_area(scene, mesh_index))); } Eigen::Map weights_map(weights.data(), weights.size()); - weights_map /= weights_map.sum(); + const double total_area = weights_map.sum(); + // If the scene has meshes but no instances (or only degenerate transforms), + // the total area is zero. Return all-zero weights instead of producing NaNs. + if (total_area > 0) { + weights_map /= total_area; + } break; } case FacetAllocationStrategy::RelativeToNumFacets: { @@ -81,7 +87,10 @@ std::vector compute_mesh_weights( weights.emplace_back(static_cast(scene.get_mesh(mesh_index).get_num_facets())); } Eigen::Map weights_map(weights.data(), weights.size()); - weights_map /= weights_map.sum(); + const double total_facets = weights_map.sum(); + if (total_facets > 0) { + weights_map /= total_facets; + } break; } case FacetAllocationStrategy::Synchronized: diff --git a/modules/scene/src/filter_instances.cpp b/modules/scene/src/filter_instances.cpp new file mode 100644 index 00000000..f4d24655 --- /dev/null +++ b/modules/scene/src/filter_instances.cpp @@ -0,0 +1,50 @@ +/* + * 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 +SimpleScene filter_instances( + const SimpleScene& scene, + function_ref, detail::type_identity_t)> keep) +{ + SimpleScene result; + for (Index mi = 0; mi < scene.get_num_meshes(); ++mi) { + bool mesh_added = false; + for (Index ii = 0; ii < scene.get_num_instances(mi); ++ii) { + if (keep(mi, ii)) { + if (!mesh_added) { + result.add_mesh(scene.get_mesh(mi)); + mesh_added = true; + } + auto instance = scene.get_instance(mi, ii); + instance.mesh_index = result.get_num_meshes() - 1; + result.add_instance(std::move(instance)); + } + } + } + return result; +} + +#define LA_X_filter_instances(_, Scalar, Index, Dimension) \ + template LA_SCENE_API SimpleScene filter_instances( \ + const SimpleScene&, \ + function_ref, detail::type_identity_t)>); +LA_SIMPLE_SCENE_X(filter_instances, 0) + +} // namespace lagrange::scene diff --git a/modules/scene/tests/test_simple_scene.cpp b/modules/scene/tests/test_simple_scene.cpp index 25816344..1e44f344 100644 --- a/modules/scene/tests/test_simple_scene.cpp +++ b/modules/scene/tests/test_simple_scene.cpp @@ -13,9 +13,12 @@ #include #include +#include #include #include +#include + #include #include @@ -113,3 +116,93 @@ TEST_CASE("SimpleScene: convert", "[scene]") test_simple_scene_convert(); test_simple_scene_convert(); } + +TEST_CASE("compute_mesh_weights: meshes without instances", "[scene]") +{ + using Scalar = double; + using Index = uint32_t; + using SceneType = lagrange::scene::SimpleScene; + using MeshType = lagrange::SurfaceMesh; + + SceneType scene; + MeshType mesh(3); + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_triangle(0, 1, 2); + scene.add_mesh(mesh); + + REQUIRE(scene.get_num_meshes() == 1); + REQUIRE(scene.compute_num_instances() == 0); + + // EvenSplit and RelativeToNumFacets do not depend on instances and must succeed. + { + const auto weights = lagrange::scene::compute_mesh_weights( + scene, + lagrange::scene::FacetAllocationStrategy::EvenSplit); + REQUIRE(weights.size() == 1); + REQUIRE(weights[0] == Catch::Approx(1.0)); + } + { + const auto weights = lagrange::scene::compute_mesh_weights( + scene, + lagrange::scene::FacetAllocationStrategy::RelativeToNumFacets); + REQUIRE(weights.size() == 1); + REQUIRE(weights[0] == Catch::Approx(1.0)); + } + + // RelativeToMeshArea relies on per-instance transformed areas; without any + // instances the total area is zero, so all weights must be zero (rather than + // non-finite from a division by zero). + { + const auto weights = lagrange::scene::compute_mesh_weights( + scene, + lagrange::scene::FacetAllocationStrategy::RelativeToMeshArea); + REQUIRE(weights.size() == 1); + REQUIRE(weights[0] == Catch::Approx(0.0)); + } +} + +TEST_CASE("compute_mesh_weights: empty scene", "[scene]") +{ + using SceneType = lagrange::scene::SimpleScene; + + SceneType scene; + REQUIRE(scene.get_num_meshes() == 0); + + // No meshes: every strategy must return an empty weight vector without + // dividing by zero. + for (const auto strategy : { + lagrange::scene::FacetAllocationStrategy::EvenSplit, + lagrange::scene::FacetAllocationStrategy::RelativeToMeshArea, + lagrange::scene::FacetAllocationStrategy::RelativeToNumFacets, + }) { + const auto weights = lagrange::scene::compute_mesh_weights(scene, strategy); + REQUIRE(weights.empty()); + } +} + +TEST_CASE("compute_mesh_weights: meshes without facets", "[scene]") +{ + using Scalar = double; + using Index = uint32_t; + using SceneType = lagrange::scene::SimpleScene; + using MeshType = lagrange::SurfaceMesh; + + SceneType scene; + MeshType mesh(3); + mesh.add_vertex({0, 0, 0}); + scene.add_mesh(mesh); + scene.add_instance({0, {}, {}}); + + REQUIRE(scene.get_num_meshes() == 1); + REQUIRE(scene.get_mesh(0).get_num_facets() == 0); + + // No facets in the scene: RelativeToNumFacets cannot normalize, so all + // weights must be zero rather than non-finite. + const auto weights = lagrange::scene::compute_mesh_weights( + scene, + lagrange::scene::FacetAllocationStrategy::RelativeToNumFacets); + REQUIRE(weights.size() == 1); + REQUIRE(weights[0] == Catch::Approx(0.0)); +} diff --git a/modules/subdivision/src/compute_sharpness.cpp b/modules/subdivision/src/compute_sharpness.cpp index 58d670a6..575f3156 100644 --- a/modules/subdivision/src/compute_sharpness.cpp +++ b/modules/subdivision/src/compute_sharpness.cpp @@ -58,8 +58,10 @@ SharpnessResults compute_sharpness( if (normal_id.has_value()) { logger().debug("Using mesh normals to set sharpness flag."); results.normal_attr = normal_id; + SeamEdgesOptions seam_opts; + seam_opts.include_boundary_edges = true; - auto seam_id = compute_seam_edges(mesh, normal_id.value()); + auto seam_id = compute_seam_edges(mesh, normal_id.value(), seam_opts); auto edge_sharpness_id = cast_attribute(mesh, seam_id, "edge_sharpness"); results.edge_sharpness_attr = edge_sharpness_id; diff --git a/modules/texproc/examples/texture_rasterization.cpp b/modules/texproc/examples/texture_rasterization.cpp index f9a99abf..84185a00 100644 --- a/modules/texproc/examples/texture_rasterization.cpp +++ b/modules/texproc/examples/texture_rasterization.cpp @@ -13,6 +13,7 @@ #include "io_helpers.h" #include +#include #include #include #include @@ -123,11 +124,23 @@ int main(int argc, char** argv) auto scene = lagrange::io::load_scene(args.input_scene, load_options); - // Load (optional) base texture + // Extract mesh, base texture, and cameras from scene + auto [mesh, scene_base_texture] = lagrange::scene::internal::single_mesh_from_scene(scene); + const auto cameras = lagrange::scene::internal::camera_transforms_from_scene(scene); + lagrange::logger().info("Found {} cameras in the input scene", cameras.size()); + + // Load (optional) base texture — overrides any texture embedded in the scene std::optional base_texture; if (!args.input_texture.empty()) { lagrange::logger().info("Loading base texture: {}", args.input_texture.string()); base_texture = load_image(args.input_texture); + if (scene_base_texture.has_value()) { + lagrange::logger().warn( + "Input scene already contains a base texture. Overriding with user-provided " + "texture."); + } + } else { + base_texture = std::move(scene_base_texture); } la_runtime_assert( @@ -137,43 +150,36 @@ int main(int argc, char** argv) args.input_renders.empty() || args.input_render_grid.empty(), "--renders-in and --render-grid-in are mutually exclusive"); - std::vector> textures_and_weights; + // `render_grid` and `renders` own the image data referenced by `views`. + Array3Df render_grid; + std::vector renders; + std::vector views; if (!args.input_render_grid.empty()) { - // Load single grid image and split based on camera count lagrange::logger().info("Loading render grid: {}", args.input_render_grid.string()); - Array3Df render_grid = load_image(args.input_render_grid); - - textures_and_weights = lagrange::texproc::rasterize_textures_from_renders( - scene, - base_texture, - render_grid.to_mdspan(), - args.width, - args.height, - args.low_confidence_ratio, - args.base_confidence); + render_grid = load_image(args.input_render_grid); + la_runtime_assert(!cameras.empty(), "No cameras found in the input scene"); + views = lagrange::image::experimental::split_grid( + ConstView3Df(render_grid.to_mdspan()), + {cameras.size()}); } else { - // Sort input renders sort_paths(args.input_renders); - - // Load rendered images to unproject lagrange::logger().info("Loading input {} renders", args.input_renders.size()); - std::vector renders; - std::vector views; for (const auto& render : args.input_renders) { renders.push_back(load_image(render)); views.push_back(renders.back().to_mdspan()); } - - textures_and_weights = lagrange::texproc::rasterize_textures_from_renders( - scene, - base_texture, - views, - args.width, - args.height, - args.low_confidence_ratio, - args.base_confidence); } + auto textures_and_weights = lagrange::texproc::rasterize_textures_from_renders( + mesh, + std::move(base_texture), + cameras, + views, + args.width, + args.height, + args.low_confidence_ratio, + args.base_confidence); + // Save textures and confidences tbb::parallel_for(size_t(0), textures_and_weights.size(), [&](size_t i) { fs::path output_texture = make_output_path(args.output_textures, i); diff --git a/modules/texproc/include/lagrange/texproc/TextureRasterizer.h b/modules/texproc/include/lagrange/texproc/TextureRasterizer.h index b833c139..2d0d0c0f 100644 --- a/modules/texproc/include/lagrange/texproc/TextureRasterizer.h +++ b/modules/texproc/include/lagrange/texproc/TextureRasterizer.h @@ -11,32 +11,16 @@ */ #pragma once +#include #include #include #include -#include - namespace lagrange::texproc { /// @addtogroup module-texproc /// @{ -/// -/// Parameters for computing the rendering of a mesh. -/// -struct CameraOptions -{ - /// Camera view transform (world space -> view space). - Eigen::Affine3f view_transform = Eigen::Affine3f::Identity(); - - /// Camera projection transform (view space -> NDC space). - /// - /// This is the standard glTF/OpenGL projection matrix, where depth is remapped to [-1, 1] (near - /// plane to -1, far plane to 1). - Eigen::Projective3f projection_transform = Eigen::Projective3f::Identity(); -}; - /// /// Options for computing the texture map and confidence from a rendering. /// @@ -92,14 +76,14 @@ class TextureRasterizer /// /// Unproject a rendered image into a UV texture and confidence map. /// - /// @param[in] image Input rendered color image. - /// @param[in] options Camera option. + /// @param[in] image Input rendered color image. + /// @param[in] transforms Camera view and projection transforms. /// /// @return A pair of (texture, weight) images. /// std::pair weighted_texture_from_render( image::experimental::View3D image, - const CameraOptions& options) const; + const CameraTransforms& transforms) const; private: /// @cond LA_INTERNAL_DOCS diff --git a/modules/texproc/python/src/texproc.cpp b/modules/texproc/python/src/texproc.cpp index 2dded73c..5db2b7bb 100644 --- a/modules/texproc/python/src/texproc.cpp +++ b/modules/texproc/python/src/texproc.cpp @@ -33,7 +33,6 @@ #include -#include #include namespace lagrange::python { @@ -279,55 +278,125 @@ void populate_texproc_module(nb::module_& m) :return: The composited texture image.)"); - m.def( - "rasterize_textures_from_renders", - [](const scene::Scene& scene, - const std::vector>& renders, - const std::optional width, - const std::optional height, - const float low_confidence_ratio, - const std::optional base_confidence) { - std::vector views; - for (const auto& render : renders) { - views.push_back(tensor_to_image_view(render)); - } - - auto textures_and_weights = tp::rasterize_textures_from_renders( - scene, - std::nullopt, - views, - width, - height, - low_confidence_ratio, - base_confidence); - + auto pack_textures_and_weights = + [](std::vector>& textures_and_weights) { std::vector textures; std::vector weights; + textures.reserve(textures_and_weights.size()); + weights.reserve(textures_and_weights.size()); for (auto& [texture_, weight_] : textures_and_weights) { auto texture = image_array_to_tensor(texture_); auto weight = image_array_to_tensor(weight_); textures.emplace_back(texture); weights.emplace_back(weight); } - return std::make_tuple(textures, weights); + }; + + auto convert_renders = [](const std::vector>& renders) { + std::vector views; + views.reserve(renders.size()); + for (const auto& render : renders) { + views.push_back(tensor_to_image_view(render)); + } + return views; + }; + + auto convert_base_texture = + [](const std::optional>& base_texture) -> std::optional { + if (!base_texture.has_value()) return std::nullopt; + const auto view = tensor_to_image_view(*base_texture); + auto image = image::experimental::create_image( + view.extent(0), + view.extent(1), + view.extent(2)); + copy_tensor_to_image_view(*base_texture, image.to_mdspan()); + return image; + }; + + constexpr auto rasterize_doc = + R"(Rasterize one (color, weight) per (render, camera) and filter out low-confidence weights. + +This function has two overloads: + +1. ``rasterize_textures_from_renders(scene, renders, *, base_texture=None, ...)``: extract mesh, + base texture, and cameras from a scene. ``base_texture`` (if provided) overrides any base texture + in the scene. +2. ``rasterize_textures_from_renders(mesh, cameras, renders, *, base_texture=None, ...)``: take an + explicit mesh and a list of CameraTransforms. + +:param scene: Scene containing a single mesh (possibly with a base texture), and multiple cameras. +:param mesh: Input mesh with UVs (alternative to scene). +:param cameras: List of CameraTransforms (alternative to scene). +:param renders: List of rendered images, one per camera. +:param base_texture: Optional base texture override. Takes precedence over any texture in the scene. +:param width: Width of the rasterized textures. Must match the width of the base texture if present. Otherwise, defaults to 1024. +:param height: Height of the rasterized textures. Must match the height of the base texture if present. Otherwise, defaults to 1024. +:param low_confidence_ratio: Discard low confidence texels whose weights are < ratio * max_weight. +:param base_confidence: Confidence value for the base texture if present. If set to 0, ignore the base texture. Defaults to 0.3 otherwise. + +:return: A pair of lists (textures, weights), one per camera.)"; + + m.def( + "rasterize_textures_from_renders", + [=](const scene::Scene& scene, + const std::vector>& renders, + const std::optional>& base_texture, + const std::optional width, + const std::optional height, + const float low_confidence_ratio, + const std::optional base_confidence) { + auto textures_and_weights = tp::rasterize_textures_from_renders( + scene, + convert_base_texture(base_texture), + convert_renders(renders), + width, + height, + low_confidence_ratio, + base_confidence); + return pack_textures_and_weights(textures_and_weights); }, "scene"_a, "renders"_a, + nb::kw_only(), + "base_texture"_a = nb::none(), "width"_a = nb::none(), "height"_a = nb::none(), "low_confidence_ratio"_a = 0.75, "base_confidence"_a = nb::none(), - R"(Rasterize one (color, weight) per (render, camera) and filter our low-confidence weights. - -:param scene: Scene containing a single mesh (possibly with a base texture), and multiple cameras. -:param renders: List of rendered images, one per camera. -:param width: Width of the rasterized textures. Must match the width of the base texture if present. Otherwise, defaults to 1024. -:param height: Height of the rasterized textures. Must match the height of the base texture if present. Otherwise, defaults to 1024. -:param low_confidence_ratio: Discard low confidence texels whose weights are < ratio * max_weight. -:param base_confidence: Confidence value for the base texture if present in the scene. If set to 0, ignore the base texture of the mesh. Defaults to 0.3 otherwise. + rasterize_doc); -:return: A pair of lists (textures, weights), one per camera.)"); + m.def( + "rasterize_textures_from_renders", + [=](const SurfaceMesh& mesh, + const std::vector& cameras, + const std::vector>& renders, + const std::optional>& base_texture, + const std::optional width, + const std::optional height, + const float low_confidence_ratio, + const std::optional base_confidence) { + auto textures_and_weights = tp::rasterize_textures_from_renders( + mesh, + convert_base_texture(base_texture), + cameras, + convert_renders(renders), + width, + height, + low_confidence_ratio, + base_confidence); + return pack_textures_and_weights(textures_and_weights); + }, + "mesh"_a, + "cameras"_a, + "renders"_a, + nb::kw_only(), + "base_texture"_a = nb::none(), + "width"_a = nb::none(), + "height"_a = nb::none(), + "low_confidence_ratio"_a = 0.75, + "base_confidence"_a = nb::none(), + rasterize_doc); m.def( "extract_mesh_with_alpha_mask", diff --git a/modules/texproc/python/tests/test_texproc.py b/modules/texproc/python/tests/test_texproc.py index 4492fcab..e44e4cba 100644 --- a/modules/texproc/python/tests/test_texproc.py +++ b/modules/texproc/python/tests/test_texproc.py @@ -108,3 +108,49 @@ def test_rasterize_and_compose(self, quad_scene, quad_tex): ) assert final_color.shape == (128, 128, 4) + + def test_camera_transforms_property(self): + ct = lagrange.CameraTransforms() + + # 4x4 setter: full matrix with last row [0, 0, 0, 1] + view = np.eye(4, dtype=np.float32) + view[0, 3] = 1.5 + view[1, 3] = -2.5 + ct.view = view + assert np.array_equal(ct.view, view) + + # 3x4 setter: compact [R|t] form; getter returns 4x4 with last row [0,0,0,1] appended + view34 = view[:3, :] # shape (3, 4) + ct.view = view34 + expected = np.eye(4, dtype=np.float32) + expected[:3, :] = view34 + assert np.allclose(ct.view, expected) + + proj = np.arange(16, dtype=np.float32).reshape(4, 4) + ct.projection = proj + assert np.array_equal(ct.projection, proj) + + def test_rasterize_with_mesh_and_cameras(self, quad_scene, quad_tex): + mesh = lagrange.scene.scene_to_mesh(quad_scene) + cameras = lagrange.scene.camera_transforms_from_scene(quad_scene) + assert len(cameras) == 8 + for cam in cameras: + assert cam.view.shape == (4, 4) + assert cam.projection.shape == (4, 4) + + views = [quad_tex.copy() for _ in range(len(cameras))] + + colors, weights = lagrange.texproc.rasterize_textures_from_renders( + mesh, + cameras, + views, + width=128, + height=128, + base_confidence=0, + ) + + assert len(colors) == len(cameras) + assert len(weights) == len(cameras) + for color, weight in zip(colors, weights): + assert color.shape == (128, 128, 4) + assert weight.shape == (128, 128, 1) diff --git a/modules/texproc/shared/shared_utils.h b/modules/texproc/shared/shared_utils.h index 85b87316..e2497011 100644 --- a/modules/texproc/shared/shared_utils.h +++ b/modules/texproc/shared/shared_utils.h @@ -29,56 +29,31 @@ using scene::internal::Array3Df; using scene::internal::ConstView3Df; using scene::internal::View3Df; -template -std::vector cameras_from_scene(const scene::Scene& scene) -{ - using ElementId = scene::ElementId; - - std::vector cameras; - for (ElementId node_id = 0; node_id < scene.nodes.size(); ++node_id) { - const auto& node = scene.nodes[node_id]; - if (!node.cameras.empty()) { - auto world_from_node = scene::utils::compute_global_node_transform(scene, node_id); - for (auto camera_id : node.cameras) { - const auto& scene_camera = scene.cameras[camera_id]; - CameraOptions camera; - camera.view_transform = - scene::utils::camera_view_transform(scene_camera, world_from_node); - camera.projection_transform = - scene::utils::camera_projection_transform(scene_camera); - cameras.push_back(camera); - } - } - } - - return cameras; -} - +/// +/// Unproject a vector of rendered images into per-camera (texture, weight) pairs. +/// +/// @param[in] mesh Input mesh with UVs. +/// @param[in] base_texture Optional base texture to seed the output with. +/// @param[in] cameras View and projection transforms for each camera. +/// @param[in] renders Rendered color images, one per camera. +/// @param[in] tex_width Optional rasterization texture width. +/// @param[in] tex_height Optional rasterization texture height. +/// @param[in] low_confidence_ratio Ratio threshold for low confidence filtering. +/// @param[in] base_confidence Optional uniform confidence for the base texture. +/// +/// @return Vector of (texture, weight) pairs. +/// template std::vector> rasterize_textures_from_renders( - const lagrange::scene::Scene& scene, - std::optional base_texture_in, + const SurfaceMesh& mesh, + std::optional base_texture, + const std::vector& cameras, const std::vector& renders, const std::optional tex_width, const std::optional tex_height, const float low_confidence_ratio, const std::optional base_confidence) { - // Load mesh, base texture and cameras from input scene - auto [mesh, base_texture] = scene::internal::single_mesh_from_scene(scene); - auto cameras = cameras_from_scene(scene); - lagrange::logger().info("Found {} cameras in the input scene", cameras.size()); - - if (base_texture_in.has_value()) { - if (base_texture.has_value()) { - lagrange::logger().warn( - "Input scene already contains a base texture. Overriding with user-provided " - "texture."); - } - base_texture = std::move(base_texture_in); - } - - // Load rendered images to unproject la_runtime_assert(!renders.empty(), "No rendered images to unproject"); for (const auto& render : renders) { size_t img_width = render.extent(0); @@ -101,8 +76,7 @@ std::vector> rasterize_textures_from_renders( // Use base texture with a low confidence if (base_confidence.has_value() && base_confidence.value() == 0) { if (base_texture.has_value()) { - lagrange::logger().warn( - "Base confidence is 0, ignoring base texture in the input scene."); + lagrange::logger().warn("Base confidence is 0, ignoring provided base texture."); } } else { if (base_texture.has_value()) { @@ -124,8 +98,7 @@ std::vector> rasterize_textures_from_renders( } else { if (base_confidence.has_value()) { lagrange::logger().warn( - "No base texture was found in the input scene. Ignoring user-provided base " - "confidence: {}", + "No base texture was provided. Ignoring user-provided base confidence: {}", base_confidence.value()); } } @@ -179,99 +152,38 @@ std::vector> rasterize_textures_from_renders( } /// -/// Overload of rasterize_textures_from_renders that takes a single grid image instead of a vector -/// of renders. The grid image is split into individual render views based on the number of cameras -/// in the scene. -/// -/// @param[in] scene Input scene with mesh, UVs and cameras. -/// @param[in] base_texture_in Optional base texture override. -/// @param[in] render_grid Single image containing a grid of renders. -/// @param[in] tex_width Optional rasterization texture width. -/// @param[in] tex_height Optional rasterization texture height. -/// @param[in] low_confidence_ratio Ratio threshold for low confidence filtering. -/// @param[in] base_confidence Optional uniform confidence for the base texture. -/// -/// @return Vector of (texture, weight) pairs. +/// Overload of rasterize_textures_from_renders that takes a scene as input. The mesh, base texture +/// (if any), and cameras are extracted from the scene and forwarded to the primary overload. An +/// optional base texture override takes precedence over any texture already present in the scene. /// template std::vector> rasterize_textures_from_renders( const lagrange::scene::Scene& scene, - std::optional base_texture_in, - const ConstView3Df& render_grid, + std::optional base_texture_override, + const std::vector& renders, const std::optional tex_width, const std::optional tex_height, const float low_confidence_ratio, const std::optional base_confidence) { - // Determine number of cameras in the scene - auto cameras = cameras_from_scene(scene); - const size_t num_cameras = cameras.size(); - la_runtime_assert(num_cameras > 0, "No cameras found in the input scene"); - - const size_t grid_width = render_grid.extent(0); - const size_t grid_height = render_grid.extent(1); - const size_t num_channels = render_grid.extent(2); - - // Find the best grid layout (rows x cols = num_cameras) such that the grid image can be evenly - // divided into cells. The heuristic picks the factorization whose cells are closest to square. - // This assumes the grid was rendered with approximately square (or at least uniform aspect - // ratio) cameras. Use the std::vector overload for grids with non-square cells. - size_t best_cols = 0; - size_t best_rows = 0; - double best_aspect_diff = std::numeric_limits::max(); - for (size_t cols = 1; cols <= num_cameras; ++cols) { - if (num_cameras % cols != 0) continue; - size_t rows = num_cameras / cols; - if (grid_width % cols != 0 || grid_height % rows != 0) continue; - size_t cell_w = grid_width / cols; - size_t cell_h = grid_height / rows; - double aspect_diff = std::abs(static_cast(cell_w) / cell_h - 1.0); - if (aspect_diff < best_aspect_diff) { - best_aspect_diff = aspect_diff; - best_cols = cols; - best_rows = rows; - } - } - la_runtime_assert( - best_cols > 0, - format( - "Cannot evenly divide grid image ({}x{}) into {} cells", - grid_width, - grid_height, - num_cameras)); - - const size_t cell_width = grid_width / best_cols; - const size_t cell_height = grid_height / best_rows; - lagrange::logger().info( - "Splitting {}x{} grid image into {}x{} cells of size {}x{}", - grid_width, - grid_height, - best_cols, - best_rows, - cell_width, - cell_height); + auto [mesh, base_texture] = scene::internal::single_mesh_from_scene(scene); + auto cameras = scene::internal::camera_transforms_from_scene(scene); + lagrange::logger().info("Found {} cameras in the input scene", cameras.size()); - // Create views into the grid image for each cell (row-major order) - using namespace image::experimental; - std::vector views; - views.reserve(num_cameras); - const dextents cell_shape{cell_width, cell_height, num_channels}; - const std::array cell_strides{ - render_grid.stride(0), - render_grid.stride(1), - render_grid.stride(2)}; - const layout_stride::mapping> cell_mapping{cell_shape, cell_strides}; - for (size_t row = 0; row < best_rows; ++row) { - for (size_t col = 0; col < best_cols; ++col) { - const float* cell_ptr = &render_grid(col * cell_width, row * cell_height, 0); - views.emplace_back(cell_ptr, cell_mapping); + if (base_texture_override.has_value()) { + if (base_texture.has_value()) { + lagrange::logger().warn( + "Input scene already contains a base texture. Overriding with user-provided " + "texture."); } + base_texture = std::move(base_texture_override); } return rasterize_textures_from_renders( - scene, - std::move(base_texture_in), - views, + mesh, + std::move(base_texture), + cameras, + renders, tex_width, tex_height, low_confidence_ratio, diff --git a/modules/texproc/src/TextureRasterizer.cpp b/modules/texproc/src/TextureRasterizer.cpp index 07c4200b..a55c926e 100644 --- a/modules/texproc/src/TextureRasterizer.cpp +++ b/modules/texproc/src/TextureRasterizer.cpp @@ -124,10 +124,12 @@ struct Rendering CameraParameters camera_parameters; RegularGrid<2, T> render_map; - Rendering(const CameraOptions& options, image::experimental::View3D rendered_image) + Rendering( + const CameraTransforms& transforms, + image::experimental::View3D rendered_image) : camera_parameters( - options.view_transform.cast(), - options.projection_transform.cast(), + transforms.view.cast(), + transforms.projection.cast(), static_cast(rendered_image.extent(0)), static_cast(rendered_image.extent(1))) { @@ -615,10 +617,10 @@ std::pair, image::experimental::Array3D rendered_image, - const CameraOptions& camera_options, + const CameraTransforms& camera_transforms, const TextureRasterizerOptions& rasterizer_options) { - Rendering> in_rendering(camera_options, rendered_image); + Rendering> in_rendering(camera_transforms, rendered_image); RegularGrid<2, Vector> texture; RegularGrid<2, double> confidence; @@ -679,7 +681,7 @@ TextureRasterizer::~TextureRasterizer() = default; template auto TextureRasterizer::weighted_texture_from_render( image::experimental::View3D image, - const CameraOptions& options) const -> std::pair + const CameraTransforms& transforms) const -> std::pair { unsigned int num_channels = static_cast(image.extent(2)); switch (num_channels) { @@ -687,25 +689,25 @@ auto TextureRasterizer::weighted_texture_from_render( return weighted_texture_from_render_impl<1>( m_impl->from_render, image, - options, + transforms, m_impl->options); case 2: return weighted_texture_from_render_impl<2>( m_impl->from_render, image, - options, + transforms, m_impl->options); case 3: return weighted_texture_from_render_impl<3>( m_impl->from_render, image, - options, + transforms, m_impl->options); case 4: return weighted_texture_from_render_impl<4>( m_impl->from_render, image, - options, + transforms, m_impl->options); default: throw Error(format( diff --git a/modules/texproc/src/mesh_utils.h b/modules/texproc/src/mesh_utils.h index c0973622..11ab6492 100644 --- a/modules/texproc/src/mesh_utils.h +++ b/modules/texproc/src/mesh_utils.h @@ -15,11 +15,11 @@ #include #include -#include #include #include #include #include +#include #include #include #include @@ -175,57 +175,6 @@ void clamp_out_of_range( } } -template -void check_for_flipped_uv(const SurfaceMesh& mesh, AttributeId id) -{ - auto uv_mesh = - [&]() -> std::pair, std::optional>> { - if (mesh.is_attribute_indexed(id)) { - const auto& uv_attr = mesh.template get_indexed_attribute(id); - auto uv_values = matrix_view(uv_attr.values()); - auto uv_indices = reshaped_view(uv_attr.indices(), 3); - return {uv_values, uv_indices}; - } else { - const auto& uv_attr = mesh.template get_attribute(id); - la_runtime_assert( - uv_attr.get_element_type() == AttributeElement::Vertex || - uv_attr.get_element_type() == AttributeElement::Corner, - "UV attribute must be per-vertex or per-corner."); - auto uv_values = matrix_view(uv_attr); - return { - uv_values, - uv_attr.get_element_type() == AttributeElement::Vertex - ? std::nullopt - : std::optional>(facet_view(mesh))}; - } - }(); - - auto uv_index = [&](Index f, unsigned int k) { - if (uv_mesh.second.has_value()) { - return (*uv_mesh.second)(f, k); - } else { - return f * 3 + k; - } - }; - - ExactPredicatesShewchuk predicates; - for (Index f = 0; f < mesh.get_num_facets(); ++f) { - Eigen::RowVector2d p0 = uv_mesh.first.row(uv_index(f, 0)).template cast(); - Eigen::RowVector2d p1 = uv_mesh.first.row(uv_index(f, 1)).template cast(); - Eigen::RowVector2d p2 = uv_mesh.first.row(uv_index(f, 2)).template cast(); - auto r = predicates.orient2D(p0.data(), p1.data(), p2.data()); - if (r <= 0) { - throw Error(format( - "The input mesh has flipped UVs:\n p0=({:.3g})\n p1=({:.3g})\n p2=(" - "{:.3g})\n" - "Please fix the input mesh before proceeding.", - join(p0, ", "), - join(p1, ", "), - join(p2, ", "))); - } - } -} - // // Jitters texel coordinates to avoid creating rank-deficient systems when a texture vertex falls // exactly on a texel center. @@ -430,13 +379,22 @@ MeshWrapper create_mesh_wrapper( weld_indexed_attribute(_mesh, texcoord_id); } - // Make sure that the number of corners is equal to (K+1) time sthe number of simplices + // Make sure that the number of corners is equal to (K+1) times the number of simplices la_runtime_assert( _mesh.get_num_corners() == _mesh.get_num_facets() * (K + 1), - "Numer of corners doesn't match the number of simplices"); + "Number of corners doesn't match the number of simplices"); if (check_flipped_uv == CheckFlippedUV::Yes) { - check_for_flipped_uv(_mesh, texcoord_id); + const std::string uv_name(_mesh.get_attribute_name(texcoord_id)); + UVOrientationOptions orient_options; + orient_options.uv_attribute_name = uv_name; + const auto orient_counts = compute_uv_orientation(_mesh, orient_options); + if (orient_counts.negative > 0) { + throw Error(format( + "The input mesh has {} flipped UV triangle(s). Please fix the input mesh " + "before proceeding.", + orient_counts.negative)); + } } wrapper.vertices = _mesh.get_vertex_to_position().get_all(); diff --git a/modules/texproc/tests/test_texture_processing.cpp b/modules/texproc/tests/test_texture_processing.cpp index 749a7812..a735d436 100644 --- a/modules/texproc/tests/test_texture_processing.cpp +++ b/modules/texproc/tests/test_texture_processing.cpp @@ -35,7 +35,7 @@ namespace { std::vector> test_rasterization( const lagrange::SurfaceMesh32f& mesh, - const std::vector& cameras, + const std::vector& cameras, const std::vector& views, size_t width, size_t height) @@ -94,7 +94,7 @@ TEST_CASE("Grid bounds", "[texproc]" LA_SLOW_DEBUG_FLAG LA_CORP_FLAG) scene_options); const auto& [mesh, _] = lagrange::scene::internal::single_mesh_from_scene(scene); - const auto cameras = lagrange::texproc::cameras_from_scene(scene); + const auto cameras = lagrange::scene::internal::camera_transforms_from_scene(scene); REQUIRE(cameras.size() == 16); std::vector views; @@ -127,7 +127,7 @@ TEST_CASE("Pumpkin pipeline", "[texproc]" LA_SLOW_DEBUG_FLAG LA_CORP_FLAG) scene_options); const auto& [mesh, _] = lagrange::scene::internal::single_mesh_from_scene(scene); - const auto cameras = lagrange::texproc::cameras_from_scene(scene); + const auto cameras = lagrange::scene::internal::camera_transforms_from_scene(scene); REQUIRE(cameras.size() == 16); std::vector views; @@ -168,7 +168,7 @@ TEST_CASE("Check benchmark", "[texproc][!benchmark]" LA_CORP_FLAG) scene_options); const auto mesh = std::get<0>(lagrange::scene::internal::single_mesh_from_scene(scene)); - const auto cameras = lagrange::texproc::cameras_from_scene(scene); + const auto cameras = lagrange::scene::internal::camera_transforms_from_scene(scene); REQUIRE(cameras.size() == 16); std::vector views; diff --git a/modules/texproc/tests/test_texture_rasterizer.cpp b/modules/texproc/tests/test_texture_rasterizer.cpp index fac7d7e7..71f9ce09 100644 --- a/modules/texproc/tests/test_texture_rasterizer.cpp +++ b/modules/texproc/tests/test_texture_rasterizer.cpp @@ -144,9 +144,9 @@ TEST_CASE("TextureRasterizer perspective camera", "[texproc]") TextureRasterizer rasterizer(mesh, opts); // Camera at (0.5, 0.5, 2) looking at quad center (0.5, 0.5, 0) - CameraOptions camera; - camera.view_transform = look_at({0.5f, 0.5f, 2.0f}, {0.5f, 0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}); - camera.projection_transform = perspective( + CameraTransforms camera; + camera.view = look_at({0.5f, 0.5f, 2.0f}, {0.5f, 0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}); + camera.projection = perspective( static_cast(2.0 * std::atan(0.5 / 2.0)), // fov to see ~unit width at z=2 1.0f, 0.1f, @@ -188,9 +188,9 @@ TEST_CASE("TextureRasterizer orthographic camera", "[texproc]") TextureRasterizer rasterizer(mesh, opts); // Orthographic camera at (0.5, 0.5, 2) looking at quad center - CameraOptions camera; - camera.view_transform = look_at({0.5f, 0.5f, 2.0f}, {0.5f, 0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}); - camera.projection_transform = ortho( + CameraTransforms camera; + camera.view = look_at({0.5f, 0.5f, 2.0f}, {0.5f, 0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}); + camera.projection = ortho( 1.5f, // width large enough to see the whole quad 1.0f, 0.1f, @@ -238,14 +238,14 @@ TEST_CASE("TextureRasterizer ortho vs perspective confidence consistency", "[tex Eigen::Vector3f up(0.0f, 1.0f, 0.0f); auto view = look_at(eye, center, up); - CameraOptions persp_camera; - persp_camera.view_transform = view; - persp_camera.projection_transform = + CameraTransforms persp_camera; + persp_camera.view = view; + persp_camera.projection = perspective(static_cast(2.0 * std::atan(0.5 / 2.0)), 1.0f, 0.1f, 10.0f); - CameraOptions ortho_camera; - ortho_camera.view_transform = view; - ortho_camera.projection_transform = ortho(1.5f, 1.0f, 0.1f, 10.0f); + CameraTransforms ortho_camera; + ortho_camera.view = view; + ortho_camera.projection = ortho(1.5f, 1.0f, 0.1f, 10.0f); auto render = create_solid_render(render_size, render_size); auto [persp_tex, persp_w] = rasterizer.weighted_texture_from_render(render, persp_camera); @@ -288,9 +288,9 @@ TEST_CASE("TextureRasterizer invalid projection matrix", "[texproc]") P(3, 2) = 0.5f; P(3, 3) = 0.5f; - CameraOptions camera; - camera.view_transform = look_at({0.5f, 0.5f, 2.0f}, {0.5f, 0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}); - camera.projection_transform = Eigen::Projective3f(P); + CameraTransforms camera; + camera.view = look_at({0.5f, 0.5f, 2.0f}, {0.5f, 0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}); + camera.projection = Eigen::Projective3f(P); auto render = create_solid_render(render_size, render_size); LA_REQUIRE_THROWS(rasterizer.weighted_texture_from_render(render, camera)); diff --git a/modules/ui/src/types/Camera_xcode264_workaround.cpp b/modules/ui/src/types/Camera_xcode264_workaround.cpp index 9c086c66..76864022 100644 --- a/modules/ui/src/types/Camera_xcode264_workaround.cpp +++ b/modules/ui/src/types/Camera_xcode264_workaround.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2019 Adobe. All rights reserved. + * 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 diff --git a/modules/ui/src/utils/math_xcode264_workaround.cpp b/modules/ui/src/utils/math_xcode264_workaround.cpp index 70358a45..6f4557db 100644 --- a/modules/ui/src/utils/math_xcode264_workaround.cpp +++ b/modules/ui/src/utils/math_xcode264_workaround.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2020 Adobe. All rights reserved. + * 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 diff --git a/modules/volume/python/src/GridWrapper.h b/modules/volume/python/src/GridWrapper.h new file mode 100644 index 00000000..7ee90ce0 --- /dev/null +++ b/modules/volume/python/src/GridWrapper.h @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +// clang-format off +#include +#include +#include +// clang-format on + +namespace lagrange::python { + +struct GridWrapper +{ + GridWrapper(openvdb::GridBase::Ptr grid) + : m_grid(std::move(grid)) + {} + GridWrapper() = default; + GridWrapper(const GridWrapper&) = default; + GridWrapper(GridWrapper&&) = default; + GridWrapper& operator=(const GridWrapper&) = default; + GridWrapper& operator=(GridWrapper&&) = default; + openvdb::GridBase::ConstPtr grid() const { return m_grid; } + openvdb::GridBase::Ptr& grid() { return m_grid; } + +private: + openvdb::GridBase::Ptr m_grid; +}; + +} // namespace lagrange::python diff --git a/modules/volume/python/src/volume.cpp b/modules/volume/python/src/volume.cpp index f7547a32..a25c7e00 100644 --- a/modules/volume/python/src/volume.cpp +++ b/modules/volume/python/src/volume.cpp @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +#include "GridWrapper.h" + #include #include #include @@ -30,6 +32,8 @@ #include #endif #include +#include +#include #include // clang-format on @@ -79,13 +83,10 @@ auto apply_or_fail_(GridPtrType&& grid, Func&& func) using ReturnType = std::invoke_result_t; if constexpr (std::is_void_v) { // Void return type - bool ok = grid->template apply([&](auto&& real_grid) { - func(std::forward(real_grid)); - return true; - }); - if (!ok) { - throw Error("Unsupported grid type."); - } + const bool apply_ok = AllGrids::apply( + [&](auto&& real_grid) -> void { func(std::forward(real_grid)); }, + *grid); + if (!apply_ok) throw Error("Unsupported grid type."); return; } else { // Non-void return type. To make it work with non-default-constructible types, we @@ -140,22 +141,19 @@ nanovdb::io::Codec to_nanovdb_compression(Compression compression) } #endif -struct GridWrapper + +template +void apply_binary_op(openvdb::GridBase::Ptr& a, openvdb::GridBase::Ptr& b, Op&& op) { - GridWrapper(openvdb::GridBase::Ptr grid) - : m_grid(std::move(grid)) - {} - GridWrapper() = default; - GridWrapper(const GridWrapper&) = default; - GridWrapper(GridWrapper&&) = default; - GridWrapper& operator=(const GridWrapper&) = default; - GridWrapper& operator=(GridWrapper&&) = default; - openvdb::GridBase::ConstPtr grid() const { return m_grid; } - openvdb::GridBase::Ptr& grid() { return m_grid; } - -private: - openvdb::GridBase::Ptr m_grid; -}; + apply_or_fail(a, [&](auto&& a_typed) { + using GridType = std::decay_t; + auto b_typed = openvdb::gridPtrCast(b); + if (!b_typed) { + throw Error("Both grids must have the same scalar type."); + } + op(a_typed, *b_typed); + }); +} template struct GridSampler @@ -353,6 +351,15 @@ void populate_volume_module(nb::module_& m) .value("Zip", Compression::Zip, "Zip compression.") .value("Blosc", Compression::Blosc, "Blosc compression."); + nb::enum_( + m, + "GridClass", + "Grid class tag indicating the semantic interpretation of voxel values") + .value("Unknown", openvdb::GRID_UNKNOWN, "Unknown or generic grid class.") + .value("LevelSet", openvdb::GRID_LEVEL_SET, "Narrow-band signed distance field.") + .value("FogVolume", openvdb::GRID_FOG_VOLUME, "Fog volume (density values).") + .value("Staggered", openvdb::GRID_STAGGERED, "Staggered vector field."); + auto float_type = [] { auto np = nb::module_::import_("numpy"); return np.attr("float32"); @@ -360,6 +367,10 @@ void populate_volume_module(nb::module_& m) nb::class_ g(m, "Grid"); + ////////////////////////////////////////////// + // IO + ////////////////////////////////////////////// + g.def_static( "load", [](std::variant input_path_or_buffer) { @@ -398,6 +409,77 @@ void populate_volume_module(nb::module_& m) "def to_buffer(ext: typing.Literal['vdb', 'nvdb'], compression: Compression = " "Compression.Blosc) -> bytes")); + ////////////////////////////////////////////// + // RW properties + ////////////////////////////////////////////// + + g.def_prop_rw( + "name", + [](const GridWrapper& self) { return self.grid()->getName(); }, + [](GridWrapper& self, std::string_view name) { self.grid()->setName(std::string(name)); }, + "The grid name."); + + + g.def_prop_rw( + "grid_class", + [](GridWrapper& self) { return self.grid()->getGridClass(); }, + [](GridWrapper& self, openvdb::GridClass grid_class) { + self.grid()->setGridClass(grid_class); + }, + "The grid class tag."); + + using ConstMatrix4f = nb::ndarray, nb::device::cpu>; + using ConstMatrix4d = nb::ndarray, nb::device::cpu>; + + g.def_prop_rw( + "transform", + [](const GridWrapper& self) { + // OpenVDB uses row-vector convention (applyMap(x) = x * M). + // Return column-vector convention (translation in last column), so transpose. + auto mat = self.grid()->transform().baseMap()->getAffineMap()->getMat4(); + Eigen::Matrix4d result; + for (int i = 0; i < 4; ++i) + for (int j = 0; j < 4; ++j) result(i, j) = mat(j, i); + return result; + }, + [](GridWrapper& self, std::variant matrix) { + // Input matrix uses column-vector convention (translation in last column). + // OpenVDB uses row-vector convention (applyMap(x) = x * M), so transpose. + std::visit( + [&](auto&& mat_in) { + auto t = mat_in.view(); + // clang-format off + openvdb::math::Mat4d mat( + t(0, 0), t(1, 0), t(2, 0), t(3, 0), + t(0, 1), t(1, 1), t(2, 1), t(3, 1), + t(0, 2), t(1, 2), t(2, 2), t(3, 2), + t(0, 3), t(1, 3), t(2, 3), t(3, 3)); + // clang-format on + self.grid()->setTransform(openvdb::math::Transform::createLinearTransform(mat)); + }, + matrix); + }, + "The grid's index-to-world 4x4 affine transform matrix (column-vector convention)."); + + g.def_prop_rw( + "background", + [](const GridWrapper& self) { + std::variant result; + apply_or_fail(self.grid(), [&](auto&& grid) { result = grid.background(); }); + return result; + }, + [](GridWrapper& self, double value) { + apply_or_fail(self.grid(), [&](auto&& grid) { + using GridScalar = typename std::decay_t::ValueType; + openvdb::tools::changeBackground(grid.tree(), static_cast(value)); + }); + }, + "The grid background value."); + + ////////////////////////////////////////////// + // RO properties + ////////////////////////////////////////////// + g.def_prop_ro( "voxel_size", [](const GridWrapper& self) { @@ -413,15 +495,6 @@ void populate_volume_module(nb::module_& m) [](const GridWrapper& self) { return self.grid()->activeVoxelCount(); }, "Return the number of active voxels in the grid."); - g.def_prop_ro( - "background", - [](const GridWrapper& self) { - std::variant result; - apply_or_fail(self.grid(), [&](auto&& grid) { result = grid.background(); }); - return result; - }, - "Return the grid background value."); - g.def_prop_ro( "bbox_index", [](const GridWrapper& self) { @@ -447,6 +520,10 @@ void populate_volume_module(nb::module_& m) "Return the axis-aligned bounding box of all active voxels in world space. If the grid is " "empty a default bbox is returned."); + ////////////////////////////////////////////// + // Methods + ////////////////////////////////////////////// + g.def( "index_to_world", [](const GridWrapper& self, @@ -573,6 +650,14 @@ void populate_volume_module(nb::module_& m) :param offset_radius: Offset radius. A negative value dilates the surface, a positive value erodes it. :param relative: Whether the offset radius is relative to the grid voxel size.)"); + g.def( + "prune", + [](GridWrapper& self, const float tolerance) { self.grid()->pruneGrid(tolerance); }, + "tolerance"_a = 0.0f, + R"(Remove nodes whose values all equal the background value within the given tolerance. + +:param tolerance: Tolerance for pruning. Default is 0.)"); + g.def( "sample_trilinear_index_space", [](const GridWrapper& self, std::variant indices) @@ -633,45 +718,6 @@ void populate_volume_module(nb::module_& m) :returns: Sampled values as an (N,) array of double.)"); - using MeshToVolumeOptions = lagrange::volume::MeshToVolumeOptions; - g.def_static( - "from_mesh", - [](const SurfaceMesh& mesh, - double voxel_size, - Sign signing_method, - nb::type_object dtype) { - lagrange::volume::MeshToVolumeOptions options; - options.voxel_size = voxel_size; - options.signing_method = signing_method; - - auto run = [&](auto&& grid_scalar) -> GridWrapper { - using GridScalar = std::decay_t; - auto grid = lagrange::volume::mesh_to_volume(mesh, options); - return GridWrapper{grid}; - }; - - auto np = nb::module_::import_("numpy"); - if (dtype.is(np.attr("float32"))) { - return run(float(0)); - } else if (dtype.is(np.attr("float64")) || dtype.is(&PyLong_Type)) { - return run(double(0)); - } else { - throw nb::type_error("Unsupported grid `dtype`!"); - } - }, - "mesh"_a, - "voxel_size"_a = MeshToVolumeOptions().voxel_size, - "signing_method"_a = MeshToVolumeOptions().signing_method, - "dtype"_a = float_type, - R"(Convert a triangle mesh to a sparse voxel grid, writing the result to a file. - -:param mesh: Input mesh. Must be a triangle mesh, a quad-mesh, or a quad-dominant mesh. -:param voxel_size: Voxel size. Negative means relative to bbox diagonal (`vs -> -vs * bbox_diag`). -:param signing_method: Method used to compute the sign of the distance field. -:param dtype: Scalar type of the output grid (float32 or float64). - -:returns: Generated sparse voxel grid.)"); - g.def( "to_mesh", [](const GridWrapper& self, @@ -709,6 +755,170 @@ void populate_volume_module(nb::module_& m) :param normal_attribute_name: If provided, computes vertex normals from the volume and store them in the appropriately named attribute. :returns: Meshed isosurface.)"); + + ////////////////////////////////////////////// + // Binary operations + ////////////////////////////////////////////// + + auto bind_binary_op = [&](const char* name, auto&& op, const char* doc) { + g.def( + name, + [captured_op = std::forward(op)](GridWrapper& self, GridWrapper& other) { + apply_binary_op(self.grid(), other.grid(), captured_op); + }, + "other"_a, + doc); + }; + + bind_binary_op( + "csg_union", + [](auto& a, auto& b) { openvdb::tools::csgUnion(a, b); }, + R"(Replace this level set grid with the CSG union of itself and ``other``. + +:param other: Level set grid of the same scalar type. Left empty after the call.)"); + + bind_binary_op( + "csg_intersection", + [](auto& a, auto& b) { openvdb::tools::csgIntersection(a, b); }, + R"(Replace this level set grid with the CSG intersection of itself and ``other``. + +:param other: Level set grid of the same scalar type. Left empty after the call.)"); + + bind_binary_op( + "csg_difference", + [](auto& a, auto& b) { openvdb::tools::csgDifference(a, b); }, + R"(Replace this level set grid with the CSG difference ``self`` minus ``other``. + +:param other: Level set grid of the same scalar type. Left empty after the call.)"); + + bind_binary_op( + "comp_min", + [](auto& a, auto& b) { openvdb::tools::compMin(a, b); }, + R"(Per-voxel ``min(self, other)``, stored in ``self``. ``other`` is left empty. + +:param other: Grid of the same scalar type.)"); + + bind_binary_op( + "comp_max", + [](auto& a, auto& b) { openvdb::tools::compMax(a, b); }, + R"(Per-voxel ``max(self, other)``, stored in ``self``. ``other`` is left empty. + +:param other: Grid of the same scalar type.)"); + + bind_binary_op( + "comp_sum", + [](auto& a, auto& b) { openvdb::tools::compSum(a, b); }, + R"(Per-voxel ``self + other``, stored in ``self``. ``other`` is left empty. + +:param other: Grid of the same scalar type.)"); + + bind_binary_op( + "comp_mul", + [](auto& a, auto& b) { openvdb::tools::compMul(a, b); }, + R"(Per-voxel ``self * other``, stored in ``self``. ``other`` is left empty. + +:param other: Grid of the same scalar type.)"); + + bind_binary_op( + "comp_div", + [](auto& a, auto& b) { openvdb::tools::compDiv(a, b); }, + R"(Per-voxel ``self / other``, stored in ``self``. ``other`` is left empty. + +:param other: Grid of the same scalar type.)"); + + ////////////////////////////////////////////// + // Factory methods + ////////////////////////////////////////////// + + using ConstVectorF = nb::ndarray, nb::c_contig, nb::device::cpu>; + using ConstVectorD = nb::ndarray, nb::c_contig, nb::device::cpu>; + + g.def_static( + "from_points", + [](ConstArray3i points, + std::variant values, + nb::type_object dtype) -> GridWrapper { + auto p = points.view(); + + auto run = [&](auto grid_scalar) -> GridWrapper { + using GridScalar = decltype(grid_scalar); + using GridType = lagrange::volume::Grid; + auto grid = GridType::create(); + auto accessor = grid->getAccessor(); + std::visit( + [&](auto&& vals) { + auto vv = vals.view(); + la_runtime_assert( + p.shape(0) == vv.shape(0), + "points and values must have the same length"); + for (size_t i = 0; i < p.shape(0); ++i) { + accessor.setValue( + openvdb::Coord(p(i, 0), p(i, 1), p(i, 2)), + static_cast(vv(i))); + } + }, + values); + return GridWrapper{grid}; + }; + + auto np = nb::module_::import_("numpy"); + if (dtype.is(np.attr("float32"))) { + return run(float(0)); + } else if (dtype.is(np.attr("float64")) || dtype.is(&PyFloat_Type)) { + return run(double(0)); + } else { + throw nb::type_error("Unsupported grid `dtype`!"); + } + }, + "points"_a, + "values"_a, + "dtype"_a = float_type, + R"(Create a sparse voxel grid from a list of voxel index coordinates and values. + +:param points: Voxel index-space coordinates as an (N, 3) array of int32. +:param values: Values at each voxel as a 1D array of float32 or float64 of length N. +:param dtype: Scalar type of the output grid (float32 or float64). + +:returns: Sparse voxel grid with the given values active at the given index coordinates.)"); + + using MeshToVolumeOptions = lagrange::volume::MeshToVolumeOptions; + g.def_static( + "from_mesh", + [](const SurfaceMesh& mesh, + double voxel_size, + Sign signing_method, + nb::type_object dtype) { + lagrange::volume::MeshToVolumeOptions options; + options.voxel_size = voxel_size; + options.signing_method = signing_method; + + auto run = [&](auto&& grid_scalar) -> GridWrapper { + using GridScalar = std::decay_t; + auto grid = lagrange::volume::mesh_to_volume(mesh, options); + return GridWrapper{grid}; + }; + + auto np = nb::module_::import_("numpy"); + if (dtype.is(np.attr("float32"))) { + return run(float(0)); + } else if (dtype.is(np.attr("float64")) || dtype.is(&PyFloat_Type)) { + return run(double(0)); + } else { + throw nb::type_error("Unsupported grid `dtype`!"); + } + }, + "mesh"_a, + "voxel_size"_a = MeshToVolumeOptions().voxel_size, + "signing_method"_a = MeshToVolumeOptions().signing_method, + "dtype"_a = float_type, + R"(Convert a triangle mesh to a sparse voxel grid, writing the result to a file. + +:param mesh: Input mesh. Must be a triangle mesh, a quad-mesh, or a quad-dominant mesh. +:param voxel_size: Voxel size. Negative means relative to bbox diagonal (`vs -> -vs * bbox_diag`). +:param signing_method: Method used to compute the sign of the distance field. +:param dtype: Scalar type of the output grid (float32 or float64). + +:returns: Generated sparse voxel grid.)"); } } // namespace lagrange::python diff --git a/modules/volume/python/tests/test_volume.py b/modules/volume/python/tests/test_volume.py index 557d53f9..452d3ee6 100644 --- a/modules/volume/python/tests/test_volume.py +++ b/modules/volume/python/tests/test_volume.py @@ -13,13 +13,14 @@ from lagrange.volume import Grid import numpy as np import tempfile -import pathlib +from pathlib import Path +import pytest class TestMeshToVolume: def test_bbox(self, cube): mesh = cube.clone() - for dtype in [np.float32, np.float64]: + for dtype in [np.float32, np.float64, float]: grid = Grid.from_mesh(mesh, dtype=dtype) assert grid.voxel_size.shape == (3,) assert grid.num_active_voxels > 0 @@ -31,7 +32,8 @@ def test_bbox(self, cube): assert np.allclose(grid.bbox_index, grid.world_to_index(grid.bbox_world)) assert np.allclose(grid.bbox_world, grid.index_to_world(grid.bbox_index)) assert np.allclose( - grid.bbox_world, grid.index_to_world(grid.bbox_index.astype(np.float64)) + grid.bbox_world, + grid.index_to_world(grid.bbox_index.astype(np.float64)), ) def test_sampling(self, cube): @@ -67,48 +69,222 @@ def test_sampling(self, cube): def test_cube(self, cube): mesh = cube.clone() with tempfile.TemporaryDirectory() as tmp_dir: - tmp_dir_path = pathlib.Path(tmp_dir) + tmp_dir = Path(tmp_dir) for ext in ["vdb", "nvdb"]: - tmp_file_path = tmp_dir_path / f"out.{ext}" for comp in [ lagrange.volume.Compression.Uncompressed, lagrange.volume.Compression.Zip, lagrange.volume.Compression.Blosc, ]: grid = Grid.from_mesh(mesh) - grid.save(tmp_file_path, compression=comp) + tmp_path = tmp_dir / f"out_{comp}.{ext}" + grid.save(tmp_path, compression=comp) buffer = grid.to_buffer(grid_type=ext, compression=comp) assert type(buffer) is bytes - mesh1 = Grid.load(tmp_file_path).to_mesh() + mesh1 = Grid.load(tmp_path).to_mesh() mesh2 = Grid.load(buffer).to_mesh() assert mesh1.num_vertices > 0 assert mesh1.num_facets > 0 assert mesh1.num_vertices == mesh2.num_vertices assert mesh1.num_facets == mesh2.num_facets - def test_offset(self, cube): - mesh = cube.clone() - for signing in [ - lagrange.volume.Sign.FloodFill, - lagrange.volume.Sign.WindingNumber, - lagrange.volume.Sign.Unsigned, + @pytest.mark.parametrize("model_name", ["cube", "triangle", "house"]) + def test_offset_in_place(self, model_name, request): + mesh = request.getfixturevalue(model_name).clone() + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir = Path(tmp_dir) + for signing in [ + lagrange.volume.Sign.FloodFill, + lagrange.volume.Sign.WindingNumber, + lagrange.volume.Sign.Unsigned, + ]: + for offset in [-1.0, 1.0]: + grid = Grid.from_mesh(mesh, signing_method=signing) + active_before = grid.num_active_voxels + grid.offset_in_place(offset, relative=True) + active_after = grid.num_active_voxels + signing_names = { + lagrange.volume.Sign.FloodFill: "flood_fill", + lagrange.volume.Sign.WindingNumber: "winding_number", + lagrange.volume.Sign.Unsigned: "unsigned", + } + offset_names = {-1.0: "erode", 1.0: "dilate"} + tmp_path = ( + tmp_dir + / f"offset_{model_name}_{signing_names[signing]}_{offset_names[offset]}.vdb" + ) + grid.save(tmp_path) + if signing == lagrange.volume.Sign.Unsigned: + assert active_before == active_after + else: + if offset < 0: + assert active_after > active_before + else: + assert active_after < active_before + + def test_from_points(self): + points = np.array( + [ + [0, 0, 0], + [1, 2, 3], + [-2, 5, 7], + ], + dtype=np.int32, + ) + values = np.array([1.0, -2.5, 3.25], dtype=np.float32) + + for grid_dtype in [np.float32, np.float64, float]: + for values_dtype in [np.float32, np.float64]: + grid = Grid.from_points(points, values.astype(values_dtype), dtype=grid_dtype) + assert grid.num_active_voxels == points.shape[0] + assert np.array_equal(grid.bbox_index[0], points.min(axis=0)) + assert np.array_equal(grid.bbox_index[1], points.max(axis=0)) + sampled = grid.sample_trilinear_index_space(points) + assert sampled.dtype == np.dtype(grid_dtype) + assert np.allclose(sampled, values) + + def test_from_points_length_mismatch(self): + import pytest + + points = np.zeros((3, 3), dtype=np.int32) + values = np.zeros(2, dtype=np.float32) + with pytest.raises(RuntimeError): + Grid.from_points(points, values) + + def test_set_name(self, cube): + grid = Grid.from_mesh(cube.clone()) + grid.name = "my_grid" + assert grid.name == "my_grid" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) / "named.vdb" + grid.save(tmp_path) + grid_ = Grid.load(tmp_path) + assert grid_.name == "my_grid" + + def test_set_transform(self): + points = np.array([[0, 0, 0], [2, 0, 0], [0, 4, 0]], dtype=np.int32) + values = np.array([1.0, 2.0, 3.0], dtype=np.float32) + + grid = Grid.from_points(points, values) + # Identity transform -> unit voxel size, index == world. + assert np.allclose(grid.voxel_size, [1.0, 1.0, 1.0]) + assert np.allclose(grid.index_to_world(points), points.astype(np.float64)) + + # Uniform scale 0.5 + translation [1, 2, 3], column-vector convention. + # Accepts both float32 and float64 matrices. + for mat_dtype in [np.float32, np.float64]: + xform = np.eye(4, dtype=mat_dtype) + xform[:3, :3] *= 0.5 + xform[:3, 3] = [1.0, 2.0, 3.0] + grid.transform = xform + assert np.allclose(grid.transform, xform) + assert np.allclose(grid.voxel_size, [0.5, 0.5, 0.5]) + expected_world = points.astype(np.float64) * 0.5 + np.array([1.0, 2.0, 3.0]) + assert np.allclose(grid.index_to_world(points), expected_world) + assert np.allclose(grid.world_to_index(expected_world), points.astype(np.float64)) + + def test_set_background(self): + points = np.array([[0, 0, 0]], dtype=np.int32) + values = np.array([1.0], dtype=np.float32) + inactive = np.array([[100, 100, 100]], dtype=np.int32) + + for grid_dtype in [np.float32, np.float64]: + grid = Grid.from_points(points, values, dtype=grid_dtype) + assert grid.background == 0.0 + + grid.background = -7.5 + assert grid.background == -7.5 + # Inactive voxels return the new background. + assert np.allclose(grid.sample_trilinear_index_space(inactive), -7.5) + # Active voxel still holds its set value. + assert np.allclose(grid.sample_trilinear_index_space(points), values) + + def test_set_grid_class(self, cube): + for grid_class in [ + lagrange.volume.GridClass.Unknown, + lagrange.volume.GridClass.LevelSet, + lagrange.volume.GridClass.FogVolume, + lagrange.volume.GridClass.Staggered, + ]: + grid = Grid.from_mesh(cube.clone()) + grid.grid_class = grid_class + assert grid.grid_class == grid_class + + def test_prune(self, cube): + grid = Grid.from_mesh(cube.clone()) + active_before = grid.num_active_voxels + grid.prune() + active_after = grid.num_active_voxels + assert active_after <= active_before + + def test_csg(self, cube): + # Build two overlapping level-set cubes and verify each CSG op changes + # the active voxel count in the expected direction relative to the + # individual inputs. + for op_name, predicate in [ + ("csg_union", lambda u, a, b: u >= max(a, b)), + ("csg_intersection", lambda i, a, b: i <= min(a, b)), + ("csg_difference", lambda d, a, b: d <= a), ]: - for dilate in [True, False]: - if dilate: - offset = -1 - else: - offset = 1 - grid = Grid.from_mesh(mesh, signing_method=signing) - active_before = grid.num_active_voxels - grid.offset_in_place(offset, relative=True) - active_after = grid.num_active_voxels - if signing == lagrange.volume.Sign.Unsigned: - assert active_before == active_after - else: - if dilate: - assert active_after > active_before - else: - assert active_after < active_before + a = Grid.from_mesh(cube.clone()) + b = Grid.from_mesh(cube.clone()) + count_a = a.num_active_voxels + count_b = b.num_active_voxels + getattr(a, op_name)(b) + assert predicate(a.num_active_voxels, count_a, count_b), op_name + + def test_csg_dtype_mismatch(self, cube): + import pytest + + a = Grid.from_mesh(cube.clone(), dtype=np.float32) + b = Grid.from_mesh(cube.clone(), dtype=np.float64) + with pytest.raises(Exception): + a.csg_union(b) + + def test_comp_ops(self): + # Two grids with one shared voxel and one disjoint voxel each, so we + # can validate per-voxel arithmetic. + shared = np.array([[0, 0, 0]], dtype=np.int32) + only_a = np.array([[1, 0, 0]], dtype=np.int32) + only_b = np.array([[0, 1, 0]], dtype=np.int32) + + def make_pair(va_shared, va_only, vb_shared, vb_only): + a = Grid.from_points( + np.vstack([shared, only_a]), + np.array([va_shared, va_only], dtype=np.float32), + ) + b = Grid.from_points( + np.vstack([shared, only_b]), + np.array([vb_shared, vb_only], dtype=np.float32), + ) + return a, b + + # comp_min + a, b = make_pair(3.0, 5.0, 1.0, 7.0) + a.comp_min(b) + assert np.allclose(a.sample_trilinear_index_space(shared), 1.0) + + # comp_max + a, b = make_pair(3.0, 5.0, 1.0, 7.0) + a.comp_max(b) + assert np.allclose(a.sample_trilinear_index_space(shared), 3.0) + + # comp_sum + a, b = make_pair(3.0, 5.0, 1.0, 7.0) + a.comp_sum(b) + assert np.allclose(a.sample_trilinear_index_space(shared), 4.0) + assert np.allclose(a.sample_trilinear_index_space(only_a), 5.0) + assert np.allclose(a.sample_trilinear_index_space(only_b), 7.0) + + # comp_mul + a, b = make_pair(3.0, 5.0, 4.0, 7.0) + a.comp_mul(b) + assert np.allclose(a.sample_trilinear_index_space(shared), 12.0) + + # comp_div + a, b = make_pair(12.0, 5.0, 4.0, 1.0) + a.comp_div(b) + assert np.allclose(a.sample_trilinear_index_space(shared), 3.0) def test_dense(self, cube): mesh = cube.clone()