From e82a2aff8066ac3a3542265d664c8cba6a7b29af Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Mon, 27 Apr 2026 11:56:17 -0400 Subject: [PATCH 1/6] Sync with cc00acc --- .github/workflows/continuous.yaml | 3 +- .github/workflows/wheel.yaml | 2 +- .gitignore | 9 + CMakeLists.txt | 32 +- LagrangeOptions.cmake.sample | 5 +- cmake/lagrange/lagrange_add_js_binding.cmake | 104 +++ .../lagrange_add_python_binding.cmake | 2 +- .../lagrange/lagrange_js_emit_registry.cmake | 63 ++ cmake/recipes/external/OpenVDB.cmake | 4 +- cmake/recipes/external/blosc.cmake | 4 +- cmake/recipes/external/cista.cmake | 8 +- cmake/recipes/external/cpptrace.cmake | 1 + cmake/recipes/external/miniz.cmake | 12 +- .../external/texture_signal_processing.cmake | 4 +- modules/CMakeLists.txt | 3 + modules/bvh/CMakeLists.txt | 4 + modules/bvh/examples/uv_overlap.cpp | 87 +- .../bvh/include/lagrange/bvh/BVHNanoflann.h | 19 +- .../include/lagrange/bvh/TriangleAABBTree.h | 7 + .../lagrange/bvh/compute_intersecting_pairs.h | 54 ++ .../lagrange/bvh/compute_mesh_distances.h | 26 +- modules/bvh/js/CMakeLists.txt | 12 + modules/bvh/js/src/bvh.cpp | 82 ++ modules/bvh/js/test/bvh.test.ts | 60 ++ modules/bvh/js/ts/bvh.ts | 69 ++ modules/bvh/python/src/bvh.cpp | 36 + .../tests/test_compute_intersecting_pairs.py | 139 +++ .../bvh/src/compute_intersecting_pairs.cpp | 174 ++++ modules/bvh/src/compute_mesh_distances.cpp | 100 +- modules/bvh/src/compute_uv_overlap.cpp | 101 +- .../tests/test_compute_intersecting_pairs.cpp | 294 ++++++ .../bvh/tests/test_compute_mesh_distances.cpp | 113 +++ modules/bvh/tests/test_compute_uv_overlap.cpp | 84 +- modules/core/CMakeLists.txt | 4 + modules/core/include/lagrange/Edge.h | 5 + .../core/include/lagrange/ExactPredicates.h | 24 +- .../lagrange/ExactPredicatesShewchuk.h | 16 +- .../core/include/lagrange/attribute_names.h | 7 + .../compute_barycentric_coordinates.h | 102 +- .../core/include/lagrange/compute_uv_charts.h | 2 +- .../include/lagrange/disconnect_uv_charts.h | 60 ++ .../lagrange/get_unique_attribute_name.h | 72 ++ .../internal/get_unique_attribute_name.h | 54 -- .../lagrange/internal/get_uv_attribute.h | 21 +- .../lagrange/internal/invert_mapping.h | 3 +- .../attributes/condense_indexed_attribute.h | 3 +- .../mesh_cleanup/remove_short_edges.h | 35 + .../core/include/lagrange/mesh_convert.impl.h | 6 +- .../lagrange/triangulate_polygonal_facets.h | 8 + modules/core/include/lagrange/utils/assert.h | 7 +- .../utils/compute_normal_cotransform.h | 84 ++ .../core/include/lagrange/utils/fmt/format.h | 60 ++ .../core/include/lagrange/utils/fmt/join.h | 175 ++++ .../core/include/lagrange/utils/fmt/print.h | 51 + .../core/include/lagrange/utils/fmt_eigen.h | 26 +- .../core/include/lagrange/utils/safe_cast.h | 1 + modules/core/include/lagrange/utils/strings.h | 12 +- .../utils/triangle_triangle_intersection.h | 72 ++ modules/core/include/lagrange/utils/warning.h | 8 +- modules/core/include/lagrange/uv_mesh.h | 20 + modules/core/js/CMakeLists.txt | 12 + modules/core/js/src/core.cpp | 206 +++++ modules/core/js/src/core_utilities.cpp | 250 +++++ modules/core/js/src/mesh_cleanup.cpp | 63 ++ modules/core/js/test/SurfaceMesh.test.ts | 156 ++++ modules/core/js/test/bindings.test.ts | 42 + modules/core/js/test/core.test.ts | 46 + modules/core/js/test/core_utilities.test.ts | 261 ++++++ modules/core/js/ts/core.ts | 407 ++++++++ .../include/lagrange/python/eigen_utils.h | 4 +- modules/core/python/scripts/meshstat.py | 58 +- modules/core/python/src/bind_attribute.h | 5 +- modules/core/python/src/bind_mesh_cleanup.h | 15 +- modules/core/python/src/bind_surface_mesh.h | 11 +- modules/core/python/src/bind_utilities.h | 74 +- modules/core/python/src/tensor_utils.cpp | 7 +- .../python/tests/test_disconnect_uv_charts.py | 183 ++++ .../tests/test_get_unique_attribute_name.py | 118 +++ .../python/tests/test_remove_short_edges.py | 6 +- modules/core/src/Attribute.cpp | 12 +- modules/core/src/ExactPredicates.cpp | 2 +- modules/core/src/ExactPredicatesShewchuk.cpp | 27 +- modules/core/src/SurfaceMesh.cpp | 63 +- .../core/src/compute_tangent_bitangent.cpp | 18 +- modules/core/src/compute_uv_charts.cpp | 18 +- modules/core/src/compute_uv_tile_list.cpp | 1 + modules/core/src/disconnect_uv_charts.cpp | 183 ++++ .../core/src/get_unique_attribute_name.cpp | 55 ++ modules/core/src/internal/cpu_features.cpp | 1 + .../internal/extract_submeshes_by_group.cpp | 311 +++++++ .../src/internal/extract_submeshes_by_group.h | 50 + .../src/internal/find_attribute_utils.cpp | 9 +- .../core/src/internal/get_uv_attribute.cpp | 21 +- modules/core/src/internal/map_attributes.cpp | 8 +- modules/core/src/isoline.cpp | 8 +- modules/core/src/map_attribute.cpp | 5 +- .../src/mesh_cleanup/remove_short_edges.cpp | 418 ++++++++- modules/core/src/remap_vertices.cpp | 15 +- modules/core/src/separate_by_facet_groups.cpp | 34 +- modules/core/src/thicken_and_close_mesh.cpp | 7 +- modules/core/src/transform_mesh.cpp | 76 +- .../core/src/triangulate_polygonal_facets.cpp | 32 +- modules/core/src/unify_index_buffer.cpp | 4 +- modules/core/src/utils/DisjointSets.cpp | 3 +- modules/core/src/utils/assert.cpp | 3 +- .../utils/triangle_triangle_intersection.cpp | 722 +++++++++++++++ modules/core/src/uv_mesh.cpp | 31 +- modules/core/tests/fmt/test_fmt.cpp | 2 +- .../mesh_cleanup/test_remove_short_edges.cpp | 177 ++++ modules/core/tests/test_assert.cpp | 5 +- .../test_compute_barycentric_coordinates.cpp | 490 ++++++++++ modules/core/tests/test_compute_normal.cpp | 5 +- .../tests/test_compute_tangent_bitangent.cpp | 5 +- modules/core/tests/test_compute_uv_charts.cpp | 64 ++ .../core/tests/test_disconnect_uv_charts.cpp | 483 ++++++++++ modules/core/tests/test_foreach_attribute.cpp | 37 +- .../tests/test_separate_by_facet_groups.cpp | 697 ++++++++++++++ modules/core/tests/test_surface_mesh.cpp | 37 +- .../test_triangle_triangle_intersection.cpp | 872 ++++++++++++++++++ .../core/tests/test_unify_index_buffer.cpp | 22 + modules/core/tests/test_uv_mesh.cpp | 126 +++ modules/filtering/CMakeLists.txt | 4 + modules/filtering/js/CMakeLists.txt | 12 + modules/filtering/js/src/filtering.cpp | 72 ++ modules/filtering/js/test/filtering.test.ts | 39 + modules/filtering/js/ts/filtering.ts | 67 ++ modules/fs/src/file_utils.cpp | 1 + .../lagrange/geodesic/GeodesicEngine.h | 60 ++ .../lagrange/geodesic/GeodesicEngineMMP.h | 13 + modules/geodesic/python/src/geodesic.cpp | 34 + modules/geodesic/python/tests/conftest.py | 35 + .../geodesic/python/tests/test_geodesic.py | 193 ++-- modules/geodesic/src/GeodesicEngine.cpp | 12 + modules/geodesic/src/GeodesicEngineMMP.cpp | 63 ++ modules/geodesic/tests/CMakeLists.txt | 3 + modules/geodesic/tests/test_geodesic_path.cpp | 244 +++++ .../lagrange/image_io/save_image_svg.h | 7 +- modules/image_io/src/save_image.cpp | 3 +- modules/io/CMakeLists.txt | 4 + modules/io/js/CMakeLists.txt | 12 + modules/io/js/src/io.cpp | 214 +++++ modules/io/js/test/io.test.ts | 97 ++ modules/io/js/ts/io.ts | 90 ++ modules/io/python/src/io.cpp | 5 +- modules/io/src/load_fbx.cpp | 5 +- modules/io/src/load_gltf.cpp | 37 +- modules/io/src/load_mesh_msh.cpp | 5 +- modules/io/src/load_mesh_ply.cpp | 29 +- modules/io/src/load_mesh_stl.cpp | 3 +- modules/io/src/load_obj.cpp | 109 ++- modules/io/src/save_gltf.cpp | 18 +- modules/io/src/save_mesh_msh.cpp | 9 +- modules/io/src/save_mesh_ply.cpp | 45 +- modules/io/src/save_obj.cpp | 183 +++- modules/io/src/stitch_mesh.h | 9 + modules/io/tests/test_io.cpp | 10 +- modules/io/tests/test_load_scene.cpp | 34 + modules/io/tests/test_obj.cpp | 571 +++++++++++- modules/packing/src/repack_uv_charts.cpp | 99 +- .../packing/tests/test_repack_uv_charts.cpp | 264 ++++++ .../examples/partition_mesh_vertices.cpp | 4 +- modules/poisson/src/octree_depth.h | 3 + modules/polyscope/examples/mesh_viewer.cpp | 5 +- modules/primitive/CMakeLists.txt | 4 + .../primitive/legacy/generate_swept_surface.h | 3 +- modules/primitive/js/CMakeLists.txt | 12 + modules/primitive/js/src/primitive.cpp | 299 ++++++ modules/primitive/js/test/primitive.test.ts | 197 ++++ modules/primitive/js/ts/primitive.ts | 266 ++++++ .../src/generate_subdivided_sphere.cpp | 7 +- .../primitive/src/generate_swept_surface.cpp | 3 +- modules/python/CMakeLists.txt | 12 +- modules/raycasting/examples/picking_demo.cpp | 5 +- .../legacy/project_attributes_closest_point.h | 1 - modules/raycasting/src/RayCaster.cpp | 8 +- .../raycasting/src/project_directional.cpp | 8 +- .../tests/test_project_attributes.cpp | 1 + .../tests/test_raycasting_speed.cpp | 5 +- modules/scene/CMakeLists.txt | 4 + .../lagrange/scene/internal/shared_utils.h | 17 +- .../include/lagrange/scene/scene_utils.h | 25 +- modules/scene/js/CMakeLists.txt | 12 + modules/scene/js/src/scene.cpp | 50 + modules/scene/js/ts/scene.ts | 37 + modules/scene/src/internal/bake_scaling.cpp | 3 +- .../scene/src/internal/scene_string_utils.cpp | 325 +++---- modules/scene/src/scene_utils.cpp | 23 +- modules/scene/tests/test_camera.cpp | 77 +- modules/serialization2/CMakeLists.txt | 4 + modules/serialization2/js/CMakeLists.txt | 1 + .../serialization2/js/src/serialization2.cpp | 96 ++ .../js/test/serialization.test.ts | 89 ++ modules/serialization2/js/ts/serialization.ts | 59 ++ .../src/serialize_simple_scene.cpp | 48 +- modules/subdivision/CMakeLists.txt | 4 + modules/subdivision/js/CMakeLists.txt | 12 + modules/subdivision/js/src/subdivision.cpp | 103 +++ .../subdivision/js/test/subdivision.test.ts | 60 ++ modules/subdivision/js/ts/subdivision.ts | 102 ++ .../subdivision/src/TopologyRefinerFactory.h | 9 +- modules/subdivision/src/subdivide_mesh.cpp | 27 +- .../examples/extract_mesh_with_alpha_mask.cpp | 3 +- modules/texproc/examples/io_helpers.h | 2 +- .../examples/texture_processing_gui.cpp | 5 +- .../examples/texture_rasterization.cpp | 105 ++- modules/texproc/shared/shared_utils.h | 107 ++- modules/texproc/src/TextureRasterizer.cpp | 78 +- modules/texproc/src/mesh_utils.h | 17 +- modules/texproc/src/texture_compositing.cpp | 55 +- .../tests/test_mesh_with_alpha_mask.cpp | 5 +- .../texproc/tests/test_texture_filtering.cpp | 5 +- .../texproc/tests/test_texture_processing.cpp | 7 +- .../texproc/tests/test_texture_rasterizer.cpp | 297 ++++++ .../texproc/tests/test_texture_stitching.cpp | 5 +- modules/ui/src/panels/LoggerPanel.cpp | 3 +- modules/ui/src/types/Camera.cpp | 69 +- .../src/types/Camera_xcode264_workaround.cpp | 124 +++ modules/ui/src/utils/math.cpp | 17 +- .../ui/src/utils/math_xcode264_workaround.cpp | 72 ++ .../include/lagrange/volume/mesh_to_volume.h | 9 +- modules/volume/src/mesh_to_volume.cpp | 37 +- modules/volume/tests/test_voxelization.cpp | 160 ++++ modules/winding/examples/fix_orientation.cpp | 6 +- .../examples/sample_points_in_mesh.cpp | 5 +- 224 files changed, 14471 insertions(+), 1204 deletions(-) create mode 100644 cmake/lagrange/lagrange_add_js_binding.cmake create mode 100644 cmake/lagrange/lagrange_js_emit_registry.cmake create mode 100644 modules/bvh/include/lagrange/bvh/compute_intersecting_pairs.h create mode 100644 modules/bvh/js/CMakeLists.txt create mode 100644 modules/bvh/js/src/bvh.cpp create mode 100644 modules/bvh/js/test/bvh.test.ts create mode 100644 modules/bvh/js/ts/bvh.ts create mode 100644 modules/bvh/python/tests/test_compute_intersecting_pairs.py create mode 100644 modules/bvh/src/compute_intersecting_pairs.cpp create mode 100644 modules/bvh/tests/test_compute_intersecting_pairs.cpp create mode 100644 modules/core/include/lagrange/disconnect_uv_charts.h create mode 100644 modules/core/include/lagrange/get_unique_attribute_name.h delete mode 100644 modules/core/include/lagrange/internal/get_unique_attribute_name.h create mode 100644 modules/core/include/lagrange/utils/compute_normal_cotransform.h create mode 100644 modules/core/include/lagrange/utils/fmt/format.h create mode 100644 modules/core/include/lagrange/utils/fmt/join.h create mode 100644 modules/core/include/lagrange/utils/fmt/print.h create mode 100644 modules/core/include/lagrange/utils/triangle_triangle_intersection.h create mode 100644 modules/core/js/CMakeLists.txt create mode 100644 modules/core/js/src/core.cpp create mode 100644 modules/core/js/src/core_utilities.cpp create mode 100644 modules/core/js/src/mesh_cleanup.cpp create mode 100644 modules/core/js/test/SurfaceMesh.test.ts create mode 100644 modules/core/js/test/bindings.test.ts create mode 100644 modules/core/js/test/core.test.ts create mode 100644 modules/core/js/test/core_utilities.test.ts create mode 100644 modules/core/js/ts/core.ts create mode 100644 modules/core/python/tests/test_disconnect_uv_charts.py create mode 100644 modules/core/python/tests/test_get_unique_attribute_name.py create mode 100644 modules/core/src/disconnect_uv_charts.cpp create mode 100644 modules/core/src/get_unique_attribute_name.cpp create mode 100644 modules/core/src/internal/extract_submeshes_by_group.cpp create mode 100644 modules/core/src/internal/extract_submeshes_by_group.h create mode 100644 modules/core/src/utils/triangle_triangle_intersection.cpp create mode 100644 modules/core/tests/test_compute_barycentric_coordinates.cpp create mode 100644 modules/core/tests/test_disconnect_uv_charts.cpp create mode 100644 modules/core/tests/test_separate_by_facet_groups.cpp create mode 100644 modules/core/tests/test_triangle_triangle_intersection.cpp create mode 100644 modules/filtering/js/CMakeLists.txt create mode 100644 modules/filtering/js/src/filtering.cpp create mode 100644 modules/filtering/js/test/filtering.test.ts create mode 100644 modules/filtering/js/ts/filtering.ts create mode 100644 modules/geodesic/python/tests/conftest.py create mode 100644 modules/geodesic/tests/test_geodesic_path.cpp create mode 100644 modules/io/js/CMakeLists.txt create mode 100644 modules/io/js/src/io.cpp create mode 100644 modules/io/js/test/io.test.ts create mode 100644 modules/io/js/ts/io.ts create mode 100644 modules/packing/tests/test_repack_uv_charts.cpp create mode 100644 modules/primitive/js/CMakeLists.txt create mode 100644 modules/primitive/js/src/primitive.cpp create mode 100644 modules/primitive/js/test/primitive.test.ts create mode 100644 modules/primitive/js/ts/primitive.ts create mode 100644 modules/scene/js/CMakeLists.txt create mode 100644 modules/scene/js/src/scene.cpp create mode 100644 modules/scene/js/ts/scene.ts create mode 100644 modules/serialization2/js/CMakeLists.txt create mode 100644 modules/serialization2/js/src/serialization2.cpp create mode 100644 modules/serialization2/js/test/serialization.test.ts create mode 100644 modules/serialization2/js/ts/serialization.ts create mode 100644 modules/subdivision/js/CMakeLists.txt create mode 100644 modules/subdivision/js/src/subdivision.cpp create mode 100644 modules/subdivision/js/test/subdivision.test.ts create mode 100644 modules/subdivision/js/ts/subdivision.ts create mode 100644 modules/texproc/tests/test_texture_rasterizer.cpp create mode 100644 modules/ui/src/types/Camera_xcode264_workaround.cpp create mode 100644 modules/ui/src/utils/math_xcode264_workaround.cpp diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index dad633ee..dc0153ad 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -12,7 +12,6 @@ concurrency: env: CTEST_OUTPUT_ON_FAILURE: ON CTEST_PARALLEL_LEVEL: 1 - SCCACHE_GHA_ENABLED: "true" jobs: #################### @@ -198,6 +197,8 @@ jobs: Windows: name: windows-2025 (${{ matrix.config }}) runs-on: windows-2025 + env: + SCCACHE_GHA_ENABLED: "true" strategy: fail-fast: false matrix: diff --git a/.github/workflows/wheel.yaml b/.github/workflows/wheel.yaml index 96237f62..abf00595 100644 --- a/.github/workflows/wheel.yaml +++ b/.github/workflows/wheel.yaml @@ -45,7 +45,7 @@ jobs: uses: astral-sh/setup-uv@v5 - name: Build wheels - uses: pypa/cibuildwheel@v2.23.3 + uses: pypa/cibuildwheel@v3.4.1 env: CIBW_BUILD_FRONTEND: "build[uv]" CIBW_ARCHS_LINUX: auto64 diff --git a/.gitignore b/.gitignore index c3aa2dea..a927af3e 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,9 @@ uv.lock *.jpg *.jpeg +# Init files +*.ini + # Openvdb files tensor_*.json tensor_*.vdb @@ -105,3 +108,9 @@ tags .claude/agent-memory/ /.tmp/ .claude/settings.local.json + +# Javascript bindings +bindings/js/adobe-lagrange-*.tgz +bindings/js/dist +bindings/js/node_modules +bindings/js/package-lock.json diff --git a/CMakeLists.txt b/CMakeLists.txt index aeadf9b5..fa103299 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,13 +50,23 @@ endif() # Set default compiler cache program based on available programs find_program(SCCACHE_PROGRAM sccache) find_program(CCACHE_PROGRAM ccache) -if(SCCACHE_PROGRAM) - set(LAGRANGE_CCACHE_PROGRAM "sccache" CACHE STRING "Compiler cache program to use (none, ccache or sccache)") +if(SCCACHE_PROGRAM AND CCACHE_PROGRAM) + # If both are available, we prefer sccache on Windows because it supports /Zi + if(WIN32) + set(LAGRANGE_CCACHE_PROGRAM_DEFAULT "sccache") + else() + set(LAGRANGE_CCACHE_PROGRAM_DEFAULT "ccache") + endif() +elseif(SCCACHE_PROGRAM) + set(LAGRANGE_CCACHE_PROGRAM_DEFAULT "sccache") elseif(CCACHE_PROGRAM) - set(LAGRANGE_CCACHE_PROGRAM "ccache" CACHE STRING "Compiler cache program to use (none, ccache or sccache)") + set(LAGRANGE_CCACHE_PROGRAM_DEFAULT "ccache") else() - set(LAGRANGE_CCACHE_PROGRAM "none" CACHE STRING "Compiler cache program to use (none, ccache or sccache)") + set(LAGRANGE_CCACHE_PROGRAM_DEFAULT "none") endif() +set(LAGRANGE_CCACHE_PROGRAM ${LAGRANGE_CCACHE_PROGRAM_DEFAULT} + CACHE STRING "Compiler cache program to use (none, ccache or sccache)" +) set(LAGRANGE_CCACHE_PROGRAMS none ccache sccache) set_property(CACHE LAGRANGE_CCACHE_PROGRAM PROPERTY STRINGS ${LAGRANGE_CCACHE_PROGRAMS}) @@ -64,18 +74,15 @@ set_property(CACHE LAGRANGE_CCACHE_PROGRAM PROPERTY STRINGS ${LAGRANGE_CCACHE_PR # Enable sscache if available if((LAGRANGE_CCACHE_PROGRAM STREQUAL "sccache") AND SCCACHE_PROGRAM) message(STATUS "Using sccache: ${SCCACHE_PROGRAM}") - set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$:Embedded>") foreach(lang IN ITEMS C CXX) - set(CMAKE_${lang}_COMPILER_LAUNCHER - ${CMAKE_COMMAND} -E env ${ccacheEnv} ${SCCACHE_PROGRAM} - ) + set(CMAKE_${lang}_COMPILER_LAUNCHER ${SCCACHE_PROGRAM}) endforeach() endif() # Enable ccache if available if((LAGRANGE_CCACHE_PROGRAM STREQUAL "ccache") AND CCACHE_PROGRAM) set(ccacheEnv - CCACHE_BASEDIR=${CMAKE_BINARY_DIR} + CCACHE_BASEDIR=${CMAKE_SOURCE_DIR} CCACHE_SLOPPINESS=clang_index_store,include_file_ctime,include_file_mtime,locale,pch_defines,time_macros ) message(STATUS "Using ccache: ${CCACHE_PROGRAM}") @@ -102,6 +109,11 @@ if(LAGRANGE_TOPLEVEL_PROJECT AND NOT DEFINED CMAKE_TOOLCHAIN_FILE) set(CMAKE_OSX_DEPLOYMENT_TARGET "13.0" CACHE STRING "Minimum OS X deployment version") endif() +# Generates a `compile_commands.json` that can be used for autocompletion +if(LAGRANGE_TOPLEVEL_PROJECT) + set(CMAKE_EXPORT_COMPILE_COMMANDS ON CACHE BOOL "Enable/Disable output of compile commands during generation.") +endif() + ################################################################################ if(SKBUILD) @@ -370,6 +382,8 @@ include(lagrange_add_executable) include(lagrange_add_module) include(lagrange_add_performance) include(lagrange_add_python_binding) +include(lagrange_add_js_binding) +include(lagrange_js_emit_registry) include(lagrange_add_test) include(lagrange_cpm_cache) include(lagrange_download_data) diff --git a/LagrangeOptions.cmake.sample b/LagrangeOptions.cmake.sample index 1de610b6..ed69a040 100644 --- a/LagrangeOptions.cmake.sample +++ b/LagrangeOptions.cmake.sample @@ -23,8 +23,8 @@ # Specify a custom install prefix path # set(CMAKE_INSTALL_PREFIX ${CMAKE_SOURCE_DIR}/install CACHE STRING "Install directory used by install().") -# Generates a `compile_commands.json` that can be used for autocompletion -# set(CMAKE_EXPORT_COMPILE_COMMANDS ON CACHE BOOL "Enable/Disable output of compile commands during generation.") +# Whether to generates a `compile_commands.json` that can be used for autocompletion +# set(CMAKE_EXPORT_COMPILE_COMMANDS OFF CACHE BOOL "Enable/Disable output of compile commands during generation.") # Use a specific C/C++ compiler, e.g. llvm-clang on macOS (so we can use clangd) # set(CMAKE_C_COMPILER "/usr/local/opt/llvm/bin/clang" CACHE STRING "C compiler") @@ -74,6 +74,7 @@ # option(LAGRANGE_MODULE_WINDING "Build module lagrange::winding" ON) # General options +# option(LAGRANGE_BINDINGS_JS "Build JavaScript/WebAssembly bindings" OFF) # option(LAGRANGE_COMPILE_TESTS "Enable compilation tests" ON) # option(LAGRANGE_DOCUMENTATION "Build Doxygen documentation" ON) # option(LAGRANGE_EXAMPLES "Build all examples" ON) diff --git a/cmake/lagrange/lagrange_add_js_binding.cmake b/cmake/lagrange/lagrange_add_js_binding.cmake new file mode 100644 index 00000000..b489e4b5 --- /dev/null +++ b/cmake/lagrange/lagrange_add_js_binding.cmake @@ -0,0 +1,104 @@ +# +# 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. +# +function(lagrange_add_js_binding module_name module_type) + # module_name: JS key this binding exposes on `lagrange.`. Usually matches + # the folder name; differs for renamed modules (e.g. serialization2 -> serialization). + # module_type: name of the TypeScript interface exported by ts/${module_name}.ts, used + # verbatim in the generated registry.ts (e.g. BVHModule, CoreModule, SerializationModule). + if(NOT module_name) + message(FATAL_ERROR "lagrange_add_js_binding() requires a JS module name argument") + endif() + if(NOT module_type) + message(FATAL_ERROR "lagrange_add_js_binding(${module_name}) requires a TS interface name") + endif() + + # C++ library target name: folder name (parent of js/). + get_filename_component(module_path "${CMAKE_CURRENT_SOURCE_DIR}/.." REALPATH) + get_filename_component(cpp_target_name "${module_path}" NAME) + + get_property(_initialized GLOBAL PROPERTY LAGRANGE_JS_BINDINGS_INITIALIZED) + if(NOT _initialized) + set_property(GLOBAL PROPERTY LAGRANGE_JS_BINDINGS_INITIALIZED TRUE) + get_property(lagrange_source_dir GLOBAL PROPERTY __lagrange_source_dir) + get_property(lagrange_binary_dir GLOBAL PROPERTY __lagrange_binary_dir) + add_subdirectory( + ${lagrange_source_dir}/bindings/js + ${lagrange_binary_dir}/bindings/lagrange_js) + + # Clear any stale symlinks from a previous configure (e.g. renamed or + # removed ts/test files) so they don't get picked up by tsc/vitest. + # Only removes symlinks — preserves checked-in files like .gitignore. + file(GLOB _stale_module_links "${lagrange_source_dir}/bindings/js/src/modules/*.ts") + foreach(_f ${_stale_module_links}) + if(IS_SYMLINK "${_f}") + file(REMOVE "${_f}") + endif() + endforeach() + file(GLOB _stale_test_links "${lagrange_source_dir}/bindings/js/test/*.ts") + foreach(_f ${_stale_test_links}) + if(IS_SYMLINK "${_f}") + file(REMOVE "${_f}") + endif() + endforeach() + endif() + + if(NOT TARGET lagrange_js) + return() + endif() + + file(GLOB_RECURSE SRC_FILES "*.cpp" "*.h") + target_sources(lagrange_js PRIVATE ${SRC_FILES}) + target_link_libraries(lagrange_js PRIVATE lagrange::${cpp_target_name}) + target_include_directories(lagrange_js PRIVATE + $) + + # Symlink TypeScript files from modules//js/ into bindings/js/ + get_property(lagrange_source_dir GLOBAL PROPERTY __lagrange_source_dir) + set(js_bindings_dir "${lagrange_source_dir}/bindings/js") + + # Register into the generated registry. lagrange_js_emit_registry emits + # `bindings/js/src/generated/registry.ts` based on this list. Each registered module must + # ship a ts/${module_name}.ts file exporting `${module_type}` (interface) and `${module_name}ModuleKeys`. + set_property(GLOBAL APPEND PROPERTY LAGRANGE_JS_REGISTRY "${module_name}") + set_property(GLOBAL PROPERTY LAGRANGE_JS_REGISTRY_TYPE_${module_name} "${module_type}") + + # Type files: modules//js/ts/*.ts → bindings/js/src/modules/*.ts + set(ts_dir "${CMAKE_CURRENT_SOURCE_DIR}/ts") + if(EXISTS "${ts_dir}") + if(NOT EXISTS "${ts_dir}/${module_name}.ts") + message(FATAL_ERROR + "lagrange_add_js_binding(${module_name}): expected ${ts_dir}/${module_name}.ts — " + "TS filename stem must match the JS module name.") + endif() + file(GLOB TS_FILES "${ts_dir}/*.ts") + foreach(ts_file ${TS_FILES}) + get_filename_component(ts_name "${ts_file}" NAME) + set(link_path "${js_bindings_dir}/src/modules/${ts_name}") + if(NOT EXISTS "${link_path}") + file(CREATE_LINK "${ts_file}" "${link_path}" SYMBOLIC) + endif() + endforeach() + endif() + + # Test files: modules//js/test/*.ts → bindings/js/test/*.ts + set(test_dir "${CMAKE_CURRENT_SOURCE_DIR}/test") + if(EXISTS "${test_dir}") + file(GLOB TEST_FILES "${test_dir}/*.ts") + foreach(test_file ${TEST_FILES}) + get_filename_component(test_name "${test_file}" NAME) + set(link_path "${js_bindings_dir}/test/${test_name}") + if(NOT EXISTS "${link_path}") + file(CREATE_LINK "${test_file}" "${link_path}" SYMBOLIC) + endif() + endforeach() + endif() +endfunction() diff --git a/cmake/lagrange/lagrange_add_python_binding.cmake b/cmake/lagrange/lagrange_add_python_binding.cmake index 8bd9334c..53f60937 100644 --- a/cmake/lagrange/lagrange_add_python_binding.cmake +++ b/cmake/lagrange/lagrange_add_python_binding.cmake @@ -31,7 +31,7 @@ function(lagrange_add_python_binding) if (EXISTS ${CMAKE_CURRENT_LIST_DIR}/scripts) message(STATUS "Adding scripts for module ${module_name}") message(STATUS ${SKBUILD_SCRIPTS_DIR}) - file(GLOB_RECURSE SCRIPTS ${CMAKE_CURRENT_LIST_DIR}/scripts/*) + file(GLOB_RECURSE SCRIPTS ${CMAKE_CURRENT_LIST_DIR}/scripts/*.py) install(PROGRAMS ${SCRIPTS} DESTINATION ${SKBUILD_SCRIPTS_DIR} COMPONENT Lagrange_Python_Runtime diff --git a/cmake/lagrange/lagrange_js_emit_registry.cmake b/cmake/lagrange/lagrange_js_emit_registry.cmake new file mode 100644 index 00000000..2a901b90 --- /dev/null +++ b/cmake/lagrange/lagrange_js_emit_registry.cmake @@ -0,0 +1,63 @@ +# +# 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. +# +# Emit bindings/js/src/generated/registry.ts listing every module that registered via +# lagrange_add_js_binding(). Defined in root scope so cmake_language(DEFER CALL ...) can +# find it when firing at end of top-level directory processing. +function(lagrange_js_emit_registry) + get_property(_modules GLOBAL PROPERTY LAGRANGE_JS_REGISTRY) + if(NOT _modules) + return() + endif() + list(REMOVE_DUPLICATES _modules) + list(SORT _modules) + + get_property(_src_dir GLOBAL PROPERTY __lagrange_source_dir) + set(_out "${_src_dir}/bindings/js/src/generated/registry.ts") + + set(_content "// GENERATED FILE — do not edit. Regenerated by CMake (lagrange_js_emit_registry).\n") + string(APPEND _content "// One entry per modules//js/ts/.ts that was compiled into this build.\n\n") + + foreach(_m IN LISTS _modules) + string(APPEND _content "export * from \"../modules/${_m}.js\";\n") + endforeach() + string(APPEND _content "\n") + + foreach(_m IN LISTS _modules) + get_property(_type GLOBAL PROPERTY LAGRANGE_JS_REGISTRY_TYPE_${_m}) + string(APPEND _content "import type { ${_type} } from \"../modules/${_m}.js\";\n") + string(APPEND _content "import { ${_m}ModuleKeys } from \"../modules/${_m}.js\";\n") + endforeach() + string(APPEND _content "\n") + + string(APPEND _content "export interface Lagrange {\n") + foreach(_m IN LISTS _modules) + get_property(_type GLOBAL PROPERTY LAGRANGE_JS_REGISTRY_TYPE_${_m}) + string(APPEND _content " ${_m}: ${_type};\n") + endforeach() + string(APPEND _content "}\n\n") + + string(APPEND _content "export const moduleRegistry = {\n") + foreach(_m IN LISTS _modules) + string(APPEND _content " ${_m}: ${_m}ModuleKeys,\n") + endforeach() + string(APPEND _content "} as const;\n") + + set(_existing "") + if(EXISTS "${_out}") + file(READ "${_out}" _existing) + endif() + if(NOT "${_existing}" STREQUAL "${_content}") + file(WRITE "${_out}" "${_content}") + list(LENGTH _modules _count) + message(STATUS "lagrange_js: wrote ${_out} (${_count} modules)") + endif() +endfunction() diff --git a/cmake/recipes/external/OpenVDB.cmake b/cmake/recipes/external/OpenVDB.cmake index 0ae3f431..c0bae13e 100644 --- a/cmake/recipes/external/OpenVDB.cmake +++ b/cmake/recipes/external/OpenVDB.cmake @@ -138,8 +138,8 @@ function(openvdb_import_target) ignore_package(ZLIB) include(miniz) if(NOT TARGET ZLIB::ZLIB) - get_target_property(_aliased miniz::miniz ALIASED_TARGET) - add_library(ZLIB::ZLIB ALIAS ${_aliased}) + include(lagrange_alias_target) + lagrange_alias_target(ZLIB::ZLIB miniz::miniz) endif() endif() diff --git a/cmake/recipes/external/blosc.cmake b/cmake/recipes/external/blosc.cmake index 320199e3..d19ff0f9 100644 --- a/cmake/recipes/external/blosc.cmake +++ b/cmake/recipes/external/blosc.cmake @@ -67,8 +67,8 @@ block() ignore_package(ZLIB) include(miniz) if(NOT TARGET ZLIB::ZLIB) - get_target_property(_aliased miniz::miniz ALIASED_TARGET) - add_library(ZLIB::ZLIB ALIAS ${_aliased}) + include(lagrange_alias_target) + lagrange_alias_target(ZLIB::ZLIB miniz::miniz) endif() set(ZLIB_INCLUDE_DIR "") set(ZLIB_LIBRARY ZLIB::ZLIB) diff --git a/cmake/recipes/external/cista.cmake b/cmake/recipes/external/cista.cmake index b5438622..c1508cb4 100644 --- a/cmake/recipes/external/cista.cmake +++ b/cmake/recipes/external/cista.cmake @@ -28,11 +28,17 @@ CPMAddPackage( add_library(cista INTERFACE) add_library(cista::cista ALIAS cista) -target_include_directories(cista SYSTEM INTERFACE "${cista_SOURCE_DIR}/include") +FetchContent_GetProperties(cista) +include(GNUInstallDirs) +target_include_directories(cista SYSTEM INTERFACE + $ + $ +) set_target_properties(cista PROPERTIES FOLDER "third_party") # Install rules set(CMAKE_INSTALL_DEFAULT_COMPONENT_NAME cista) +install(DIRECTORY ${cista_SOURCE_DIR}/include/cista DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) install(TARGETS cista EXPORT Cista_Targets INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) install(EXPORT Cista_Targets DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cista NAMESPACE cista::) diff --git a/cmake/recipes/external/cpptrace.cmake b/cmake/recipes/external/cpptrace.cmake index 8bc74e1a..7b5dfc2e 100644 --- a/cmake/recipes/external/cpptrace.cmake +++ b/cmake/recipes/external/cpptrace.cmake @@ -17,6 +17,7 @@ endif() message(STATUS "Third-party (external): creating target 'cpptrace::cpptrace'") lagrange_find_package(zstd CONFIG REQUIRED GLOBAL) +include(miniz) include(CPM) CPMAddPackage( diff --git a/cmake/recipes/external/miniz.cmake b/cmake/recipes/external/miniz.cmake index cdd89514..65b7fbf4 100644 --- a/cmake/recipes/external/miniz.cmake +++ b/cmake/recipes/external/miniz.cmake @@ -21,9 +21,14 @@ CPMAddPackage( URL https://github.com/richgel999/miniz/releases/download/3.0.2/miniz-3.0.2.zip URL_MD5 0604f14151944ff984444b04c5c760e5 ) +FetchContent_GetProperties(miniz) add_library(miniz STATIC ${miniz_SOURCE_DIR}/miniz.c) -add_library(miniz::miniz ALIAS miniz) + +# Not an alias because libdwarf (included via cpptrace) depends on zlib/miniz and expects +# an imported target (it doesn't define zlib/miniz in its export set for install). +add_library(miniz::miniz IMPORTED INTERFACE GLOBAL) +target_link_libraries(miniz::miniz INTERFACE miniz) include(GNUInstallDirs) target_include_directories(miniz PUBLIC @@ -34,11 +39,6 @@ target_include_directories(miniz PUBLIC set_target_properties(miniz PROPERTIES FOLDER third_party) set_target_properties(miniz PROPERTIES POSITION_INDEPENDENT_CODE ON) -target_include_directories(miniz PUBLIC - $ - $ -) - # Install rules set(CMAKE_INSTALL_DEFAULT_COMPONENT_NAME miniz) install(FILES ${miniz_SOURCE_DIR}/miniz.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) diff --git a/cmake/recipes/external/texture_signal_processing.cmake b/cmake/recipes/external/texture_signal_processing.cmake index 8967e2ab..e8e606a8 100644 --- a/cmake/recipes/external/texture_signal_processing.cmake +++ b/cmake/recipes/external/texture_signal_processing.cmake @@ -18,8 +18,8 @@ message(STATUS "Third-party (external): creating target 'texture_signal_processi include(CPM) CPMAddPackage( NAME texture_signal_processing - GITHUB_REPOSITORY mkazhdan/TextureSignalProcessing - GIT_TAG dd7ab66cc0e75bec1e6eb6b704d240e8780f46e7 + GITHUB_REPOSITORY jdumas/TextureSignalProcessing + GIT_TAG 4689f674deadde94511b6f4c00b6dedf3790527e # adobe/lagrange branch ) add_library(texture_signal_processing::texture_signal_processing INTERFACE IMPORTED GLOBAL) diff --git a/modules/CMakeLists.txt b/modules/CMakeLists.txt index 7b010296..6aeda78e 100644 --- a/modules/CMakeLists.txt +++ b/modules/CMakeLists.txt @@ -26,6 +26,9 @@ list(PREPEND module_names "ui") ################################################################################ +# Embind / WebAssembly (not a Lagrange library module; see bindings/js/) +option(LAGRANGE_BINDINGS_JS "Build JavaScript/WebAssembly bindings" OFF) + # Always build core library add_subdirectory(core) diff --git a/modules/bvh/CMakeLists.txt b/modules/bvh/CMakeLists.txt index 03ad3c72..88c5e7a8 100644 --- a/modules/bvh/CMakeLists.txt +++ b/modules/bvh/CMakeLists.txt @@ -35,3 +35,7 @@ endif() if(LAGRANGE_MODULE_PYTHON) add_subdirectory(python) endif() + +if(LAGRANGE_BINDINGS_JS) + add_subdirectory(js) +endif() diff --git a/modules/bvh/examples/uv_overlap.cpp b/modules/bvh/examples/uv_overlap.cpp index 422c7d3e..f0ce31f8 100644 --- a/modules/bvh/examples/uv_overlap.cpp +++ b/modules/bvh/examples/uv_overlap.cpp @@ -12,21 +12,22 @@ #include #include #include +#include #include +#include #include #include #include #include #include -#include +#include #include #include -#include +#include #include #include #include -#include #include #include @@ -65,7 +66,7 @@ void prepare_mesh_for_display(SurfaceMesh& mesh) lagrange::logger().info( "Unifying index buffers for {} non-UV indexed attributes: {}", ids.size(), - fmt::join(attr_names, ", ")); + lagrange::join(attr_names, ", ")); mesh = lagrange::unify_index_buffer(mesh, ids); } @@ -93,7 +94,7 @@ void repack_overlapping_charts( lagrange::UVChartOptions chart_options; chart_options.uv_attribute_name = uv_attribute_name; chart_options.output_attribute_name = "@chart_id"; - size_t num_charts = lagrange::compute_uv_charts(mesh, chart_options); + lagrange::compute_uv_charts(mesh, chart_options); // 2. Combine chart ID and overlap color into a split chart attribute: // split_id = chart_id * num_colors + overlap_color @@ -115,49 +116,10 @@ void repack_overlapping_charts( split_ids); // 3. Split UV indices so that facets in different split charts don't share UV vertices - lagrange::AttributeMatcher uv_matcher; - uv_matcher.usages = lagrange::AttributeUsage::UV; - uv_matcher.element_types = lagrange::AttributeElement::Indexed; - auto uv_attr_id = lagrange::find_matching_attribute(mesh, uv_matcher); - la_runtime_assert(uv_attr_id.has_value(), "No indexed UV attribute found."); - - auto& uv_attr = mesh.template ref_indexed_attribute(*uv_attr_id); - auto old_values = lagrange::matrix_view(uv_attr.values()); - auto uv_indices = lagrange::vector_ref(uv_attr.indices()); - - std::unordered_map< - std::pair, - uint32_t, - lagrange::OrderedPairHash>> - remap; - std::vector new_values; - new_values.reserve(old_values.rows()); - - for (uint32_t f = 0; f < num_facets; ++f) { - auto c_begin = mesh.get_facet_corner_begin(f); - auto c_end = mesh.get_facet_corner_end(f); - for (auto c = c_begin; c < c_end; ++c) { - auto key = std::make_pair(static_cast(uv_indices[c]), split_ids[f]); - auto [it, inserted] = remap.emplace(key, static_cast(new_values.size())); - if (inserted) { - new_values.push_back(old_values.row(key.first)); - } - uv_indices[c] = it->second; - } - } - - uv_attr.values().resize_elements(new_values.size()); - auto new_val_ref = lagrange::matrix_ref(uv_attr.values()); - for (size_t i = 0; i < new_values.size(); ++i) { - new_val_ref.row(i) = new_values[i]; - } - - lagrange::logger().info( - "Split UV vertices: {} -> {} (charts={}, colors={}).", - old_values.rows(), - new_values.size(), - num_charts, - num_colors); + lagrange::DisconnectUVChartsOptions disconnect_options; + disconnect_options.uv_attribute_name = uv_attribute_name; + disconnect_options.chart_id_attribute_name = "@split_chart_id"; + lagrange::disconnect_uv_charts(mesh, disconnect_options); // 4. Repack using the split chart attribute lagrange::packing::RepackOptions repack_options; @@ -214,16 +176,27 @@ void register_uv_mesh( { lagrange::UVMeshOptions uv_opts; uv_opts.uv_attribute_name = uv_attribute_name; - auto uv = lagrange::uv_mesh_view(mesh, uv_opts); - auto* ps = - static_cast<::polyscope::SurfaceMesh*>(lagrange::polyscope::register_structure(name, uv)); + polyscope::SurfaceMesh* ps_mesh = [&] { + using Scalar = SurfaceMesh::Scalar; + using Index = SurfaceMesh::Index; + using OtherScalar = std::conditional_t, double, float>; + if (lagrange::uv_attribute_id(mesh, uv_opts)) { + auto uv = lagrange::uv_mesh_view(mesh, uv_opts); + return lagrange::polyscope::register_mesh(name, uv); + } else if (lagrange::uv_attribute_id(mesh, uv_opts)) { + auto uv = lagrange::uv_mesh_view(mesh, uv_opts); + return lagrange::polyscope::register_mesh(name, uv); + } else { + throw std::runtime_error("Unable to find a UV attribute for mesh: " + name); + } + }(); if (x_offset != 0.0) { glm::mat4 T = glm::translate(glm::mat4(1.0f), glm::vec3(x_offset, 0, 0)); - ps->setTransform(T); + ps_mesh->setTransform(T); } if (coloring_id != lagrange::invalid_attribute_id()) { auto& ca = mesh.template get_attribute(coloring_id); - lagrange::polyscope::register_attribute(*ps, "uv_overlap_color", ca); + lagrange::polyscope::register_attribute(*ps_mesh, "uv_overlap_color", ca); } } @@ -264,11 +237,11 @@ void register_view(DemoState& state) polyscope::view::setUpDir(polyscope::UpDir::YUp); polyscope::view::setNavigateStyle(polyscope::NavigateStyle::Planar); } else { - auto* ps3d = lagrange::polyscope::register_structure(state.mesh_name, state.mesh_display); + auto* ps3d = lagrange::polyscope::register_mesh(state.mesh_name, state.mesh_display); ps3d->setTransform(glm::mat4(1.0f)); if (state.repacked) { auto* ps3d_repacked = - lagrange::polyscope::register_structure(repacked_name, state.repacked_mesh_display); + lagrange::polyscope::register_mesh(repacked_name, state.repacked_mesh_display); ps3d_repacked->setTransform(glm::mat4(1.0f)); } @@ -384,7 +357,9 @@ int main(int argc, char** argv) // Load and triangulate lagrange::logger().info("Loading input mesh: {}", args.input); - auto mesh = lagrange::io::load_mesh(args.input); + lagrange::io::LoadOptions load_options; + load_options.stitch_vertices = true; + auto mesh = lagrange::io::load_mesh(args.input, load_options); lagrange::triangulate_polygonal_facets(mesh); lagrange::logger().info( "Mesh has {} vertices and {} facets.", diff --git a/modules/bvh/include/lagrange/bvh/BVHNanoflann.h b/modules/bvh/include/lagrange/bvh/BVHNanoflann.h index 25d69fb7..29cafcd4 100644 --- a/modules/bvh/include/lagrange/bvh/BVHNanoflann.h +++ b/modules/bvh/include/lagrange/bvh/BVHNanoflann.h @@ -54,13 +54,15 @@ class BVHNanoflann : public BVH<_VertexArray, _ElementArray> void build(const VertexArray& vertices) override { - // Nanoflann stores a const ref to vertices, we need to make sure - // vertices outlives the BVH. Storing a local copy for now. m_vertices = vertices; + build_index(); + } - constexpr int max_leaf = 10; // TODO: Experiment with different values. - m_tree = std::make_unique(m_vertices.cols(), m_vertices, max_leaf); - m_tree->index_->buildIndex(); + /// Overload that moves vertices in to avoid an extra copy. + void build(VertexArray&& vertices) + { + m_vertices = std::move(vertices); + build_index(); } bool does_support_query_closest_point() const override { return true; } @@ -136,6 +138,13 @@ class BVHNanoflann : public BVH<_VertexArray, _ElementArray> private: + void build_index() + { + constexpr int max_leaf = 10; + m_tree = std::make_unique(m_vertices.cols(), m_vertices, max_leaf); + m_tree->index_->buildIndex(); + } + VertexArray m_vertices; std::unique_ptr m_tree; }; diff --git a/modules/bvh/include/lagrange/bvh/TriangleAABBTree.h b/modules/bvh/include/lagrange/bvh/TriangleAABBTree.h index 88aff18a..fb0b30c7 100644 --- a/modules/bvh/include/lagrange/bvh/TriangleAABBTree.h +++ b/modules/bvh/include/lagrange/bvh/TriangleAABBTree.h @@ -87,6 +87,13 @@ class TriangleAABBTree RowVectorType& closest_point, Scalar& closest_sq_dist) const; + /// + /// Get a const reference to the underlying AABB tree. + /// + /// @return Const reference to the AABB tree. + /// + const AABB& get_aabb() const { return m_aabb; } + private: SurfaceMesh m_mesh; diff --git a/modules/bvh/include/lagrange/bvh/compute_intersecting_pairs.h b/modules/bvh/include/lagrange/bvh/compute_intersecting_pairs.h new file mode 100644 index 00000000..e2605953 --- /dev/null +++ b/modules/bvh/include/lagrange/bvh/compute_intersecting_pairs.h @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include + +namespace lagrange::bvh { + +/// +/// @defgroup group-bvh-intersecting-pairs Intersecting Pairs +/// @ingroup group-bvh +/// +/// Compute intersecting facet pairs in a mesh using BVH acceleration. +/// +/// @{ + +/// +/// Compute all pairs of intersecting facets in a mesh using an AABB tree for acceleration. +/// +/// Detects facet pairs whose interiors overlap using exact geometric predicates. All facet +/// pairs (including vertex- and edge-adjacent ones) are tested geometrically. Contacts at +/// shared vertices or edges do not count as intersections; only interior overlaps are reported. +/// +/// @param[in] mesh The input mesh. Must be a triangle mesh (only triangular facets). +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return An AdjacencyList representing the intersection graph. For each facet i, +/// get_neighbors(i) returns all facets whose interior overlaps the interior of i. +/// +/// @throws std::runtime_error if the mesh is not a triangle mesh or not 3D. +/// +/// @note Uses exact predicates (orient3D, orient2D) with include_boundary=false. +/// Boundary contacts (shared vertices or edges) are correctly excluded. +/// +/// @see triangle_triangle_intersection +/// +template +AdjacencyList compute_intersecting_pairs(const SurfaceMesh& mesh); + +/// @} + +} // namespace lagrange::bvh diff --git a/modules/bvh/include/lagrange/bvh/compute_mesh_distances.h b/modules/bvh/include/lagrange/bvh/compute_mesh_distances.h index 3ef83715..c37ce1c1 100644 --- a/modules/bvh/include/lagrange/bvh/compute_mesh_distances.h +++ b/modules/bvh/include/lagrange/bvh/compute_mesh_distances.h @@ -34,10 +34,12 @@ struct MeshDistancesOptions /// Compute the distance from each vertex in @p source to the closest point on @p target, /// and store the result as a per-vertex scalar attribute on @p source. /// -/// Both meshes must have the same spatial dimension. @p target must be a triangle mesh. +/// Both meshes must have the same spatial dimension. @p target must be either a triangle mesh +/// or a point cloud (no facets). If @p target is a point cloud, distances are computed to the +/// nearest vertex rather than to the nearest point on a triangle. /// /// @param[in,out] source Mesh whose vertices are queried. The output attribute is added here. -/// @param[in] target Triangle mesh against which distances are computed. +/// @param[in] target Triangle mesh or point cloud against which distances are computed. /// @param[in] options Options controlling the name of the output attribute. /// /// @return AttributeId of the newly created (or overwritten) distance attribute on @p source. @@ -62,12 +64,14 @@ LA_BVH_API AttributeId compute_mesh_distances( /// \right) /// @f] /// where @f$ \mathrm{dist}(v, M) @f$ is the distance from vertex @f$ v @f$ to the closest -/// point on mesh @f$ M @f$. +/// point on @f$ M @f$. For triangle meshes the closest point may lie on a triangle face or +/// edge; for point clouds (no facets) it is the nearest vertex. /// -/// Both meshes must have the same spatial dimension and must be triangle meshes. +/// Both meshes must have the same spatial dimension. Each mesh must be either a triangle mesh +/// or a point cloud (no facets). /// -/// @param[in] source First mesh. -/// @param[in] target Second mesh. +/// @param[in] source First mesh or point cloud. +/// @param[in] target Second mesh or point cloud. /// /// @return Hausdorff distance. /// @@ -88,12 +92,14 @@ LA_BVH_API Scalar compute_hausdorff( /// + \frac{1}{|B|} \sum_{b \in B} \mathrm{dist}(b, A)^2 /// @f] /// where @f$ \mathrm{dist}(v, M) @f$ is the distance from vertex @f$ v @f$ to the closest -/// point on mesh @f$ M @f$. +/// point on @f$ M @f$. For triangle meshes the closest point may lie on a triangle face or +/// edge; for point clouds (no facets) it is the nearest vertex. /// -/// Both meshes must have the same spatial dimension and must be triangle meshes. +/// Both meshes must have the same spatial dimension. Each mesh must be either a triangle mesh +/// or a point cloud (no facets). /// -/// @param[in] source First mesh. -/// @param[in] target Second mesh. +/// @param[in] source First mesh or point cloud. +/// @param[in] target Second mesh or point cloud. /// /// @return Chamfer distance. /// diff --git a/modules/bvh/js/CMakeLists.txt b/modules/bvh/js/CMakeLists.txt new file mode 100644 index 00000000..7e5b661d --- /dev/null +++ b/modules/bvh/js/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_js_binding(bvh BVHModule) diff --git a/modules/bvh/js/src/bvh.cpp b/modules/bvh/js/src/bvh.cpp new file mode 100644 index 00000000..be81ef70 --- /dev/null +++ b/modules/bvh/js/src/bvh.cpp @@ -0,0 +1,82 @@ +/* + * 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 "bind_types.h" + +#include +#include + +#include +#include + +#include + +namespace { + +using namespace lagrange; +using namespace lagrange::js::bind; +using val = emscripten::val; + +MappingPolicy parse_mapping_policy(const std::string& s) +{ + if (s == "keepFirst") return MappingPolicy::KeepFirst; + if (s == "error") return MappingPolicy::Error; + return MappingPolicy::Average; +} + +} // namespace + +EMSCRIPTEN_BINDINGS(lagrange_bvh) +{ + using namespace emscripten; + + function( + "computeMeshDistances", + +[](MeshType& source, const MeshType& target, val opts) { + bvh::MeshDistancesOptions o; + // output_attribute_name is std::string, safe to bind (unlike string_view) + if (!opts.isUndefined()) { + auto name = opts["outputAttributeName"]; + if (!name.isUndefined()) o.output_attribute_name = name.as(); + } + compute_mesh_distances(source, target, o); + }); + + function( + "computeHausdorff", + +[](const MeshType& source, const MeshType& target) -> Scalar { + return bvh::compute_hausdorff(source, target); + }); + + function( + "computeChamfer", + +[](const MeshType& source, const MeshType& target) -> Scalar { + return bvh::compute_chamfer(source, target); + }); + + function( + "weldVertices", + +[](MeshType& mesh, val opts) { + bvh::WeldOptions o; + apply_opt(opts, "radius", o.radius); + apply_opt(opts, "boundaryOnly", o.boundary_only); + if (!opts.isUndefined()) { + auto cpf = opts["collisionPolicyFloat"]; + if (!cpf.isUndefined()) + o.collision_policy_float = parse_mapping_policy(cpf.as()); + auto cpi = opts["collisionPolicyIntegral"]; + if (!cpi.isUndefined()) + o.collision_policy_integral = parse_mapping_policy(cpi.as()); + } + bvh::weld_vertices(mesh, std::move(o)); + }); +} diff --git a/modules/bvh/js/test/bvh.test.ts b/modules/bvh/js/test/bvh.test.ts new file mode 100644 index 00000000..60721b28 --- /dev/null +++ b/modules/bvh/js/test/bvh.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +describe("bvh", () => { + test("computeHausdorff returns 0 for identical meshes", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const dist = lagrange.bvh.computeHausdorff(mesh, mesh); + expect(dist).toBeCloseTo(0, 5); + mesh.delete(); + }); + + test("computeHausdorff returns nonzero for different meshes", () => { + const a = lagrange.primitive.generateSphere({ radius: 1 }); + const b = lagrange.primitive.generateSphere({ radius: 2 }); + const dist = lagrange.bvh.computeHausdorff(a, b); + expect(dist).toBeGreaterThan(0.5); + a.delete(); + b.delete(); + }); + + test("computeChamfer returns 0 for identical meshes", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const dist = lagrange.bvh.computeChamfer(mesh, mesh); + expect(dist).toBeCloseTo(0, 5); + mesh.delete(); + }); + + test("computeMeshDistances adds distance attribute", () => { + const source = lagrange.primitive.generateSphere({ radius: 1 }); + const target = lagrange.primitive.generateSphere({ radius: 2 }); + lagrange.bvh.computeMeshDistances(source, target); + expect(source.hasAttribute("@distance_to_mesh")).toBe(true); + source.delete(); + target.delete(); + }); + + test("weldVertices merges nearby vertices", () => { + // Create two triangles with nearly-duplicate vertices + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addVertex([0, 1e-8, 0]); // near-duplicate of v0 + mesh.addVertex([1, 1e-8, 0]); // near-duplicate of v1 + mesh.addVertex([0, -1, 0]); + mesh.addTriangle(0, 1, 2); + mesh.addTriangle(3, 5, 4); + expect(mesh.getNumVertices()).toBe(6); + lagrange.bvh.weldVertices(mesh, { radius: 1e-6 }); + expect(mesh.getNumVertices()).toBe(4); + mesh.delete(); + }); +}); diff --git a/modules/bvh/js/ts/bvh.ts b/modules/bvh/js/ts/bvh.ts new file mode 100644 index 00000000..7c41e5cd --- /dev/null +++ b/modules/bvh/js/ts/bvh.ts @@ -0,0 +1,69 @@ +/** + * Spatial queries powered by a bounding-volume hierarchy: + * closest-point distances between meshes, mesh-to-mesh distance metrics, + * and proximity-based vertex welding. + * + * Omitted option fields fall back to library defaults. + */ + +import type { SurfaceMesh } from "./core.js"; + +/** + * What to do when multiple source values map to the same target element. + * - `"average"`: average the incoming values. + * - `"keepFirst"`: keep the first value, discard the rest. + * - `"error"`: throw on collision. + */ +export type MappingPolicy = "average" | "keepFirst" | "error"; + +export interface MeshDistancesOptions { + /** Attribute name to write distances into. Default: `"@distance_to_mesh"`. */ + outputAttributeName?: string; +} + +export interface WeldOptions { + /** Vertices closer than this distance are merged. Default: `1e-6`. */ + radius?: number; + /** Only consider vertices on the mesh boundary. Default: `false`. */ + boundaryOnly?: boolean; + /** How to combine floating-point attributes of merged vertices. Default: `"average"`. */ + collisionPolicyFloat?: MappingPolicy; + /** How to combine integer attributes of merged vertices. Default: `"keepFirst"`. */ + collisionPolicyIntegral?: MappingPolicy; +} + +/** + * Distance queries between meshes. Accessible as `lagrange.bvh`. + */ +export interface BVHModule { + /** + * For every vertex of `source`, compute the distance to the closest point + * on `target` and store it as a per-vertex attribute on `source`. + * In-place on `source`; `target` is untouched. + */ + computeMeshDistances(source: SurfaceMesh, target: SurfaceMesh, opts?: MeshDistancesOptions): void; + /** + * Symmetric Hausdorff distance: the largest closest-point distance from any + * vertex of either mesh to the other. Sensitive to outliers — good for + * worst-case tolerancing. + */ + computeHausdorff(source: SurfaceMesh, target: SurfaceMesh): number; + /** + * Symmetric Chamfer distance: the average closest-point distance between + * the two meshes. Smoother than Hausdorff — good for shape-similarity + * scoring. + */ + computeChamfer(source: SurfaceMesh, target: SurfaceMesh): number; + /** + * Merge nearby vertices (stricter than `core.removeDuplicateVertices` + * because it uses a radius rather than exact match). In-place. + */ + weldVertices(mesh: SurfaceMesh, opts?: WeldOptions): void; +} + +export const bvhModuleKeys = [ + "computeMeshDistances", + "computeHausdorff", + "computeChamfer", + "weldVertices", +] as const satisfies readonly (keyof BVHModule)[]; diff --git a/modules/bvh/python/src/bvh.cpp b/modules/bvh/python/src/bvh.cpp index 37065f40..97991335 100644 --- a/modules/bvh/python/src/bvh.cpp +++ b/modules/bvh/python/src/bvh.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -558,6 +559,41 @@ overlapping. :param method: Candidate detection algorithm (default: UVOverlapMethod.Hybrid). :return: UVOverlapResult containing overlap detection results.)"); + + m.def( + "compute_intersecting_pairs", + [](const MeshType& mesh) { + auto adj = bvh::compute_intersecting_pairs(mesh); + // Convert AdjacencyList to list of tuples for Python + std::vector> result; + for (Index i = 0; i < adj.get_num_entries(); ++i) { + auto neighbors = adj.get_neighbors(i); + for (Index j : neighbors) { + // Only add each pair once (i < j) + if (i < j) { + result.emplace_back(i, j); + } + } + } + return result; + }, + "mesh"_a, + R"(Compute all pairs of intersecting facets in a triangle mesh using BVH acceleration. + +Detects facet pairs whose interiors intersect using exact geometric predicates. Only facets +that do not share vertices are tested (vertex-adjacent facets are skipped). + +:param mesh: The input triangle mesh. Must contain only triangular facets. + +:returns: A list of tuples (i, j) where i < j representing non-vertex-adjacent facets + whose interiors intersect. + +:raises RuntimeError: If the mesh is not a triangle mesh or if the mesh is not 3D. + +.. note:: + Vertex-adjacent facets are filtered before testing. The geometric test uses + include_boundary=false (interior intersection only). +)"); } } // namespace lagrange::python diff --git a/modules/bvh/python/tests/test_compute_intersecting_pairs.py b/modules/bvh/python/tests/test_compute_intersecting_pairs.py new file mode 100644 index 00000000..15b53b96 --- /dev/null +++ b/modules/bvh/python/tests/test_compute_intersecting_pairs.py @@ -0,0 +1,139 @@ +# +# 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 + + +class TestComputeIntersectingPairs: + def test_empty_mesh(self): + """Test with an empty mesh.""" + mesh = lagrange.SurfaceMesh() + intersections = lagrange.bvh.compute_intersecting_pairs(mesh) + assert len(intersections) == 0 + + def test_single_triangle(self): + """Test with a single triangle (no self-intersections).""" + mesh = lagrange.SurfaceMesh() + mesh.add_vertex([0.0, 0.0, 0.0]) + mesh.add_vertex([1.0, 0.0, 0.0]) + mesh.add_vertex([0.0, 1.0, 0.0]) + mesh.add_triangle(0, 1, 2) + + intersections = lagrange.bvh.compute_intersecting_pairs(mesh) + assert len(intersections) == 0 + + def test_non_intersecting_triangles(self): + """Test with two non-intersecting triangles.""" + mesh = lagrange.SurfaceMesh() + # Triangle 1 in XY plane at z=0 + mesh.add_vertex([0.0, 0.0, 0.0]) + mesh.add_vertex([1.0, 0.0, 0.0]) + mesh.add_vertex([0.0, 1.0, 0.0]) + mesh.add_triangle(0, 1, 2) + + # Triangle 2 in XY plane at z=1 (parallel, separated) + mesh.add_vertex([0.0, 0.0, 1.0]) + mesh.add_vertex([1.0, 0.0, 1.0]) + mesh.add_vertex([0.0, 1.0, 1.0]) + mesh.add_triangle(3, 4, 5) + + intersections = lagrange.bvh.compute_intersecting_pairs(mesh) + assert len(intersections) == 0 + + def test_adjacent_triangles(self): + """Test that adjacent triangles sharing an edge are not reported.""" + mesh = lagrange.SurfaceMesh() + mesh.add_vertex([0.0, 0.0, 0.0]) + mesh.add_vertex([1.0, 0.0, 0.0]) + mesh.add_vertex([0.5, 1.0, 0.0]) + mesh.add_vertex([0.5, -1.0, 0.0]) + + # Two triangles sharing edge (0, 1) + mesh.add_triangle(0, 1, 2) + mesh.add_triangle(0, 1, 3) + + intersections = lagrange.bvh.compute_intersecting_pairs(mesh) + assert len(intersections) == 0 + + def test_intersecting_triangles(self): + """Test with two triangles that intersect.""" + mesh = lagrange.SurfaceMesh() + # Triangle 1 in XY plane + mesh.add_vertex([-1.0, 0.0, 0.0]) + mesh.add_vertex([1.0, 0.0, 0.0]) + mesh.add_vertex([0.0, 1.0, 0.0]) + mesh.add_triangle(0, 1, 2) + + # Triangle 2 crossing through triangle 1 + mesh.add_vertex([0.0, 0.5, -1.0]) + mesh.add_vertex([0.0, 0.5, 1.0]) + mesh.add_vertex([0.0, -0.5, 0.0]) + mesh.add_triangle(3, 4, 5) + + intersections = lagrange.bvh.compute_intersecting_pairs(mesh) + assert len(intersections) == 1 + assert intersections[0] == (0, 1) + + def test_multiple_intersections(self): + """Test with multiple self-intersections.""" + mesh = lagrange.SurfaceMesh() + + # Create a mesh with known intersections + # Triangle 0: horizontal at z=0 + mesh.add_vertex([-2.0, -2.0, 0.0]) + mesh.add_vertex([2.0, -2.0, 0.0]) + mesh.add_vertex([0.0, 2.0, 0.0]) + mesh.add_triangle(0, 1, 2) + + # Triangle 1: vertical crossing triangle 0 + mesh.add_vertex([0.0, 0.0, -1.0]) + mesh.add_vertex([0.0, 0.0, 1.0]) + mesh.add_vertex([1.0, 0.0, 0.0]) + mesh.add_triangle(3, 4, 5) + + # Triangle 2: another vertical crossing triangle 0 + mesh.add_vertex([-0.5, 0.0, -1.0]) + mesh.add_vertex([-0.5, 0.0, 1.0]) + mesh.add_vertex([-1.5, 0.0, 0.0]) + mesh.add_triangle(6, 7, 8) + + intersections = lagrange.bvh.compute_intersecting_pairs(mesh) + + # Should find at least 2 intersections: (0,1) and (0,2) + assert len(intersections) >= 2 + + # Check that first indices are smaller than second indices + for pair in intersections: + assert pair[0] < pair[1] + + def test_quad_mesh_throws(self): + """Test that non-triangle meshes throw an error.""" + mesh = lagrange.SurfaceMesh() + 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_quad(0, 1, 2, 3) + + with pytest.raises(RuntimeError): + lagrange.bvh.compute_intersecting_pairs(mesh) + + def test_return_type(self): + """Test that the return type is a list.""" + mesh = lagrange.SurfaceMesh() + mesh.add_vertex([0.0, 0.0, 0.0]) + mesh.add_vertex([1.0, 0.0, 0.0]) + mesh.add_vertex([0.0, 1.0, 0.0]) + mesh.add_triangle(0, 1, 2) + + intersections = lagrange.bvh.compute_intersecting_pairs(mesh) + assert isinstance(intersections, list) diff --git a/modules/bvh/src/compute_intersecting_pairs.cpp b/modules/bvh/src/compute_intersecting_pairs.cpp new file mode 100644 index 00000000..1ae402f8 --- /dev/null +++ b/modules/bvh/src/compute_intersecting_pairs.cpp @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include +#include +#include + +namespace lagrange::bvh { + +template +AdjacencyList compute_intersecting_pairs(const SurfaceMesh& mesh) +{ + // Check that mesh is a triangle mesh + if (!mesh.is_triangle_mesh()) { + throw Error("compute_intersecting_pairs requires a triangle mesh"); + } + + const Index dim = mesh.get_dimension(); + if (dim != 3) { + throw Error("compute_intersecting_pairs only supports 3D meshes"); + } + + const Index num_facets = mesh.get_num_facets(); + if (num_facets == 0) { + using IndexArray = typename AdjacencyList::IndexArray; + using ValueArray = typename AdjacencyList::ValueArray; + return AdjacencyList(ValueArray(), IndexArray(1, 0)); + } + + // The underlying AABB tree uses uint32_t indices; verify the facet count fits + if (num_facets > static_cast(std::numeric_limits::max())) { + throw Error( + "compute_intersecting_pairs: mesh has too many facets for the AABB tree (max 2^32-1)"); + } + + logger().trace("Computing intersecting pairs for mesh with {} facets", num_facets); + + // Build AABB tree for the mesh + TriangleAABBTree aabb_tree(mesh); + + // Get mesh data + const auto vertices = vertex_view(mesh); + + // Thread-local storage for intersecting pairs found by each thread (flat list to avoid + // O(num_facets * num_threads) memory allocation) + using Pair = std::pair; + tbb::enumerable_thread_specific> thread_local_pairs; + + // Parallel loop over all facets + tbb::parallel_for(Index(0), num_facets, [&](Index i) { + // Get vertices of facet i + auto facet_i_vertices = mesh.get_facet_vertices(i); + la_debug_assert(facet_i_vertices.size() == 3); + + // Get positions of facet i vertices + auto p0_i = vertices.row(facet_i_vertices[0]); + auto p1_i = vertices.row(facet_i_vertices[1]); + auto p2_i = vertices.row(facet_i_vertices[2]); + + // Compute bounding box for facet i + typename TriangleAABBTree::AlignedBoxType bbox_i; + bbox_i.setEmpty(); + bbox_i.extend(p0_i.transpose()); + bbox_i.extend(p1_i.transpose()); + bbox_i.extend(p2_i.transpose()); + + // Local storage for this thread's intersecting pairs + auto& local_pairs = thread_local_pairs.local(); + + // Query AABB tree for potentially intersecting facets + std::vector candidates_raw; + aabb_tree.get_aabb().intersect(bbox_i, candidates_raw); + + // Check each candidate facet + for (uint32_t j_raw : candidates_raw) { + Index j = static_cast(j_raw); + // Only check j > i to avoid duplicates and self-intersection + if (j <= i) continue; + + // No adjacency pre-filtering: triangle_triangle_intersection with + // include_boundary=false correctly returns false for pairs that only + // touch at shared vertices or edges, so adjacent facets are handled + // by the geometric test without special-casing. + + // Get vertices of facet j + auto facet_j_vertices = mesh.get_facet_vertices(j); + la_debug_assert(facet_j_vertices.size() == 3); + + // Get positions of facet j vertices + auto p0_j = vertices.row(facet_j_vertices[0]); + auto p1_j = vertices.row(facet_j_vertices[1]); + auto p2_j = vertices.row(facet_j_vertices[2]); + + // Check for intersection using exact predicates + if (triangle_triangle_intersection( + span(p0_i.data(), 3), + span(p1_i.data(), 3), + span(p2_i.data(), 3), + span(p0_j.data(), 3), + span(p1_j.data(), 3), + span(p2_j.data(), 3))) { + local_pairs.push_back({i, j}); + } + } + }); + + // Merge results from all threads into per-facet neighbor lists + using NeighborList = std::vector; + std::vector merged_neighbors(num_facets); + for (const auto& local_pairs : thread_local_pairs) { + for (const auto& [i, j] : local_pairs) { + merged_neighbors[i].push_back(j); + merged_neighbors[j].push_back(i); + } + } + + // Count total number of neighbors and build index array + using IndexArray = typename AdjacencyList::IndexArray; + using ValueArray = typename AdjacencyList::ValueArray; + + IndexArray adjacency_index(num_facets + 1, 0); + for (Index i = 0; i < num_facets; ++i) { + adjacency_index[i + 1] = static_cast(merged_neighbors[i].size()); + } + std::partial_sum(adjacency_index.begin(), adjacency_index.end(), adjacency_index.begin()); + + // Build value array + ValueArray adjacency_data(adjacency_index.back()); + for (Index i = 0; i < num_facets; ++i) { + std::copy( + merged_neighbors[i].begin(), + merged_neighbors[i].end(), + adjacency_data.begin() + adjacency_index[i]); + } + + logger().trace("Found {} intersecting facet pairs", adjacency_index.back() / 2); + + return AdjacencyList(std::move(adjacency_data), std::move(adjacency_index)); +} + +// Explicit template instantiations +#define LA_X_compute_intersecting_pairs(_, Scalar, Index) \ + template LA_BVH_API AdjacencyList compute_intersecting_pairs( \ + const SurfaceMesh&); +LA_SURFACE_MESH_X(compute_intersecting_pairs, 0) + +} // namespace lagrange::bvh diff --git a/modules/bvh/src/compute_mesh_distances.cpp b/modules/bvh/src/compute_mesh_distances.cpp index 5b60eed9..0c6539b0 100644 --- a/modules/bvh/src/compute_mesh_distances.cpp +++ b/modules/bvh/src/compute_mesh_distances.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -36,11 +37,9 @@ namespace lagrange::bvh { namespace { /// -/// Compute the distance from each vertex in @p mesh to the closest point on @p tree, +/// Compute the distance from each vertex in @p mesh to the closest point on a triangle tree, /// writing the results into the pre-allocated output span @p out_distances. /// -/// @pre out_distances.size() == mesh.get_num_vertices() -/// template void compute_vertex_distances( const SurfaceMesh& mesh, @@ -67,6 +66,65 @@ void compute_vertex_distances( }); } +/// +/// Compute the distance from each vertex in @p mesh to the nearest point in a nanoflann kd-tree, +/// writing the results into the pre-allocated output span @p out_distances. +/// +template +void compute_vertex_distances_point_cloud( + const SurfaceMesh& mesh, + const SurfaceMesh& target, + span out_distances) +{ + const Index num_vertices = mesh.get_num_vertices(); + la_debug_assert(out_distances.size() == num_vertices); + + if (target.get_num_vertices() == 0) { + std::fill(out_distances.begin(), out_distances.end(), Scalar(0)); + return; + } + + using VertexArray = Eigen::Matrix; + + BVHNanoflann kdtree; + kdtree.build(VertexArray(vertex_view(target))); + + auto source_vertices = vertex_view(mesh); + + tbb::parallel_for(Index(0), num_vertices, [&](Index vi) { + auto result = kdtree.query_closest_point(source_vertices.row(vi)); + out_distances[vi] = std::sqrt(result.squared_distance); + }); +} + +/// +/// Compute vertex distances from @p source to @p target, automatically selecting +/// a TriangleAABBTree (for triangle meshes) or BVHNanoflann (for point clouds). +/// +template +void compute_vertex_distances_auto( + const SurfaceMesh& source, + const SurfaceMesh& target, + span out_distances) +{ + if (target.get_num_facets() > 0) { + la_runtime_assert( + target.is_triangle_mesh(), + "Target mesh must be a triangle mesh or a point cloud (mesh with zero facets)."); + TriangleAABBTree tree(target); + compute_vertex_distances(source, tree, out_distances); + } else { + if (source.get_dimension() == 3) { + compute_vertex_distances_point_cloud<3>(source, target, out_distances); + } else { + la_runtime_assert( + source.get_dimension() == 2, + "Only 2D and 3D meshes are supported for point cloud distance computation."); + compute_vertex_distances_point_cloud<2>(source, target, out_distances); + } + } +} + } // namespace template @@ -78,7 +136,9 @@ AttributeId compute_mesh_distances( la_runtime_assert( source.get_dimension() == target.get_dimension(), "Source and target meshes must have the same spatial dimension."); - la_runtime_assert(target.is_triangle_mesh(), "Target mesh must be a triangle mesh."); + la_runtime_assert( + target.get_num_facets() == 0 || target.is_triangle_mesh(), + "Target mesh must be a triangle mesh or a point cloud (mesh with zero facets)."); const AttributeId attr_id = internal::find_or_create_attribute( source, @@ -88,11 +148,9 @@ AttributeId compute_mesh_distances( 1, internal::ResetToDefault::No); - TriangleAABBTree tree(target); - // Write directly into the attribute buffer — no temporary vector needed. auto& attr = source.template ref_attribute(attr_id); - compute_vertex_distances(source, tree, attr.ref_all()); + compute_vertex_distances_auto(source, target, attr.ref_all()); return attr_id; } @@ -105,20 +163,22 @@ Scalar compute_hausdorff( la_runtime_assert( source.get_dimension() == target.get_dimension(), "Source and target meshes must have the same spatial dimension."); - la_runtime_assert(source.is_triangle_mesh(), "Source mesh must be a triangle mesh."); - la_runtime_assert(target.is_triangle_mesh(), "Target mesh must be a triangle mesh."); + la_runtime_assert( + source.get_num_facets() == 0 || source.is_triangle_mesh(), + "Source mesh must be a triangle mesh or a point cloud (mesh with zero facets)."); + la_runtime_assert( + target.get_num_facets() == 0 || target.is_triangle_mesh(), + "Target mesh must be a triangle mesh or a point cloud (mesh with zero facets)."); // Directed source → target. - TriangleAABBTree tree_target(target); std::vector dist_fwd(source.get_num_vertices()); - compute_vertex_distances(source, tree_target, span(dist_fwd)); + compute_vertex_distances_auto(source, target, span(dist_fwd)); Scalar d_fwd = dist_fwd.empty() ? Scalar(0) : *std::max_element(dist_fwd.begin(), dist_fwd.end()); // Directed target → source. - TriangleAABBTree tree_source(source); std::vector dist_bwd(target.get_num_vertices()); - compute_vertex_distances(target, tree_source, span(dist_bwd)); + compute_vertex_distances_auto(target, source, span(dist_bwd)); Scalar d_bwd = dist_bwd.empty() ? Scalar(0) : *std::max_element(dist_bwd.begin(), dist_bwd.end()); @@ -133,18 +193,20 @@ Scalar compute_chamfer( la_runtime_assert( source.get_dimension() == target.get_dimension(), "Source and target meshes must have the same spatial dimension."); - la_runtime_assert(source.is_triangle_mesh(), "Source mesh must be a triangle mesh."); - la_runtime_assert(target.is_triangle_mesh(), "Target mesh must be a triangle mesh."); + la_runtime_assert( + source.get_num_facets() == 0 || source.is_triangle_mesh(), + "Source mesh must be a triangle mesh or a point cloud (mesh with zero facets)."); + la_runtime_assert( + target.get_num_facets() == 0 || target.is_triangle_mesh(), + "Target mesh must be a triangle mesh or a point cloud (mesh with zero facets)."); // Source → target distances. - TriangleAABBTree tree_target(target); std::vector dist_fwd(source.get_num_vertices()); - compute_vertex_distances(source, tree_target, span(dist_fwd)); + compute_vertex_distances_auto(source, target, span(dist_fwd)); // Target → source distances. - TriangleAABBTree tree_source(source); std::vector dist_bwd(target.get_num_vertices()); - compute_vertex_distances(target, tree_source, span(dist_bwd)); + compute_vertex_distances_auto(target, source, span(dist_bwd)); auto sum_squared = [](const std::vector& d) -> Scalar { return tbb::parallel_reduce( diff --git a/modules/bvh/src/compute_uv_overlap.cpp b/modules/bvh/src/compute_uv_overlap.cpp index 0b401cc9..598e0624 100644 --- a/modules/bvh/src/compute_uv_overlap.cpp +++ b/modules/bvh/src/compute_uv_overlap.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -830,26 +831,20 @@ std::vector chart_aware_graph_color( return color; } -} // namespace - -// --------------------------------------------------------------------------- -// compute_uv_overlap — main entry point -// --------------------------------------------------------------------------- +template +struct InternalOverlapResult +{ + bool has_overlap = false; + std::optional overlap_area; + std::vector> overlapping_pairs; + std::vector colors; +}; template -UVOverlapResult compute_uv_overlap( - SurfaceMesh& mesh, +InternalOverlapResult compute_uv_overlap_impl( + SurfaceMesh uv_mesh, const UVOverlapOptions& options) { - la_runtime_assert(mesh.is_triangle_mesh(), "compute_uv_overlap: mesh must be triangulated."); - - // ----- Phase 1: extract UV mesh ----- - UVMeshOptions uv_opts; - uv_opts.uv_attribute_name = options.uv_attribute_name; - uv_opts.element_types = UVMeshOptions::ElementTypes::All; - // uv_mesh_view creates a 2-D mesh whose vertex positions are UV coordinates. - auto uv_mesh = uv_mesh_view(mesh, uv_opts); - const Index num_facets = uv_mesh.get_num_facets(); if (num_facets == 0) return {}; @@ -889,7 +884,7 @@ UVOverlapResult compute_uv_overlap( { std::vector> pairs; // only used when collect_pairs size_t count = 0; - Scalar area = 0.f; + double area = 0; }; tbb::enumerable_thread_specific tls; @@ -914,7 +909,7 @@ UVOverlapResult compute_uv_overlap( // ----- Phase 5: accumulate results ----- size_t total_results = 0; - Scalar total_area = Scalar(0); + double total_area = 0; std::vector> overlap_edges; for (auto& loc : tls) { @@ -930,16 +925,66 @@ UVOverlapResult compute_uv_overlap( if (total_results == 0) return {}; - UVOverlapResult result; + InternalOverlapResult result; result.has_overlap = true; if (compute_area) result.overlap_area = total_area; // ----- Phase 6: optional per-facet overlap coloring ----- if (options.compute_overlap_coloring && !overlap_edges.empty()) { const auto uv_adj = compute_facet_facet_adjacency(uv_mesh); - std::vector colors = - chart_aware_graph_color(num_facets, overlap_edges, uv_adj); + result.colors = chart_aware_graph_color(num_facets, overlap_edges, uv_adj); + } + + if (options.compute_overlapping_pairs) { + tbb::parallel_sort(overlap_edges.begin(), overlap_edges.end()); + result.overlapping_pairs = std::move(overlap_edges); + } + + return result; +} + +} // namespace +// --------------------------------------------------------------------------- +// compute_uv_overlap — main entry point +// --------------------------------------------------------------------------- + +template +UVOverlapResult compute_uv_overlap( + SurfaceMesh& mesh, + const UVOverlapOptions& options) +{ + la_runtime_assert(mesh.is_triangle_mesh(), "compute_uv_overlap: mesh must be triangulated."); + + // ----- Phase 1: Extract UV mesh and dispatch internal implementation + UVMeshOptions uv_opts; + uv_opts.uv_attribute_name = options.uv_attribute_name; + uv_opts.element_types = UVMeshOptions::ElementTypes::All; + auto internal = [&]() -> InternalOverlapResult { + using OtherScalar = std::conditional_t, double, float>; + if (uv_attribute_id(mesh, uv_opts)) { + return compute_uv_overlap_impl( + uv_mesh_view(mesh, uv_opts), + options); + } else if (uv_attribute_id(mesh, uv_opts)) { + return compute_uv_overlap_impl( + uv_mesh_view(mesh, uv_opts), + options); + } else { + throw Error("compute_uv_overlap: no suitable UV attribute found."); + } + }(); + + // ----- Phase 2: Convert internal result to public result + UVOverlapResult result; + result.has_overlap = internal.has_overlap; + if (internal.overlap_area.has_value()) { + result.overlap_area = static_cast(internal.overlap_area.value()); + } + result.overlapping_pairs = std::move(internal.overlapping_pairs); + + // Write the coloring attribute to the original mesh. + if (!internal.colors.empty()) { const AttributeId attr_id = internal::find_or_create_attribute( mesh, options.overlap_coloring_attribute_name, @@ -947,22 +992,14 @@ UVOverlapResult compute_uv_overlap( AttributeUsage::Scalar, 1, internal::ResetToDefault::No); - auto& attr = mesh.template ref_attribute(attr_id); auto attr_data = attr.ref_all(); - la_debug_assert(static_cast(attr_data.size()) == num_facets); - for (Index f = 0; f < num_facets; ++f) { - attr_data[f] = colors[f]; - } - + la_debug_assert(attr_data.size() == internal.colors.size()); + la_debug_assert(attr_data.size() == static_cast(mesh.get_num_facets())); + std::copy(internal.colors.begin(), internal.colors.end(), attr_data.begin()); result.overlap_coloring_id = attr_id; } - if (options.compute_overlapping_pairs) { - tbb::parallel_sort(overlap_edges.begin(), overlap_edges.end()); - result.overlapping_pairs = std::move(overlap_edges); - } - return result; } diff --git a/modules/bvh/tests/test_compute_intersecting_pairs.cpp b/modules/bvh/tests/test_compute_intersecting_pairs.cpp new file mode 100644 index 00000000..8eea5452 --- /dev/null +++ b/modules/bvh/tests/test_compute_intersecting_pairs.cpp @@ -0,0 +1,294 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include + +#include + +TEST_CASE("bvh::compute_intersecting_pairs", "[bvh][intersecting_pairs]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SECTION("Empty mesh") + { + SurfaceMesh mesh(3); + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 0); + } + + SECTION("Single triangle") + { + SurfaceMesh mesh(3); + mesh.add_vertex({0.0, 0.0, 0.0}); + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_vertex({0.0, 1.0, 0.0}); + mesh.add_triangle(0, 1, 2); + + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 1); + REQUIRE(intersections.get_neighbors(0).size() == 0); + } + + SECTION("Two non-intersecting triangles") + { + SurfaceMesh mesh(3); + // Triangle 1 in XY plane at z=0 + mesh.add_vertex({0.0, 0.0, 0.0}); + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_vertex({0.0, 1.0, 0.0}); + mesh.add_triangle(0, 1, 2); + + // Triangle 2 in XY plane at z=1 (parallel, separated) + mesh.add_vertex({0.0, 0.0, 1.0}); + mesh.add_vertex({1.0, 0.0, 1.0}); + mesh.add_vertex({0.0, 1.0, 1.0}); + mesh.add_triangle(3, 4, 5); + + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 2); + REQUIRE(intersections.get_neighbors(0).size() == 0); + REQUIRE(intersections.get_neighbors(1).size() == 0); + } + + SECTION("Two adjacent triangles (sharing edge)") + { + SurfaceMesh mesh(3); + mesh.add_vertex({0.0, 0.0, 0.0}); + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_vertex({0.5, 1.0, 0.0}); + mesh.add_vertex({0.5, -1.0, 0.0}); + + // Two triangles sharing edge (0, 1) + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 1, 3); + + // Edge-adjacent triangles without interior overlap must not be reported. + // The geometric test (include_boundary=false) correctly returns false for them. + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 2); + REQUIRE(intersections.get_neighbors(0).size() == 0); + REQUIRE(intersections.get_neighbors(1).size() == 0); + } + + SECTION("Two intersecting triangles") + { + SurfaceMesh mesh(3); + // Triangle 1 in XY plane + mesh.add_vertex({-1.0, 0.0, 0.0}); + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_vertex({0.0, 1.0, 0.0}); + mesh.add_triangle(0, 1, 2); + + // Triangle 2 crossing through triangle 1 + mesh.add_vertex({0.0, 0.5, -1.0}); + mesh.add_vertex({0.0, 0.5, 1.0}); + mesh.add_vertex({0.0, -0.5, 0.0}); + mesh.add_triangle(3, 4, 5); + + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 2); + auto n0 = intersections.get_neighbors(0); + auto n1 = intersections.get_neighbors(1); + REQUIRE(n0.size() == 1); + REQUIRE(n1.size() == 1); + REQUIRE(n0[0] == 1); + REQUIRE(n1[0] == 0); + } + + SECTION("Cube with self-intersecting face") + { + SurfaceMesh mesh(3); + // Simple quad split into triangles + 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}); + + // Two triangles forming a quad + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + + // Add an intersecting triangle in the middle + mesh.add_vertex({0.5, 0.5, -0.5}); + mesh.add_vertex({0.5, 0.5, 0.5}); + mesh.add_vertex({0.25, 0.25, 0.0}); + mesh.add_triangle(4, 5, 6); + + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 3); + // The crossing triangle (index 2) must intersect both triangles of the quad (0 and 1) + auto n2 = intersections.get_neighbors(2); + REQUIRE(n2.size() == 2); + std::set n2_actual(n2.begin(), n2.end()); + std::set n2_expected{Index(0), Index(1)}; + REQUIRE(n2_actual == n2_expected); + } + + SECTION("Topologically duplicate triangles") + { + // Two triangles referencing the exact same three vertices are spatially identical: + // their interiors overlap completely and must be reported as intersecting. + SurfaceMesh mesh(3); + mesh.add_vertex({0.0, 0.0, 0.0}); + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_vertex({0.0, 1.0, 0.0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 1, 2); // exact duplicate + + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 2); + REQUIRE(intersections.get_neighbors(0).size() == 1); + REQUIRE(intersections.get_neighbors(1).size() == 1); + REQUIRE(intersections.get_neighbors(0)[0] == Index(1)); + REQUIRE(intersections.get_neighbors(1)[0] == Index(0)); + } + + SECTION("Vertex-adjacent triangles with interior overlap") + { + // T0 lies in the z=0 plane. T1 shares vertex (0,0,0) with T0 but its edge + // (2,1,-2)->(2,1,2) pierces T0's interior at (2,1,0), which is strictly inside T0. + // The shared vertex is boundary contact and must not prevent detection of the + // interior crossing. + SurfaceMesh mesh(3); + mesh.add_vertex({0.0, 0.0, 0.0}); // 0 (shared) + mesh.add_vertex({4.0, 0.0, 0.0}); // 1 + mesh.add_vertex({2.0, 4.0, 0.0}); // 2 + mesh.add_triangle(0, 1, 2); // T0 in z=0 plane + + mesh.add_vertex({2.0, 1.0, -2.0}); // 3 + mesh.add_vertex({2.0, 1.0, 2.0}); // 4 + mesh.add_triangle(0, 3, 4); // T1: shares vertex 0; edge 3-4 pierces T0's interior + + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 2); + REQUIRE(intersections.get_neighbors(0).size() == 1); + REQUIRE(intersections.get_neighbors(1).size() == 1); + REQUIRE(intersections.get_neighbors(0)[0] == Index(1)); + REQUIRE(intersections.get_neighbors(1)[0] == Index(0)); + } + + SECTION("Non-coplanar triangles sharing a vertex with crossing opposite edges") + { + // T0 lies in the y=0 plane; T1 lies in the x=z plane. They share vertex V0=(0,0,0). + // The opposite edge of T0 (V1=(2,0,0) -- V2=(0,0,2)) and the opposite edge of T1 + // (V3=(1,-1,1) -- V4=(1,1,1)) both pass through (1,0,1), so the opposite edges + // intersect. The intersection line of the two planes (x=z, y=0) runs from V0 to + // (1,0,1), and its interior is strictly inside both triangles, so the pair must be + // reported. + SurfaceMesh mesh(3); + mesh.add_vertex({0.0, 0.0, 0.0}); // 0 (shared) + mesh.add_vertex({2.0, 0.0, 0.0}); // 1 + mesh.add_vertex({0.0, 0.0, 2.0}); // 2 + mesh.add_triangle(0, 1, 2); // T0 in y=0 plane + + mesh.add_vertex({1.0, -1.0, 1.0}); // 3 + mesh.add_vertex({1.0, 1.0, 1.0}); // 4 + mesh.add_triangle(0, 3, 4); // T1 in x=z plane; shares vertex 0 + + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 2); + REQUIRE(intersections.get_neighbors(0).size() == 1); + REQUIRE(intersections.get_neighbors(1).size() == 1); + REQUIRE(intersections.get_neighbors(0)[0] == Index(1)); + REQUIRE(intersections.get_neighbors(1)[0] == Index(0)); + } + + SECTION("Non-triangle mesh should throw") + { + SurfaceMesh mesh(3); + 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_quad(0, 1, 2, 3); + + REQUIRE_THROWS(bvh::compute_intersecting_pairs(mesh)); + } + + SECTION("Multiple intersections") + { + SurfaceMesh mesh(3); + + // Create a mesh with known intersections + // Triangle 0: horizontal at z=0 + mesh.add_vertex({-2.0, -2.0, 0.0}); + mesh.add_vertex({2.0, -2.0, 0.0}); + mesh.add_vertex({0.0, 2.0, 0.0}); + mesh.add_triangle(0, 1, 2); + + // Triangle 1: vertical crossing triangle 0 + mesh.add_vertex({0.0, 0.0, -1.0}); + mesh.add_vertex({0.0, 0.0, 1.0}); + mesh.add_vertex({1.0, 0.0, 0.0}); + mesh.add_triangle(3, 4, 5); + + // Triangle 2: another vertical crossing triangle 0 + mesh.add_vertex({-0.5, 0.0, -1.0}); + mesh.add_vertex({-0.5, 0.0, 1.0}); + mesh.add_vertex({-1.5, 0.0, 0.0}); + mesh.add_triangle(6, 7, 8); + + auto intersections = bvh::compute_intersecting_pairs(mesh); + REQUIRE(intersections.get_num_entries() == 3); + + // Triangle 0 should intersect with both triangle 1 and 2 + auto n0 = intersections.get_neighbors(0); + REQUIRE(n0.size() == 2); + std::set s0(n0.begin(), n0.end()); + REQUIRE(s0.count(1) == 1); + REQUIRE(s0.count(2) == 1); + + // Triangle 1 should intersect with triangle 0 + auto n1 = intersections.get_neighbors(1); + REQUIRE(n1.size() == 1); + REQUIRE(n1[0] == 0); + + // Triangle 2 should intersect with triangle 0 + auto n2 = intersections.get_neighbors(2); + REQUIRE(n2.size() == 1); + REQUIRE(n2[0] == 0); + } + + SECTION("Real mesh - ball.obj") + { + auto mesh = lagrange::testing::load_surface_mesh("open/core/ball.obj"); + + REQUIRE(mesh.is_triangle_mesh()); + REQUIRE(mesh.get_num_facets() > 0); + + auto intersections = bvh::compute_intersecting_pairs(mesh); + + // Count total intersecting pairs and check specific pair + Index total_intersections = 0; + bool found_0_4801 = false; + + for (Index i = 0; i < intersections.get_num_entries(); ++i) { + auto neighbors = intersections.get_neighbors(i); + for (Index j : neighbors) { + if (i < j) { + total_intersections++; + } + if ((i == 0 && j == 4801) || (i == 4801 && j == 0)) { + found_0_4801 = true; + } + } + } + + REQUIRE_FALSE(found_0_4801); + + // Ball.obj is a valid sphere mesh: no pair of facets has overlapping interiors. + REQUIRE(total_intersections == 0); + } +} diff --git a/modules/bvh/tests/test_compute_mesh_distances.cpp b/modules/bvh/tests/test_compute_mesh_distances.cpp index 9a9e63c0..af56f5f4 100644 --- a/modules/bvh/tests/test_compute_mesh_distances.cpp +++ b/modules/bvh/tests/test_compute_mesh_distances.cpp @@ -65,6 +65,13 @@ make_concentric_spheres(Scalar r1, Scalar r2, size_t num_sections = 64) return {sphere_a, sphere_b}; } +// Strip facets from a mesh to create a point cloud. +SurfaceMesh to_point_cloud(SurfaceMesh mesh) +{ + mesh.clear_facets(); + return mesh; +} + } // namespace // --------------------------------------------------------------------------- @@ -157,6 +164,50 @@ TEST_CASE("compute_mesh_distances", "[bvh][mesh_distances]") REQUIRE(empty_source.get_num_vertices() == 0); REQUIRE(dist.size() == 0); } + + SECTION("point cloud: mesh to point cloud") + { + const Scalar d = 1.5f; + auto [sq_a, sq_b] = make_parallel_squares(d); + auto pc_b = to_point_cloud(sq_b); + + auto attr_id = bvh::compute_mesh_distances(sq_a, pc_b); + auto dist = attribute_matrix_view(sq_a, attr_id); + + // Vertices of sq_a are directly below vertices of pc_b, so distance = d. + for (Index vi = 0; vi < sq_a.get_num_vertices(); ++vi) { + REQUIRE_THAT(dist(vi, 0), Catch::Matchers::WithinAbs(d, 1e-5f)); + } + } + + SECTION("point cloud: point cloud to mesh") + { + const Scalar d = 1.5f; + auto [sq_a, sq_b] = make_parallel_squares(d); + auto pc_a = to_point_cloud(sq_a); + + auto attr_id = bvh::compute_mesh_distances(pc_a, sq_b); + auto dist = attribute_matrix_view(pc_a, attr_id); + + for (Index vi = 0; vi < pc_a.get_num_vertices(); ++vi) { + REQUIRE_THAT(dist(vi, 0), Catch::Matchers::WithinAbs(d, 1e-5f)); + } + } + + SECTION("point cloud: point cloud to point cloud") + { + const Scalar d = 1.5f; + auto [sq_a, sq_b] = make_parallel_squares(d); + auto pc_a = to_point_cloud(sq_a); + auto pc_b = to_point_cloud(sq_b); + + auto attr_id = bvh::compute_mesh_distances(pc_a, pc_b); + auto dist = attribute_matrix_view(pc_a, attr_id); + + for (Index vi = 0; vi < pc_a.get_num_vertices(); ++vi) { + REQUIRE_THAT(dist(vi, 0), Catch::Matchers::WithinAbs(d, 1e-5f)); + } + } } // --------------------------------------------------------------------------- @@ -244,6 +295,37 @@ TEST_CASE("compute_hausdorff", "[bvh][mesh_distances]") Scalar h = bvh::compute_hausdorff(empty_a, empty_b); REQUIRE_THAT(h, Catch::Matchers::WithinAbs(0.0f, 1e-6f)); } + + SECTION("point cloud: point cloud to point cloud") + { + const Scalar d = 2.0f; + auto [sq_a, sq_b] = make_parallel_squares(d); + auto pc_a = to_point_cloud(sq_a); + auto pc_b = to_point_cloud(sq_b); + + Scalar h = bvh::compute_hausdorff(pc_a, pc_b); + REQUIRE_THAT(h, Catch::Matchers::WithinAbs(d, 1e-5f)); + } + + SECTION("point cloud: mesh to point cloud") + { + const Scalar d = 2.0f; + auto [sq_a, sq_b] = make_parallel_squares(d); + auto pc_b = to_point_cloud(sq_b); + + Scalar h = bvh::compute_hausdorff(sq_a, pc_b); + REQUIRE_THAT(h, Catch::Matchers::WithinAbs(d, 1e-5f)); + } + + SECTION("point cloud: point cloud to mesh") + { + const Scalar d = 2.0f; + auto [sq_a, sq_b] = make_parallel_squares(d); + auto pc_a = to_point_cloud(sq_a); + + Scalar h = bvh::compute_hausdorff(pc_a, sq_b); + REQUIRE_THAT(h, Catch::Matchers::WithinAbs(d, 1e-5f)); + } } // --------------------------------------------------------------------------- @@ -331,4 +413,35 @@ TEST_CASE("compute_chamfer", "[bvh][mesh_distances]") Scalar c = bvh::compute_chamfer(empty_a, empty_b); REQUIRE_THAT(c, Catch::Matchers::WithinAbs(0.0f, 1e-6f)); } + + SECTION("point cloud: point cloud to point cloud") + { + const Scalar d = 1.5f; + auto [sq_a, sq_b] = make_parallel_squares(d); + auto pc_a = to_point_cloud(sq_a); + auto pc_b = to_point_cloud(sq_b); + + Scalar c = bvh::compute_chamfer(pc_a, pc_b); + REQUIRE_THAT(c, Catch::Matchers::WithinAbs(2.0f * d * d, 1e-4f)); + } + + SECTION("point cloud: mesh to point cloud") + { + const Scalar d = 1.5f; + auto [sq_a, sq_b] = make_parallel_squares(d); + auto pc_b = to_point_cloud(sq_b); + + Scalar c = bvh::compute_chamfer(sq_a, pc_b); + REQUIRE_THAT(c, Catch::Matchers::WithinAbs(2.0f * d * d, 1e-4f)); + } + + SECTION("point cloud: point cloud to mesh") + { + const Scalar d = 1.5f; + auto [sq_a, sq_b] = make_parallel_squares(d); + auto pc_a = to_point_cloud(sq_a); + + Scalar c = bvh::compute_chamfer(pc_a, sq_b); + REQUIRE_THAT(c, Catch::Matchers::WithinAbs(2.0f * d * d, 1e-4f)); + } } diff --git a/modules/bvh/tests/test_compute_uv_overlap.cpp b/modules/bvh/tests/test_compute_uv_overlap.cpp index 0d1b78ba..47e270b2 100644 --- a/modules/bvh/tests/test_compute_uv_overlap.cpp +++ b/modules/bvh/tests/test_compute_uv_overlap.cpp @@ -36,25 +36,25 @@ using Index = uint32_t; /// The 3-D vertex positions are set to (u, v, 0) for convenience; they are not /// used by compute_uv_overlap. /// +template SurfaceMesh make_uv_mesh( - const std::vector>& uv_coords, + const std::vector>& uv_coords, const std::vector>& faces) { SurfaceMesh mesh; for (auto& uv : uv_coords) { - mesh.add_vertex({uv[0], uv[1], Scalar(0)}); + mesh.add_vertex({Scalar(uv[0]), Scalar(uv[1]), Scalar(0)}); } for (auto& f : faces) { mesh.add_triangle(f[0], f[1], f[2]); } - // Create a per-vertex UV attribute so that uv_mesh_view can extract UV positions. - const AttributeId uv_id = mesh.template create_attribute( + const AttributeId uv_id = mesh.template create_attribute( "@uv", AttributeElement::Vertex, AttributeUsage::UV, 2); - auto& attr = mesh.template ref_attribute(uv_id); + auto& attr = mesh.template ref_attribute(uv_id); auto values = attr.ref_all(); for (Index i = 0; i < static_cast(uv_coords.size()); ++i) { values[i * 2 + 0] = uv_coords[i][0]; @@ -490,6 +490,80 @@ TEST_CASE( REQUIRE(result.overlapping_pairs.size() > 0); } +// --------------------------------------------------------------------------- +// Different UV scalar type (double UVs on float mesh) +// --------------------------------------------------------------------------- + +TEST_CASE("compute_uv_overlap: double UVs on float mesh - no overlap", "[bvh][uv_overlap]") +{ + using namespace lagrange; + + auto mesh = make_uv_mesh( + {{0, 0}, {1, 0}, {0, 1}, {2, 0}, {3, 0}, {2, 1}}, + {{{0, 1, 2}}, {{3, 4, 5}}}); + + auto result = bvh::compute_uv_overlap(mesh); + REQUIRE_FALSE(result.overlap_area.has_value()); +} + +TEST_CASE("compute_uv_overlap: double UVs on float mesh - full overlap", "[bvh][uv_overlap]") +{ + using namespace lagrange; + + auto mesh = make_uv_mesh( + {{0, 0}, {1, 0}, {0, 1}, {0, 0}, {1, 0}, {0, 1}}, + {{{0, 1, 2}}, {{3, 4, 5}}}); + + auto result = bvh::compute_uv_overlap(mesh); + REQUIRE(result.overlap_area.has_value()); + REQUIRE_THAT(*result.overlap_area, Catch::Matchers::WithinAbs(0.5f, 1e-5f)); +} + +TEST_CASE("compute_uv_overlap: double UVs on float mesh - partial overlap", "[bvh][uv_overlap]") +{ + using namespace lagrange; + + auto mesh = make_uv_mesh( + {{0, 0}, {1, 0}, {0, 1}, {0.5, 0}, {1.5, 0}, {0.5, 1}}, + {{{0, 1, 2}}, {{3, 4, 5}}}); + + auto result = bvh::compute_uv_overlap(mesh); + REQUIRE(result.overlap_area.has_value()); + REQUIRE_THAT(*result.overlap_area, Catch::Matchers::WithinAbs(0.125f, 1e-5f)); +} + +TEST_CASE("compute_uv_overlap: double UVs on float mesh - adjacent triangles", "[bvh][uv_overlap]") +{ + using namespace lagrange; + + auto mesh = make_uv_mesh({{0, 0}, {1, 0}, {0, 1}, {1, 1}}, {{{0, 1, 2}}, {{1, 3, 2}}}); + + auto result = bvh::compute_uv_overlap(mesh); + REQUIRE_FALSE(result.overlap_area.has_value()); +} + +TEST_CASE("compute_uv_overlap: double UVs on float mesh - coloring", "[bvh][uv_overlap]") +{ + using namespace lagrange; + + auto mesh = make_uv_mesh( + {{0, 0}, {1, 0}, {0, 1}, {0.5, 0}, {1.5, 0}, {0.5, 1}, {1.0, 0}, {2.0, 0}, {1.0, 1}}, + {{{0, 1, 2}}, {{3, 4, 5}}, {{6, 7, 8}}}); + + bvh::UVOverlapOptions opts; + opts.compute_overlap_coloring = true; + auto result = bvh::compute_uv_overlap(mesh, opts); + + REQUIRE(result.overlap_area.has_value()); + REQUIRE(result.overlap_coloring_id != invalid_attribute_id()); + + auto colors = attribute_vector_view(mesh, result.overlap_coloring_id); + REQUIRE(colors.size() == 3); + REQUIRE(colors[0] != colors[1]); + REQUIRE(colors[1] != colors[2]); + REQUIRE(colors[0] == colors[2]); +} + // --------------------------------------------------------------------------- // Benchmarks (disabled by default; run with [!benchmark] tag) // --------------------------------------------------------------------------- diff --git a/modules/core/CMakeLists.txt b/modules/core/CMakeLists.txt index 620e0216..45f8dded 100644 --- a/modules/core/CMakeLists.txt +++ b/modules/core/CMakeLists.txt @@ -177,3 +177,7 @@ endif() if(LAGRANGE_MODULE_PYTHON) add_subdirectory(python) endif() + +if(LAGRANGE_BINDINGS_JS) + add_subdirectory(js) +endif() diff --git a/modules/core/include/lagrange/Edge.h b/modules/core/include/lagrange/Edge.h index f6f9f300..561741ee 100644 --- a/modules/core/include/lagrange/Edge.h +++ b/modules/core/include/lagrange/Edge.h @@ -21,6 +21,7 @@ #include #include #include +#include namespace lagrange { @@ -181,7 +182,11 @@ EdgeFacetMap compute_edge_facet_map_in_active_facets( const EdgeType edge(v1, v2); auto it = edge_facet_map.find(edge); if (it == edge_facet_map.end()) { + // GCC 13.1-13.3 -Warray-bounds false positive with -fsanitize=undefined + // https://gcc.gnu.org/bugzilla/show_bug.cgi?id=109727 + LA_IGNORE_ARRAY_BOUNDS_BEGIN edge_facet_map[edge] = {i}; + LA_IGNORE_ARRAY_BOUNDS_END } else { it->second.push_back(i); } diff --git a/modules/core/include/lagrange/ExactPredicates.h b/modules/core/include/lagrange/ExactPredicates.h index 6b1447e9..6c930b1a 100644 --- a/modules/core/include/lagrange/ExactPredicates.h +++ b/modules/core/include/lagrange/ExactPredicates.h @@ -52,7 +52,7 @@ class LA_CORE_API ExactPredicates /// /// @return 1 if the points are collinear, 0 otherwise. /// - virtual short collinear3D(double p1[3], double p2[3], double p3[3]) const; + virtual short collinear3D(const double p1[3], const double p2[3], const double p3[3]) const; /// /// Exact 2D orientation test. @@ -65,7 +65,7 @@ class LA_CORE_API ExactPredicates /// order; a negative value if they occur in clockwise order; and zero if they are /// collinear. /// - virtual short orient2D(double p1[2], double p2[2], double p3[2]) const = 0; + virtual short orient2D(const double p1[2], const double p2[2], const double p3[2]) const = 0; /// /// Exact 3D orientation test. @@ -80,7 +80,11 @@ class LA_CORE_API ExactPredicates /// order when viewed from above the plane. Returns a negative value if p4 lies /// above the plane. Returns zero if the points are coplanar. /// - virtual short orient3D(double p1[3], double p2[3], double p3[3], double p4[3]) const = 0; + virtual short orient3D( + const double p1[3], + const double p2[3], + const double p3[3], + const double p4[3]) const = 0; /// /// Exact 2D incircle test. @@ -95,7 +99,11 @@ class LA_CORE_API ExactPredicates /// are cocircular. The points p1, p2, and p3 must be in counterclockwise order, or /// the sign of the result will be reversed. /// - virtual short incircle(double p1[2], double p2[2], double p3[2], double p4[2]) const = 0; + virtual short incircle( + const double p1[2], + const double p2[2], + const double p3[2], + const double p4[2]) const = 0; /// /// Exact 3D insphere test. @@ -112,8 +120,12 @@ class LA_CORE_API ExactPredicates /// they have a positive orientation (as defined by orient3d()), or the sign of the /// result will be reversed. /// - virtual short insphere(double p1[3], double p2[3], double p3[3], double p4[3], double p5[3]) - const = 0; + virtual short insphere( + const double p1[3], + const double p2[3], + const double p3[3], + const double p4[3], + const double p5[3]) const = 0; }; /// @} diff --git a/modules/core/include/lagrange/ExactPredicatesShewchuk.h b/modules/core/include/lagrange/ExactPredicatesShewchuk.h index 44c92cc9..980b75cd 100644 --- a/modules/core/include/lagrange/ExactPredicatesShewchuk.h +++ b/modules/core/include/lagrange/ExactPredicatesShewchuk.h @@ -29,23 +29,29 @@ class LA_CORE_API ExactPredicatesShewchuk : public ExactPredicates /// /// @copydoc ExactPredicates::orient2D /// - virtual short orient2D(double p1[2], double p2[2], double p3[2]) const; + virtual short orient2D(const double p1[2], const double p2[2], const double p3[2]) const; /// /// @copydoc ExactPredicates::orient2D /// - virtual short orient3D(double p1[3], double p2[3], double p3[3], double p4[3]) const; + virtual short + orient3D(const double p1[3], const double p2[3], const double p3[3], const double p4[3]) const; /// /// @copydoc ExactPredicates::orient2D /// - virtual short incircle(double p1[2], double p2[2], double p3[2], double p4[2]) const; + virtual short + incircle(const double p1[2], const double p2[2], const double p3[2], const double p4[2]) const; /// /// @copydoc ExactPredicates::orient2D /// - virtual short insphere(double p1[3], double p2[3], double p3[3], double p4[3], double p5[3]) - const; + virtual short insphere( + const double p1[3], + const double p2[3], + const double p3[3], + const double p4[3], + const double p5[3]) const; }; /// @} diff --git a/modules/core/include/lagrange/attribute_names.h b/modules/core/include/lagrange/attribute_names.h index 849f2475..4c80e93d 100644 --- a/modules/core/include/lagrange/attribute_names.h +++ b/modules/core/include/lagrange/attribute_names.h @@ -61,6 +61,13 @@ struct AttributeName */ static constexpr std::string_view object_id = "object_id"; + /** + * Line ID (in obj files). Marks which OBJ line element each edge segment belongs to. + * 0 for regular faces, 1-based ID for line element segments. + * Paired with AttributeUsage::Scalar. + */ + static constexpr std::string_view line_id = "line_id"; + /** * Skinning weights, with all joints specified for each vertex. Paired with * AttributeUsage::Vector. diff --git a/modules/core/include/lagrange/compute_barycentric_coordinates.h b/modules/core/include/lagrange/compute_barycentric_coordinates.h index a56171e8..89980dd1 100644 --- a/modules/core/include/lagrange/compute_barycentric_coordinates.h +++ b/modules/core/include/lagrange/compute_barycentric_coordinates.h @@ -11,10 +11,36 @@ */ #pragma once -#include +#include + +// clang-format off +#include +#include +#include +// clang-format on + +#include +#include namespace lagrange { +/// +/// Compute the barycentric coordinates of point @p p with respect to triangle (v0, v1, v2). +/// +/// Uses a projection-based approach that solves a 2x2 Gram matrix system. This handles triangles in +/// any orientation, including those coplanar with a coordinate plane (e.g. z=0), triangles with a +/// vertex at the origin, and 2D points. For collinear or degenerate triangles, falls back to a +/// constrained least-squares solve with a partition-of-unity constraint (bary sums to 1). +/// +/// @param[in] v0 First triangle vertex. +/// @param[in] v1 Second triangle vertex. +/// @param[in] v2 Third triangle vertex. +/// @param[in] p Query point. +/// +/// @tparam PointType Eigen column or row vector type. +/// +/// @return Barycentric coordinates (w0, w1, w2) such that p ≈ w0*v0 + w1*v1 + w2*v2. +/// template auto compute_barycentric_coordinates( const Eigen::MatrixBase& v0, @@ -23,16 +49,70 @@ auto compute_barycentric_coordinates( const Eigen::MatrixBase& p) -> Eigen::Matrix { using Scalar = typename PointType::Scalar; - const auto dim = p.size(); - Eigen::Matrix M; - M.setZero(); - M.block(0, 0, dim, 1) = v0; - M.block(0, 1, dim, 1) = v1; - M.block(0, 2, dim, 1) = v2; - Eigen::Matrix rhs; - rhs.setZero(); - rhs.segment(0, dim) = p; - return (M.inverse() * rhs).eval(); + + // Compile-time size info. For fully dynamic vectors (e.g. VectorXd), both are Dynamic and we + // cap internal matrices at dim 3 to avoid heap allocation. + constexpr int Dim = PointType::SizeAtCompileTime; + constexpr int MaxDim = PointType::MaxSizeAtCompileTime; + // Cap at 3 for stack allocation: use known max if available, otherwise 3. + constexpr int EffectiveMaxDim = (MaxDim != Eigen::Dynamic) ? MaxDim : 3; + constexpr int Rows = (Dim != Eigen::Dynamic) ? Dim + 1 : Eigen::Dynamic; + constexpr int MaxRows = EffectiveMaxDim + 1; + + const Eigen::Index dim = p.size(); + la_debug_assert(dim <= EffectiveMaxDim && "Point dimension exceeds maximum supported size."); + la_debug_assert(v0.size() == dim); + la_debug_assert(v1.size() == dim); + la_debug_assert(v2.size() == dim); + + // Fast path: solve 2x2 Gram matrix system via projection. + // e1 = v1 - v0, e2 = v2 - v0, ep = p - v0 + // G = [e1·e1 e1·e2] b = [ep·e1] + // [e1·e2 e2·e2] [ep·e2] + // G * [u; v] = b => p ≈ (1-u-v)*v0 + u*v1 + v*v2 + const auto e1 = (v1 - v0).eval(); + const auto e2 = (v2 - v0).eval(); + const auto ep = (p - v0).eval(); + + Eigen::Matrix G; + G(0, 0) = e1.squaredNorm(); + G(0, 1) = e1.dot(e2); + G(1, 0) = G(0, 1); + G(1, 1) = e2.squaredNorm(); + + const Scalar det = G.determinant(); + const Scalar tol = std::numeric_limits::epsilon() * G(0, 0) * G(1, 1); + + if (std::abs(det) > tol) { + // Non-degenerate triangle: direct 2x2 solve. + const Eigen::Matrix b(ep.dot(e1), ep.dot(e2)); + const Eigen::Matrix uv = G.inverse() * b; + + Eigen::Matrix bary; + bary(0) = Scalar(1) - uv(0) - uv(1); + bary(1) = uv(0); + bary(2) = uv(1); + return bary; + } + + // Slow path for degenerate triangles: constrained least-squares with partition-of-unity. + // [ v0 v1 v2 ] [ p ] + // [ 1 1 1 ] * b = [ 1 ] + Eigen::Matrix M(dim + 1, 3); + M.row(dim).setOnes(); + for (Eigen::Index d = 0; d < dim; ++d) { + M(d, 0) = v0[d]; + M(d, 1) = v1[d]; + M(d, 2) = v2[d]; + } + + Eigen::Matrix rhs(dim + 1); + for (Eigen::Index d = 0; d < dim; ++d) { + rhs(d) = p[d]; + } + rhs(dim) = Scalar(1); + + return M.colPivHouseholderQr().solve(rhs); } diff --git a/modules/core/include/lagrange/compute_uv_charts.h b/modules/core/include/lagrange/compute_uv_charts.h index fe53c0ee..28a43d38 100644 --- a/modules/core/include/lagrange/compute_uv_charts.h +++ b/modules/core/include/lagrange/compute_uv_charts.h @@ -26,7 +26,7 @@ struct UVChartOptions using ConnectivityType = lagrange::ConnectivityType; /// Input UV attribute name. - /// If empty, the first indexed UV attribute will be used. + /// If empty, the first vertex/indexed UV attribute will be used. std::string_view uv_attribute_name = ""; /// Output chart id attribute name. diff --git a/modules/core/include/lagrange/disconnect_uv_charts.h b/modules/core/include/lagrange/disconnect_uv_charts.h new file mode 100644 index 00000000..51d6e51e --- /dev/null +++ b/modules/core/include/lagrange/disconnect_uv_charts.h @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include + +#include + +namespace lagrange { + +/// +/// @addtogroup group-surfacemesh-utils +/// @{ +/// + +struct DisconnectUVChartsOptions +{ + /// Input UV attribute name. + /// If empty, the first indexed UV attribute will be used. The 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 = ""; +}; + +/** + * Disconnect UV charts by duplicating UV vertices shared across different charts. + * + * After this operation, no two facets belonging to different UV charts will share a UV vertex + * index. Without any input chart id attribute, this eliminates non-manifold UV vertices (pinch + * points) where charts touch at a single vertex. + * + * @param mesh Input mesh. The UV attribute must be indexed. + * @param options Options to control chart disconnection. + * + * @tparam Scalar Mesh scalar type. + * @tparam Index Mesh index type. + * + * @return The number of UV vertices that were duplicated. + * + * @see @ref DisconnectUVChartsOptions + */ +template +size_t disconnect_uv_charts( + SurfaceMesh& mesh, + const DisconnectUVChartsOptions& options = {}); + +/// @} + +} // namespace lagrange diff --git a/modules/core/include/lagrange/get_unique_attribute_name.h b/modules/core/include/lagrange/get_unique_attribute_name.h new file mode 100644 index 00000000..63536f5a --- /dev/null +++ b/modules/core/include/lagrange/get_unique_attribute_name.h @@ -0,0 +1,72 @@ +/* + * 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 { + +/// +/// @defgroup group-surfacemesh-utils Mesh utility functions +/// @ingroup group-surfacemesh +/// +/// Various attribute and mesh processing utilities +/// +/// @{ + +/// +/// Options for generating unique attribute names. +/// +struct UniqueAttributeNameOptions +{ + /// Separator between the base name and the counter. Default is ".". + std::string separator = "."; + + /// Postfix to append after the counter. Default is empty. + std::string postfix = ""; + + /// Maximum number of attempts to find a unique name. Default is 1000. + int max_increment = 1000; + + /// Whether to emit a warning when a collision is detected and a suffix is added. + /// Default is true. + bool emit_warning = true; +}; + +/// +/// Returns a unique attribute name by appending a suffix if necessary. +/// +/// If the input name does not exist in the mesh, it is returned as is. If it already exists, +/// a suffix of the form `{separator}{count}{postfix}` is appended until a unique name is found. +/// If no unique name can be found after `options.max_increment` attempts, an error is thrown. +/// +/// @param mesh The mesh to check for existing attribute names. +/// @param name The desired attribute name. +/// @param options Options for generating the unique name. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return A unique attribute name. +/// +template +std::string get_unique_attribute_name( + const SurfaceMesh& mesh, + std::string_view name, + const UniqueAttributeNameOptions& options = {}); + +/// @} + +} // namespace lagrange diff --git a/modules/core/include/lagrange/internal/get_unique_attribute_name.h b/modules/core/include/lagrange/internal/get_unique_attribute_name.h deleted file mode 100644 index 352d8a78..00000000 --- a/modules/core/include/lagrange/internal/get_unique_attribute_name.h +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ -#pragma once - -#include -#include -#include - -#include - -namespace lagrange::internal { - -/// -/// Returns a unique attribute name by appending a suffix if necessary. If the input name does not -/// exist in the mesh, it is returned as is. If it already exists, a suffix of the form ".0", ".1", -/// etc. is appended until a unique name is found. If a unique name cannot be found after 1000 -/// attempts, an error is thrown. -/// -/// @param mesh The mesh to check for existing attribute names. -/// @param name The desired attribute name. -/// -/// @tparam Scalar Mesh scalar type. -/// @tparam Index Mesh index type. -/// -/// @return A unique attribute name. -/// -template -std::string get_unique_attribute_name(const SurfaceMesh& mesh, std::string_view name) -{ - if (!mesh.has_attribute(name)) { - return std::string(name); - } else { - std::string new_name; - for (int cnt = 0; cnt < 1000; ++cnt) { - new_name = fmt::format("{}.{}", name, cnt); - if (!mesh.has_attribute(new_name)) { - logger().warn("Attribute '{}' already exists. Using '{}' instead.", name, new_name); - return new_name; - } - } - throw Error(fmt::format("Could not assign a unique attribute name for: {}", name)); - } -} - -} // namespace lagrange::internal diff --git a/modules/core/include/lagrange/internal/get_uv_attribute.h b/modules/core/include/lagrange/internal/get_uv_attribute.h index 66ed376a..b09fcba1 100644 --- a/modules/core/include/lagrange/internal/get_uv_attribute.h +++ b/modules/core/include/lagrange/internal/get_uv_attribute.h @@ -10,17 +10,6 @@ * governing permissions and limitations under the License. */ #pragma once -/* - * Copyright 2025 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ #include #include @@ -31,6 +20,12 @@ namespace lagrange::internal { +/// Behavior when a named UV attribute has a mismatched scalar type. +enum class TypeMismatchPolicy { + Assert, ///< Assert on type mismatch (default, for strict callers). + Graceful, ///< Return invalid_attribute_id() on type mismatch (for dual-dispatch probing). +}; + /// /// Get the ID of the UV attribute of a mesh. /// @@ -39,6 +34,7 @@ namespace lagrange::internal { /// vertex UV attribute or, if element_types is set to /// UVMeshOptions::ElementTypes::All, the first corner attribute. /// @param element_types Supported element types for the UV attribute lookup. +/// @param type_mismatch Policy for handling scalar type mismatches on named attributes. /// /// @tparam Scalar Mesh scalar type. /// @tparam Index Mesh index type. @@ -50,7 +46,8 @@ template AttributeId get_uv_id( const SurfaceMesh& mesh, std::string_view uv_attribute_name = "", - UVMeshOptions::ElementTypes element_types = UVMeshOptions::ElementTypes::IndexedOrVertex); + UVMeshOptions::ElementTypes element_types = UVMeshOptions::ElementTypes::IndexedOrVertex, + TypeMismatchPolicy type_mismatch = TypeMismatchPolicy::Assert); /// /// Get the constant UV attribute buffers of a mesh. diff --git a/modules/core/include/lagrange/internal/invert_mapping.h b/modules/core/include/lagrange/internal/invert_mapping.h index e128bfe6..ac764a5e 100644 --- a/modules/core/include/lagrange/internal/invert_mapping.h +++ b/modules/core/include/lagrange/internal/invert_mapping.h @@ -11,6 +11,7 @@ */ #include +#include #include #include @@ -96,7 +97,7 @@ InverseMapping invert_mapping( } la_runtime_assert( j < static_cast(mapping.offsets.size()), - fmt::format( + format( "Mapped element index cannot exceeds {} number of elements!", has_target_count ? "target" : "source")); ++mapping.offsets[j + 1]; diff --git a/modules/core/include/lagrange/legacy/attributes/condense_indexed_attribute.h b/modules/core/include/lagrange/legacy/attributes/condense_indexed_attribute.h index 3be39f28..1eecd20a 100644 --- a/modules/core/include/lagrange/legacy/attributes/condense_indexed_attribute.h +++ b/modules/core/include/lagrange/legacy/attributes/condense_indexed_attribute.h @@ -28,6 +28,7 @@ #include #include #include +#include #include namespace lagrange { @@ -47,7 +48,7 @@ void condense_indexed_attribute( static_assert(MeshTrait::is_mesh(), "MeshType is not a mesh"); la_runtime_assert( mesh.has_indexed_attribute(attr_name), - fmt::format("Missing attribute '{}'", attr_name)); + format("Missing attribute '{}'", attr_name)); using Index = typename MeshType::Index; using AttributeArray = typename MeshType::AttributeArray; diff --git a/modules/core/include/lagrange/mesh_cleanup/remove_short_edges.h b/modules/core/include/lagrange/mesh_cleanup/remove_short_edges.h index 8c6f9267..7db99fd1 100644 --- a/modules/core/include/lagrange/mesh_cleanup/remove_short_edges.h +++ b/modules/core/include/lagrange/mesh_cleanup/remove_short_edges.h @@ -16,6 +16,9 @@ #endif #include +#include + +#include namespace lagrange { @@ -24,6 +27,29 @@ namespace lagrange { /// @{ /// +/// +/// Options for remove_short_edges function. +/// +struct RemoveShortEdgesOptions +{ + /// Edge length threshold for removal. Edges with length <= threshold will be removed. + double threshold = 0; + + /// Optional: User-defined per-vertex importance attribute name. + /// If provided (non-empty) and exists, this attribute will be used to determine which vertex + /// to keep during edge collapse. Higher values = more important. + /// If empty or does not exist, importance will be computed from geometry + /// (dihedral angles, boundary status) and stored with a temporary name. + /// Type: Scalar (float or double) + std::string_view vertex_importance_attribute_name = ""; + + /// Maximum normal deviation (in radians) allowed for 1-ring facets of the removed vertex + /// after an edge collapse. A collapse is skipped if any surrounding facet's normal would + /// rotate by more than this angle. The default (pi/2) only rejects actual normal flips; + /// tighten this value (e.g. pi/6) for stricter geometric quality. + double max_normal_deviation_angle = lagrange::internal::pi / 2; +}; + /// /// Collapse all edges shorter than a given tolerance. /// @@ -33,6 +59,15 @@ namespace lagrange { template void remove_short_edges(SurfaceMesh& mesh, Scalar threshold = 0); +/// +/// Collapse all edges shorter than a given tolerance. +/// +/// @param mesh Input mesh to be updated in place. +/// @param options Options for edge removal including threshold and importance attribute. +/// +template +void remove_short_edges(SurfaceMesh& mesh, const RemoveShortEdgesOptions& options); + /// @} } // namespace lagrange diff --git a/modules/core/include/lagrange/mesh_convert.impl.h b/modules/core/include/lagrange/mesh_convert.impl.h index b046c654..a1daf026 100644 --- a/modules/core/include/lagrange/mesh_convert.impl.h +++ b/modules/core/include/lagrange/mesh_convert.impl.h @@ -19,8 +19,8 @@ #include #include #include +#include #include -#include #include #include #include @@ -143,7 +143,7 @@ SurfaceMesh to_surface_mesh_internal(InputMeshType&& mesh) }; auto transfer_attribute = [&](auto&& name, auto&& array, AttributeElement elem) { - std::string new_name = internal::get_unique_attribute_name(new_mesh, name); + std::string new_name = get_unique_attribute_name(new_mesh, name); decltype(auto) attr = array->template get(); if constexpr (policy == Policy::Copy) { new_mesh.template create_attribute( @@ -197,7 +197,7 @@ SurfaceMesh to_surface_mesh_internal(InputMeshType&& mesh) decltype(auto) attr = mesh.get_indexed_attribute_array(name); decltype(auto) values = std::get<0>(attr)->template get(); decltype(auto) indices = std::get<1>(attr)->template get(); - std::string new_name = internal::get_unique_attribute_name(new_mesh, name); + std::string new_name = get_unique_attribute_name(new_mesh, name); if constexpr (policy == Policy::Wrap) { static_assert(std::is_same_v, "Mesh attribute index type mismatch"); diff --git a/modules/core/include/lagrange/triangulate_polygonal_facets.h b/modules/core/include/lagrange/triangulate_polygonal_facets.h index 27f78be4..75c93f41 100644 --- a/modules/core/include/lagrange/triangulate_polygonal_facets.h +++ b/modules/core/include/lagrange/triangulate_polygonal_facets.h @@ -33,6 +33,14 @@ struct TriangulationOptions }; Scheme scheme = Scheme::Earcut; ///< Triangulation scheme to use + + /// If true, facets with exactly 2 vertices (edges) are preserved as-is instead of being removed + /// during triangulation. + bool preserve_edges = false; + + /// If true, facets with exactly 1 vertex (points) are preserved as-is instead of being removed + /// during triangulation. + bool preserve_points = false; }; /// diff --git a/modules/core/include/lagrange/utils/assert.h b/modules/core/include/lagrange/utils/assert.h index f863e135..25cd79b9 100644 --- a/modules/core/include/lagrange/utils/assert.h +++ b/modules/core/include/lagrange/utils/assert.h @@ -12,6 +12,7 @@ #pragma once #include +#include #include @@ -44,15 +45,15 @@ /// /// Finally, our assertion macros can take either 1 or 2 arguments, the second argument being an /// optional error message. To conveniently format assertion messages with a printf-like syntax, use -/// `fmt::format`: +/// `lagrange::format`: /// /// @code /// #include -/// #include +/// #include /// /// la_debug_assert(x == 3); /// la_debug_assert(x == 3, "Error message"); -/// la_debug_assert(x == 3, fmt::format("Incorrect value of x: {}", x)); +/// la_debug_assert(x == 3, lagrange::format("Incorrect value of x: {}", x)); /// @endcode /// /// @{ diff --git a/modules/core/include/lagrange/utils/compute_normal_cotransform.h b/modules/core/include/lagrange/utils/compute_normal_cotransform.h new file mode 100644 index 00000000..afd6f5ad --- /dev/null +++ b/modules/core/include/lagrange/utils/compute_normal_cotransform.h @@ -0,0 +1,84 @@ +/* + * 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-utils +/// @{ + +/// +/// Computes the normal (cotransform) matrix for a 3D affine transform. +/// +/// For transforming normals correctly under an affine transform M, one should use det(M) * M^{-T}, +/// which equals the cofactor matrix of the linear part of M. +/// +/// @param[in] transform Input 3D affine transform. Since the translation part does not affect +/// normal transformation, it is ignored and only the linear part is used. +/// +/// @tparam Scalar Scalar type. +/// +/// @return A 3x3 matrix for transforming normals. +/// +/// @see transform_mesh() for usage with mesh normal attributes. +/// +template +Eigen::Matrix3 compute_normal_cotransform( + const Eigen::Transform& transform) +{ + const auto& matrix = transform.linear(); + auto minor2x2 = [&](int i, int j) -> Scalar { + const int i1 = (i == 0 ? 1 : 0); + const int i2 = (i == 2 ? 1 : 2); + const int j1 = (j == 0 ? 1 : 0); + const int j2 = (j == 2 ? 1 : 2); + return matrix(i1, j1) * matrix(i2, j2) - matrix(i1, j2) * matrix(i2, j1); + }; + + Eigen::Matrix3 result; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + result(i, j) = ((i + j) % 2 == 0 ? Scalar(1) : Scalar(-1)) * minor2x2(i, j); + } + } + return result; +} + +/// +/// Computes the normal (cotransform) matrix for a 2D affine transform. +/// +/// @param[in] transform Input 2D affine transform. Since the translation part does not affect +/// normal transformation, it is ignored and only the linear part is used. +/// +/// @tparam Scalar Scalar type. +/// +/// @return A 2x2 matrix for transforming normals. +/// +template +Eigen::Matrix2 compute_normal_cotransform( + const Eigen::Transform& transform) +{ + const auto& matrix = transform.linear(); + Eigen::Matrix2 result; + result(0, 0) = matrix(1, 1); + result(0, 1) = -matrix(1, 0); + result(1, 0) = -matrix(0, 1); + result(1, 1) = matrix(0, 0); + return result; +} + +/// @} + +} // namespace lagrange diff --git a/modules/core/include/lagrange/utils/fmt/format.h b/modules/core/include/lagrange/utils/fmt/format.h new file mode 100644 index 00000000..e426f52e --- /dev/null +++ b/modules/core/include/lagrange/utils/fmt/format.h @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +/// +/// @file fmt/format.h +/// +/// Provides `lagrange::format`, `lagrange::format_string`, and `lagrange::ptr` — uniform wrappers +/// that dispatch to either `std::format` or `{fmt}` depending on `SPDLOG_USE_STD_FORMAT`. +/// + +#ifdef SPDLOG_USE_STD_FORMAT + + #include + +namespace lagrange { + +template +using format_string = std::format_string; + +using std::format; + +/// Equivalent to `fmt::ptr`. Formats a pointer for output. +template +const void* ptr(const T* p) +{ + return static_cast(p); +} + +} // namespace lagrange + +#else + +// clang-format off +#include +#include +#include +// clang-format on + + +namespace lagrange { + +template +using format_string = fmt::format_string; + +using fmt::format; +using fmt::ptr; + +} // namespace lagrange + +#endif diff --git a/modules/core/include/lagrange/utils/fmt/join.h b/modules/core/include/lagrange/utils/fmt/join.h new file mode 100644 index 00000000..b312bbde --- /dev/null +++ b/modules/core/include/lagrange/utils/fmt/join.h @@ -0,0 +1,175 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +/// +/// @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). +/// + +#ifdef SPDLOG_USE_STD_FORMAT + + #include + #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. +template +using range_value_t = + std::remove_cvref_t&>()))>; + +/// Lazy view returned by `lagrange::join` for ranges. +template +struct range_join_view +{ + Range range; + std::string_view sep; +}; + +/// Lazy view returned by `lagrange::join` for tuples and pairs. +template +struct tuple_join_view +{ + const Tuple& tuple; + std::string_view sep; +}; + +} // 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) +{ + return fmt_detail::range_join_view{std::forward(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}; +} + +/// 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}; +} + +} // 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. +template +struct std::formatter, char> +{ + using value_type = lagrange::fmt_detail::range_value_t; + std::formatter m_elem; + + constexpr auto parse(std::format_parse_context& ctx) { return m_elem.parse(ctx); } + + 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; + } +}; + +/// 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). +template +struct std::formatter, char> +{ + constexpr auto parse(std::format_parse_context& ctx) + { + if (ctx.begin() != ctx.end() && *ctx.begin() != '}') { + throw std::format_error("format spec not supported for tuple/pair join"); + } + return ctx.begin(); + } + + 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; + } +}; + +#else + +// clang-format off +#include +#include +#include +// clang-format on + +namespace lagrange { + +/// 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) +{ + return fmt::join(std::forward(r), sep); +} + +} // namespace lagrange + +#endif diff --git a/modules/core/include/lagrange/utils/fmt/print.h b/modules/core/include/lagrange/utils/fmt/print.h new file mode 100644 index 00000000..ef8a1738 --- /dev/null +++ b/modules/core/include/lagrange/utils/fmt/print.h @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +/// +/// @file fmt/print.h +/// +/// Provides `lagrange::print` — writes a formatted string to an output stream. +/// + +#include + +#if !defined(SPDLOG_USE_STD_FORMAT) && FMT_VERSION >= 90000 +// clang-format off +#include +#include +#include +// clang-format on +#endif + +#include + +namespace lagrange { + +/// Writes a formatted string to an output stream. +template +void print(std::ostream& os, format_string fmt_str, Args&&... args) +{ +#if defined(SPDLOG_USE_STD_FORMAT) + // std::print is only available since C++23 + os << std::format(fmt_str, std::forward(args)...); +#elif FMT_VERSION >= 90000 + // fmt v9+ accepts format_string directly in fmt::print(ostream, ...). + fmt::print(os, fmt_str, std::forward(args)...); +#else + // fmt v8 uses `to_string_view(const S&)` internally, which doesn't recognize + // basic_format_string as a string-like type. Fall back to format + ostream write. + os << fmt::format(fmt_str, std::forward(args)...); +#endif +} + +} // namespace lagrange diff --git a/modules/core/include/lagrange/utils/fmt_eigen.h b/modules/core/include/lagrange/utils/fmt_eigen.h index 74797a07..60a4a869 100644 --- a/modules/core/include/lagrange/utils/fmt_eigen.h +++ b/modules/core/include/lagrange/utils/fmt_eigen.h @@ -41,7 +41,31 @@ #elif defined(SPDLOG_USE_STD_FORMAT) -// It's still a bit early for C++20 format support... + #include + #include + +template +struct std::formatter, T>, char>> +{ + std::formatter m_scalar; + + constexpr auto parse(std::format_parse_context& ctx) { return m_scalar.parse(ctx); } + + auto format(T const& a, std::format_context& ctx) const + { + auto out = ctx.out(); + for (Eigen::Index ir = 0; ir < a.rows(); ir++) { + for (Eigen::Index ic = 0; ic < a.cols(); ic++) { + out = m_scalar.format(a(ir, ic), ctx); + *out++ = ' '; + } + if (ir + 1 < a.rows()) { + *out++ = '\n'; + } + } + return out; + } +}; #else diff --git a/modules/core/include/lagrange/utils/safe_cast.h b/modules/core/include/lagrange/utils/safe_cast.h index e1d0269d..3481188c 100644 --- a/modules/core/include/lagrange/utils/safe_cast.h +++ b/modules/core/include/lagrange/utils/safe_cast.h @@ -15,6 +15,7 @@ #include #include +#include #include namespace lagrange { diff --git a/modules/core/include/lagrange/utils/strings.h b/modules/core/include/lagrange/utils/strings.h index 4f1cccdb..410d74d8 100644 --- a/modules/core/include/lagrange/utils/strings.h +++ b/modules/core/include/lagrange/utils/strings.h @@ -12,12 +12,8 @@ #pragma once #include +#include -// clang-format off -#include -#include -#include -// clang-format on #include #include @@ -100,10 +96,12 @@ LA_CORE_API std::string to_upper(std::string str); /// @return A string object holding the formatted result. /// template -std::string string_format(fmt::format_string format, Args&&... args) +std::string string_format(lagrange::format_string format, Args&&... args) { // TODO: Remove this string_format in our next major release... -#if FMT_VERSION >= 90100 +#ifdef SPDLOG_USE_STD_FORMAT + return std::format(format, std::forward(args)...); +#elif FMT_VERSION >= 90100 return fmt::format(fmt::runtime(format), std::forward(args)...); #else return fmt::format(format, std::forward(args)...); diff --git a/modules/core/include/lagrange/utils/triangle_triangle_intersection.h b/modules/core/include/lagrange/utils/triangle_triangle_intersection.h new file mode 100644 index 00000000..fa055ff1 --- /dev/null +++ b/modules/core/include/lagrange/utils/triangle_triangle_intersection.h @@ -0,0 +1,72 @@ +/* + * 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-utils-geom +/// @{ +/// + +/// +/// Controls whether boundary contact counts as intersection in triangle_triangle_intersection(). +/// +enum class IncludeBoundaryIntersection { + /// Only interior intersections count. Triangles touching at vertices or edges are not + /// considered intersecting. + No, + + /// Any contact counts as intersection, including touching at vertices or edges. + Yes +}; + +/// +/// Check if two 3D triangles intersect using exact predicates for robustness. +/// +/// This function uses Shewchuk's exact orient3D and orient2D predicates to robustly determine +/// if two triangles intersect in 3D space. The algorithm tests each edge of one triangle +/// against the other triangle using tetrahedra orientation tests, with special handling for +/// coplanar cases using 2D projections. +/// +/// @param[in] t1_v0 First vertex of triangle 1. +/// @param[in] t1_v1 Second vertex of triangle 1. +/// @param[in] t1_v2 Third vertex of triangle 1. +/// @param[in] t2_v0 First vertex of triangle 2. +/// @param[in] t2_v1 Second vertex of triangle 2. +/// @param[in] t2_v2 Third vertex of triangle 2. +/// @param[in] boundary Whether touching at boundaries counts as intersection. +/// +/// @tparam Scalar The scalar type (e.g., float, double). +/// +/// @return True if the triangles intersect, false otherwise. +/// +/// @note Orientation and coplanarity decisions rely on Shewchuk's exact predicates for +/// robustness. Some auxiliary computations (e.g., projection axis selection and +/// collinear interval parameterization) use standard floating-point arithmetic. +/// +template +bool triangle_triangle_intersection( + span t1_v0, + span t1_v1, + span t1_v2, + span t2_v0, + span t2_v1, + span t2_v2, + IncludeBoundaryIntersection boundary = IncludeBoundaryIntersection::No); + +/// @} + +} // namespace lagrange diff --git a/modules/core/include/lagrange/utils/warning.h b/modules/core/include/lagrange/utils/warning.h index 54b97b17..21173b93 100644 --- a/modules/core/include/lagrange/utils/warning.h +++ b/modules/core/include/lagrange/utils/warning.h @@ -151,8 +151,12 @@ #define LA_IGNORE_RANGE_LOOP_ANALYSIS_END LA_DISABLE_WARNING_END /// Ignore warning "out of bounds subscripts or offsets into arrays" -/// This is used to bypass the following GCC bug: -/// https://gcc.gnu.org/bugzilla/show_bug.cgi?id=106247 +/// This is used to bypass the following GCC bugs: +/// - https://gcc.gnu.org/bugzilla/show_bug.cgi?id=106247 +/// - https://gcc.gnu.org/bugzilla/show_bug.cgi?id=109727 +/// False positive -Warray-bounds when using -fsanitize=address/undefined +/// and single-element initializer lists in optimized builds (-O2/-O3). +/// Affects GCC 13.1-13.3, fixed in GCC 13.4.0, 14.3.0, and 15.1.0. /// @hideinitializer #define LA_IGNORE_ARRAY_BOUNDS_BEGIN LA_DISABLE_WARNING_BEGIN \ LA_DISABLE_WARNING_GCC(-Warray-bounds) diff --git a/modules/core/include/lagrange/uv_mesh.h b/modules/core/include/lagrange/uv_mesh.h index 748f83b2..bb6f3b3d 100644 --- a/modules/core/include/lagrange/uv_mesh.h +++ b/modules/core/include/lagrange/uv_mesh.h @@ -13,6 +13,7 @@ #include +#include #include namespace lagrange { @@ -89,6 +90,25 @@ SurfaceMesh uv_mesh_view( const SurfaceMesh& mesh, const UVMeshOptions& options = {}); +/** + * Check whether a UV attribute of a given scalar type exists on a mesh. + * + * @param mesh Input mesh. + * @param options Options to control UV attribute lookup. + * + * @tparam Scalar Mesh scalar type. + * @tparam Index Mesh index type. + * @tparam UVScalar Target UV attribute value type. + * + * @return The attribute ID if a matching UV attribute is found, or @c std::nullopt otherwise. + * + * @see @ref UVMeshOptions + */ +template +std::optional uv_attribute_id( + const SurfaceMesh& mesh, + const UVMeshOptions& options = {}); + /// @} } // namespace lagrange diff --git a/modules/core/js/CMakeLists.txt b/modules/core/js/CMakeLists.txt new file mode 100644 index 00000000..726df794 --- /dev/null +++ b/modules/core/js/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_js_binding(core CoreModule) diff --git a/modules/core/js/src/core.cpp b/modules/core/js/src/core.cpp new file mode 100644 index 00000000..50d85765 --- /dev/null +++ b/modules/core/js/src/core.cpp @@ -0,0 +1,206 @@ +/* + * 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 "bind_types.h" + +#include +#include +#include + +#include +#include + +#include +#include + +namespace lagrange::js::bind { +namespace { + +// Splits vertices at attribute discontinuities so all indexed attributes become per-vertex. +// The returned mesh has a unified index buffer suitable for WebGL / zero-copy views. +MeshType unify_index_buffer_js(const MeshType& mesh) +{ + return lagrange::unify_index_buffer(mesh); +} + +void mesh_add_vertex(MeshType& mesh, const emscripten::val& vertex_coords) +{ + const Index dim = mesh.get_dimension(); + const unsigned len = vertex_coords["length"].as(); + if (len != static_cast(dim)) { + throw std::runtime_error("addVertex: array length must equal mesh dimension"); + } + std::vector coords(static_cast(dim)); + for (Index i = 0; i < dim; ++i) { + coords[static_cast(i)] = vertex_coords[i].as(); + } + mesh.add_vertex(span(coords.data(), coords.size())); +} + +void mesh_add_triangles(MeshType& mesh, const emscripten::val& triangle_indices) +{ + const unsigned len = triangle_indices["length"].as(); + if (len % 3 != 0) { + throw std::runtime_error("addTriangles: array length must be divisible by 3"); + } + const Index num_facets = static_cast(len / 3); + std::vector buf(static_cast(len)); + for (unsigned i = 0; i < len; ++i) { + buf[i] = triangle_indices[i].as(); + } + mesh.add_triangles(num_facets, span(buf.data(), buf.size())); +} + +emscripten::val copy_span_f32(span s) +{ + if (s.empty()) { + return emscripten::val::global("Float32Array").new_(0); + } + std::vector buf(s.begin(), s.end()); + return emscripten::val::global("Float32Array") + .new_(emscripten::typed_memory_view(buf.size(), buf.data())); +} + +emscripten::val copy_span_u32(span s) +{ + if (s.empty()) { + return emscripten::val::global("Uint32Array").new_(0); + } + std::vector buf(s.begin(), s.end()); + return emscripten::val::global("Uint32Array") + .new_(emscripten::typed_memory_view(buf.size(), buf.data())); +} + +emscripten::val mesh_get_position(const MeshType& mesh, Index vertex_id) +{ + return copy_span_f32(mesh.get_position(vertex_id)); +} + +emscripten::val mesh_get_facet_vertices(const MeshType& mesh, Index facet_id) +{ + return copy_span_u32(mesh.get_facet_vertices(facet_id)); +} + +bool mesh_has_attribute(const MeshType& mesh, const std::string& name) +{ + return mesh.has_attribute(name); +} + +} // namespace +} // namespace lagrange::js::bind + +EMSCRIPTEN_BINDINGS(lagrange_core) +{ + using namespace emscripten; + using namespace lagrange::js::bind; + + class_("SurfaceMesh") + .constructor() + .function("getNumVertices", &MeshType::get_num_vertices) + .function("getNumFacets", &MeshType::get_num_facets) + .function("getNumCorners", &MeshType::get_num_corners) + .function("getDimension", &MeshType::get_dimension) + .function("isTriangleMesh", &MeshType::is_triangle_mesh) + .function( + "addVertex", + +[](MeshType& mesh, const emscripten::val& vertex_coords) { + mesh_add_vertex(mesh, vertex_coords); + }) + .function("addTriangle", &MeshType::add_triangle) + .function( + "addTriangles", + +[](MeshType& mesh, const emscripten::val& triangle_indices) { + mesh_add_triangles(mesh, triangle_indices); + }) + .function("getNumEdges", &MeshType::get_num_edges) + .function("isQuadMesh", &MeshType::is_quad_mesh) + .function("isRegular", &MeshType::is_regular) + .function("isHybrid", &MeshType::is_hybrid) + .function("getVertexPerFacet", &MeshType::get_vertex_per_facet) + .function("getFacetSize", &MeshType::get_facet_size) + .function("getFacetVertex", &MeshType::get_facet_vertex) + .function("getFacetCornerBegin", &MeshType::get_facet_corner_begin) + .function("getFacetCornerEnd", &MeshType::get_facet_corner_end) + .function("getCornerVertex", &MeshType::get_corner_vertex) + .function("getCornerFacet", &MeshType::get_corner_facet) + .function("getPosition", &mesh_get_position) + .function("getFacetVertices", &mesh_get_facet_vertices) + .function("shrinkToFit", &MeshType::shrink_to_fit) + .function("clearFacets", &MeshType::clear_facets) + .function("clearVertices", &MeshType::clear_vertices) + .function("hasAttribute", &mesh_has_attribute) + .function( + "addVertices", + +[](MeshType& mesh, const emscripten::val& coords) { + const unsigned len = coords["length"].as(); + const Index dim = mesh.get_dimension(); + if (len % dim != 0) { + throw std::runtime_error( + "addVertices: array length must be divisible by dimension"); + } + const Index num_verts = static_cast(len / dim); + std::vector buf(len); + for (unsigned i = 0; i < len; ++i) { + buf[i] = coords[i].as(); + } + mesh.add_vertices(num_verts, buf); + }) + .function( + "removeVertices", + +[](MeshType& mesh, const emscripten::val& indices) { + const unsigned len = indices["length"].as(); + std::vector buf(len); + for (unsigned i = 0; i < len; ++i) { + buf[i] = indices[i].as(); + } + mesh.remove_vertices(buf); + }) + .function( + "removeFacets", + +[](MeshType& mesh, const emscripten::val& indices) { + const unsigned len = indices["length"].as(); + std::vector buf(len); + for (unsigned i = 0; i < len; ++i) { + buf[i] = indices[i].as(); + } + mesh.remove_facets(buf); + }) + .function( + "clone", + +[](const MeshType& self) -> MeshType { return MeshType(self); }) + .function( + "clone", + +[](const MeshType& self, emscripten::val opts) -> MeshType { + bool strip = false; + apply_opt(opts, "strip", strip); + return strip ? MeshType::stripped_copy(self) : MeshType(self); + }) + .function( + "flipFacets", + +[](MeshType& mesh) { mesh.flip_facets([](Index) { return true; }); }) + .function( + "flipFacets", + +[](MeshType& mesh, const emscripten::val& indices) { + if (indices.isUndefined() || indices.isNull()) { + mesh.flip_facets([](Index) { return true; }); + } else { + const unsigned len = indices["length"].as(); + std::vector buf(len); + for (unsigned i = 0; i < len; ++i) { + buf[i] = indices[i].as(); + } + mesh.flip_facets(lagrange::span(buf.data(), buf.size())); + } + }); + + function("unifyIndexBuffer", &unify_index_buffer_js); +} diff --git a/modules/core/js/src/core_utilities.cpp b/modules/core/js/src/core_utilities.cpp new file mode 100644 index 00000000..27837d67 --- /dev/null +++ b/modules/core/js/src/core_utilities.cpp @@ -0,0 +1,250 @@ +/* + * 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 "bind_types.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace { + +using namespace lagrange; +using namespace lagrange::js::bind; +using val = emscripten::val; + +NormalWeightingType parse_weight_type(const std::string& s) +{ + if (s == "uniform") return NormalWeightingType::Uniform; + if (s == "cornerTriangleArea") return NormalWeightingType::CornerTriangleArea; + return NormalWeightingType::Angle; +} + +} // namespace + +EMSCRIPTEN_BINDINGS(lagrange_core_utilities) +{ + using namespace emscripten; + + // --- Normals --- + + function( + "computeVertexNormal", + +[](MeshType& mesh, val opts) { + VertexNormalOptions o; + if (!opts.isUndefined()) { + auto wt = opts["weightType"]; + if (!wt.isUndefined()) o.weight_type = parse_weight_type(wt.as()); + apply_opt( + opts, + "recomputeWeightedCornerNormals", + o.recompute_weighted_corner_normals); + apply_opt(opts, "keepWeightedCornerNormals", o.keep_weighted_corner_normals); + apply_opt(opts, "distanceTolerance", o.distance_tolerance); + } + compute_vertex_normal(mesh, std::move(o)); + }); + + function("computeFacetNormal", +[](MeshType& m) { compute_facet_normal(m); }); + + function( + "computeNormal", + +[](MeshType& mesh, Scalar feature_angle_threshold, val opts) { + NormalOptions o; + std::vector cone_verts; + if (!opts.isUndefined()) { + auto wt = opts["weightType"]; + if (!wt.isUndefined()) o.weight_type = parse_weight_type(wt.as()); + apply_opt(opts, "recomputeFacetNormals", o.recompute_facet_normals); + apply_opt(opts, "keepFacetNormals", o.keep_facet_normals); + apply_opt(opts, "distanceTolerance", o.distance_tolerance); + auto cv = opts["coneVertices"]; + if (!cv.isUndefined()) { + unsigned len = cv["length"].as(); + cone_verts.reserve(len); + for (unsigned i = 0; i < len; ++i) { + cone_verts.push_back(cv[i].as()); + } + } + } + compute_normal( + mesh, + feature_angle_threshold, + span(cone_verts.data(), cone_verts.size()), + std::move(o)); + }); + + function( + "computeTangentBitangent", + +[](MeshType& mesh, val opts) { + TangentBitangentOptions o; + if (!opts.isUndefined()) { + apply_opt(opts, "padWithSign", o.pad_with_sign); + apply_opt(opts, "orthogonalizeBitangent", o.orthogonalize_bitangent); + apply_opt(opts, "keepExistingTangent", o.keep_existing_tangent); + } + compute_tangent_bitangent(mesh, std::move(o)); + }); + + // --- Mesh operations --- + + function("triangulatePolygonalFacets", +[](MeshType& m) { triangulate_polygonal_facets(m); }); + + function( + "combineMeshes", + +[](val js_array, val opts) -> MeshType { + unsigned len = js_array["length"].as(); + std::vector meshes; + meshes.reserve(len); + for (unsigned i = 0; i < len; ++i) + meshes.push_back(js_array[i].as(emscripten::allow_raw_pointers())); + bool preserve_attributes = true; + apply_opt(opts, "preserveAttributes", preserve_attributes); + return combine_meshes( + span(meshes.data(), meshes.size()), + preserve_attributes); + }); + + function( + "computeComponents", + +[](MeshType& mesh, val opts) -> size_t { + ComponentOptions o; + std::vector blockers; + if (!opts.isUndefined()) { + auto ct = opts["connectivityType"]; + if (!ct.isUndefined()) { + o.connectivity_type = ct.as() == "vertex" + ? ConnectivityType::Vertex + : ConnectivityType::Edge; + } + auto bl = opts["blockerElements"]; + if (!bl.isUndefined()) { + unsigned len = bl["length"].as(); + blockers.reserve(len); + for (unsigned i = 0; i < len; ++i) { + blockers.push_back(bl[i].as()); + } + } + } + if (blockers.empty()) { + return compute_components(mesh, std::move(o)); + } else { + return compute_components( + mesh, + span(blockers.data(), blockers.size()), + std::move(o)); + } + }); + + function( + "orientOutward", + +[](MeshType& mesh, val opts) { + OrientOptions o; + apply_opt(opts, "positive", o.positive); + orient_outward(mesh, o); + }); + + function("normalizeMesh", +[](MeshType& m) { normalize_mesh(m); }); + function("computeFacetArea", +[](MeshType& m) { compute_facet_area(m); }); + + // --- Topology queries --- + + function("isManifold", +[](const MeshType& m) -> bool { return is_manifold(m); }); + function("isVertexManifold", +[](const MeshType& m) -> bool { return is_vertex_manifold(m); }); + function("isEdgeManifold", +[](const MeshType& m) -> bool { return is_edge_manifold(m); }); + function("isClosed", +[](const MeshType& m) -> bool { return is_closed(m); }); + function("computeEuler", +[](const MeshType& m) -> int { return compute_euler(m); }); + + // --- Seam edges, valence, coloring --- + + function( + "computeSeamEdges", + +[](MeshType& mesh, unsigned indexed_attribute_id) { + compute_seam_edges(mesh, static_cast(indexed_attribute_id)); + }); + + function("computeVertexValence", +[](MeshType& mesh) { compute_vertex_valence(mesh); }); + + function( + "computeGreedyColoring", + +[](MeshType& mesh, val opts) { + GreedyColoringOptions o; + if (!opts.isUndefined()) { + auto et = opts["elementType"]; + if (!et.isUndefined()) { + o.element_type = et.as() == "vertex" ? AttributeElement::Vertex + : AttributeElement::Facet; + } + apply_opt(opts, "numColorUsed", o.num_color_used); + } + compute_greedy_coloring(mesh, o); + }); + + // --- Additional mesh cleanup --- + + function( + "removeDuplicateFacets", + +[](MeshType& mesh, val opts) { + RemoveDuplicateFacetOptions o; + apply_opt(opts, "considerOrientation", o.consider_orientation); + remove_duplicate_facets(mesh, o); + }); + + function("removeIsolatedVertices", +[](MeshType& m) { remove_isolated_vertices(m); }); + + function( + "removeNullAreaFacets", + +[](MeshType& mesh, val opts) { + RemoveNullAreaFacetsOptions o; + apply_opt(opts, "nullAreaThreshold", o.null_area_threshold); + apply_opt(opts, "removeIsolatedVertices", o.remove_isolated_vertices); + remove_null_area_facets(mesh, o); + }); + + function( + "removeShortEdges", + +[](MeshType& mesh, Scalar threshold) { remove_short_edges(mesh, threshold); }); + + function( + "removeTopologicallyDegenerateFacets", + +[](MeshType& m) { remove_topologically_degenerate_facets(m); }); + + function("resolveNonmanifoldness", +[](MeshType& m) { resolve_nonmanifoldness(m); }); + + function( + "resolveVertexNonmanifoldness", + +[](MeshType& m) { resolve_vertex_nonmanifoldness(m); }); +} diff --git a/modules/core/js/src/mesh_cleanup.cpp b/modules/core/js/src/mesh_cleanup.cpp new file mode 100644 index 00000000..6ce581c0 --- /dev/null +++ b/modules/core/js/src/mesh_cleanup.cpp @@ -0,0 +1,63 @@ +/* + * 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 "bind_types.h" + +#include +#include +#include +#include + +#include + +namespace lagrange::js::bind { +namespace { + +void close_small_holes_js(MeshType& mesh, size_t max_hole_size, emscripten::val triangulate_holes) +{ + CloseSmallHolesOptions options; + options.max_hole_size = max_hole_size; + if (!triangulate_holes.isUndefined()) options.triangulate_holes = triangulate_holes.as(); + close_small_holes(mesh, std::move(options)); +} + +void remove_degenerate_facets_js(MeshType& mesh) +{ + remove_degenerate_facets(mesh); +} + +void remove_duplicate_vertices_js(MeshType& mesh) +{ + remove_duplicate_vertices(mesh, {}); +} + +void split_long_edges_js(MeshType& mesh, float max_edge_length, bool recursive) +{ + SplitLongEdgesOptions options; + options.max_edge_length = max_edge_length; + options.recursive = recursive; + split_long_edges(mesh, std::move(options)); +} + +} // namespace +} // namespace lagrange::js::bind + +EMSCRIPTEN_BINDINGS(lagrange_mesh_cleanup) +{ + using namespace emscripten; + using namespace lagrange::js::bind; + + function("closeSmallHoles", &close_small_holes_js); + function("removeDegenerateFacets", &remove_degenerate_facets_js); + function("removeDuplicateVertices", &remove_duplicate_vertices_js); + function("splitLongEdges", &split_long_edges_js); +} diff --git a/modules/core/js/test/SurfaceMesh.test.ts b/modules/core/js/test/SurfaceMesh.test.ts new file mode 100644 index 00000000..8f8881a1 --- /dev/null +++ b/modules/core/js/test/SurfaceMesh.test.ts @@ -0,0 +1,156 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +describe("SurfaceMesh", () => { + test("create empty mesh", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + expect(mesh.getNumVertices()).toBe(0); + expect(mesh.getNumFacets()).toBe(0); + expect(mesh.getDimension()).toBe(3); + mesh.delete(); + }); + + test("add vertices and triangles", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addTriangle(0, 1, 2); + expect(mesh.getNumVertices()).toBe(3); + expect(mesh.getNumFacets()).toBe(1); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("getPosition returns vertex coordinates", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([1, 2, 3]); + const pos = mesh.getPosition(0); + expect(pos[0]).toBeCloseTo(1); + expect(pos[1]).toBeCloseTo(2); + expect(pos[2]).toBeCloseTo(3); + mesh.delete(); + }); + + test("clone produces independent mesh", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addTriangle(0, 1, 2); + + const copy = mesh.clone(); + expect(copy.getNumVertices()).toBe(3); + expect(copy.getNumFacets()).toBe(1); + + // Mutate the clone; original must remain unchanged. + copy.addVertex([1, 1, 0]); + copy.addTriangle(1, 3, 2); + expect(copy.getNumVertices()).toBe(4); + expect(copy.getNumFacets()).toBe(2); + expect(mesh.getNumVertices()).toBe(3); + expect(mesh.getNumFacets()).toBe(1); + + mesh.delete(); + copy.delete(); + }); + + test("clone survives deletion of original", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addTriangle(0, 1, 2); + + const copy = mesh.clone(); + mesh.delete(); + + expect(copy.getNumVertices()).toBe(3); + expect(copy.getNumFacets()).toBe(1); + const pos = copy.getPosition(1); + expect(pos[0]).toBeCloseTo(1); + copy.addVertex([1, 1, 0]); + copy.addTriangle(1, 3, 2); + expect(copy.getNumVertices()).toBe(4); + expect(copy.getNumFacets()).toBe(2); + + copy.delete(); + }); + + test("clone with strip drops user attributes", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addTriangle(0, 1, 2); + lagrange.core.computeFacetNormal(mesh); + expect(mesh.hasAttribute("@facet_normal")).toBe(true); + + const stripped = mesh.clone({ strip: true }); + expect(stripped.getNumVertices()).toBe(3); + expect(stripped.getNumFacets()).toBe(1); + expect(stripped.hasAttribute("@facet_normal")).toBe(false); + + const kept = mesh.clone(); + expect(kept.hasAttribute("@facet_normal")).toBe(true); + + mesh.delete(); + stripped.delete(); + kept.delete(); + }); + + test("flipFacets with no argument flips all facets", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addVertex([1, 1, 0]); + mesh.addTriangle(0, 1, 2); + mesh.addTriangle(1, 3, 2); + + const before0 = Array.from(mesh.getFacetVertices(0)); + const before1 = Array.from(mesh.getFacetVertices(1)); + mesh.flipFacets(); + const after0 = Array.from(mesh.getFacetVertices(0)); + const after1 = Array.from(mesh.getFacetVertices(1)); + + expect(after0).toEqual([...before0].reverse()); + expect(after1).toEqual([...before1].reverse()); + mesh.delete(); + }); + + test("flipFacets with index list only flips listed facets", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addVertex([1, 1, 0]); + mesh.addTriangle(0, 1, 2); + mesh.addTriangle(1, 3, 2); + + const before0 = Array.from(mesh.getFacetVertices(0)); + const before1 = Array.from(mesh.getFacetVertices(1)); + mesh.flipFacets([0]); + expect(Array.from(mesh.getFacetVertices(0))).toEqual([...before0].reverse()); + expect(Array.from(mesh.getFacetVertices(1))).toEqual(before1); + mesh.delete(); + }); + + test("getFacetVertices returns correct indices", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addTriangle(0, 1, 2); + const verts = mesh.getFacetVertices(0); + expect(Array.from(verts)).toEqual([0, 1, 2]); + mesh.delete(); + }); +}); diff --git a/modules/core/js/test/bindings.test.ts b/modules/core/js/test/bindings.test.ts new file mode 100644 index 00000000..4c8e5bb7 --- /dev/null +++ b/modules/core/js/test/bindings.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +describe("bindings", () => { + test("meshToBabylonMeshData32 returns positions, normals, uvs and indices", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const data = lagrange.bindings.meshToBabylonMeshData32(mesh); + mesh.delete(); + expect(data.numVertices).toBeGreaterThan(0); + expect(data.numTriangles).toBeGreaterThan(0); + expect(data.positions.length).toBe(data.numVertices * 3); + expect(data.indices.length).toBe(data.numTriangles * 3); + expect(data.normals).toBeDefined(); + expect(data.normals!.length).toBe(data.numVertices * 3); + expect(data.uvs).toBeDefined(); + expect(data.uvs!.length).toBe(data.numVertices * 2); + }); + + test("meshToBabylonMeshDataView64 returns views with normals and uvs", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const unified = lagrange.core.unifyIndexBuffer(mesh); + const data = lagrange.bindings.meshToBabylonMeshDataView64(unified); + expect(data).toBeDefined(); + expect(data!.numVertices).toBeGreaterThan(0); + expect(data!.numTriangles).toBeGreaterThan(0); + expect(data!.positions.length).toBe(data!.numVertices * 3); + expect(data!.indices.length).toBe(data!.numTriangles * 3); + expect(data!.normals).toBeDefined(); + expect(data!.normals!.length).toBe(data!.numVertices * 3); + expect(data!.uvs).toBeDefined(); + expect(data!.uvs!.length).toBe(data!.numVertices * 2); + unified.delete(); + mesh.delete(); + }); +}); diff --git a/modules/core/js/test/core.test.ts b/modules/core/js/test/core.test.ts new file mode 100644 index 00000000..45288b02 --- /dev/null +++ b/modules/core/js/test/core.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; +import { invalidScalar, invalidIndex } from "../src/modules/core.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +describe("core", () => { + test("invalidScalar is Infinity, invalidIndex is 0xFFFFFFFF", () => { + expect(invalidScalar).toBe(Infinity); + expect(invalidIndex).toBe(0xFFFFFFFF); + }); + + test("removeDuplicateVertices merges coincident vertices", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + // Two triangles sharing an edge but with duplicate vertices + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addVertex([0, 0, 0]); // duplicate of v0 + mesh.addVertex([1, 0, 0]); // duplicate of v1 + mesh.addVertex([0, -1, 0]); + mesh.addTriangle(0, 1, 2); + mesh.addTriangle(3, 5, 4); + expect(mesh.getNumVertices()).toBe(6); + lagrange.core.removeDuplicateVertices(mesh); + expect(mesh.getNumVertices()).toBe(4); + mesh.delete(); + }); + + test("unifyIndexBuffer returns a mesh", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addTriangle(0, 1, 2); + const unified = lagrange.core.unifyIndexBuffer(mesh); + expect(unified.getNumFacets()).toBe(1); + unified.delete(); + mesh.delete(); + }); +}); diff --git a/modules/core/js/test/core_utilities.test.ts b/modules/core/js/test/core_utilities.test.ts new file mode 100644 index 00000000..70efba86 --- /dev/null +++ b/modules/core/js/test/core_utilities.test.ts @@ -0,0 +1,261 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +/** Helper: create a simple triangle mesh. */ +function makeTriangleMesh() { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addTriangle(0, 1, 2); + return mesh; +} + +/** Helper: generate a sphere for tests needing a real mesh. */ +function makeSphere() { + return lagrange.primitive.generateSphere({ radius: 1 }); +} + +describe("core utilities", () => { + test("computeVertexNormal adds normals", () => { + const mesh = makeSphere(); + lagrange.core.computeVertexNormal(mesh); + expect(mesh.hasAttribute("@vertex_normal")).toBe(true); + mesh.delete(); + }); + + test("computeVertexNormal with weight type", () => { + const mesh = makeSphere(); + lagrange.core.computeVertexNormal(mesh, { weightType: "uniform" }); + expect(mesh.hasAttribute("@vertex_normal")).toBe(true); + mesh.delete(); + }); + + test("computeFacetNormal adds facet normals", () => { + const mesh = makeSphere(); + lagrange.core.computeFacetNormal(mesh); + expect(mesh.hasAttribute("@facet_normal")).toBe(true); + mesh.delete(); + }); + + test("computeNormal adds indexed normals with feature angle", () => { + const mesh = makeSphere(); + lagrange.core.computeNormal(mesh, Math.PI / 6); + expect(mesh.hasAttribute("@normal")).toBe(true); + mesh.delete(); + }); + + test("computeTangentBitangent adds tangent attributes", () => { + const mesh = makeSphere(); + // Sphere already has UVs and normals from generation + lagrange.core.computeTangentBitangent(mesh); + expect(mesh.hasAttribute("@tangent")).toBe(true); + expect(mesh.hasAttribute("@bitangent")).toBe(true); + mesh.delete(); + }); + + test("triangulatePolygonalFacets is a no-op on triangle mesh", () => { + const mesh = makeTriangleMesh(); + const numFacets = mesh.getNumFacets(); + lagrange.core.triangulatePolygonalFacets(mesh); + expect(mesh.getNumFacets()).toBe(numFacets); + mesh.delete(); + }); + + test("combineMeshes merges two meshes", () => { + const a = makeTriangleMesh(); + const b = makeTriangleMesh(); + const combined = lagrange.core.combineMeshes([a, b]); + expect(combined.getNumVertices()).toBe(6); + expect(combined.getNumFacets()).toBe(2); + combined.delete(); + a.delete(); + b.delete(); + }); + + test("computeComponents returns component count", () => { + const a = makeTriangleMesh(); + const b = makeTriangleMesh(); + const mesh = lagrange.core.combineMeshes([a, b]); + const count = lagrange.core.computeComponents(mesh); + expect(count).toBe(2); + mesh.delete(); + a.delete(); + b.delete(); + }); + + test("orientOutward runs without error", () => { + const mesh = makeSphere(); + lagrange.core.orientOutward(mesh); + expect(mesh.getNumFacets()).toBeGreaterThan(0); + mesh.delete(); + }); + + test("normalizeMesh scales mesh to unit box", () => { + const mesh = makeSphere(); + lagrange.core.normalizeMesh(mesh); + // After normalization, positions should be within [-0.5, 0.5] + const pos = mesh.getPosition(0); + expect(Math.abs(pos[0])).toBeLessThanOrEqual(0.6); + expect(Math.abs(pos[1])).toBeLessThanOrEqual(0.6); + expect(Math.abs(pos[2])).toBeLessThanOrEqual(0.6); + mesh.delete(); + }); + + test("computeFacetArea adds area attribute", () => { + const mesh = makeSphere(); + lagrange.core.computeFacetArea(mesh); + expect(mesh.hasAttribute("@facet_area")).toBe(true); + mesh.delete(); + }); + + test("isManifold returns true for sphere", () => { + const mesh = makeSphere(); + expect(lagrange.core.isManifold(mesh)).toBe(true); + mesh.delete(); + }); + + test("isVertexManifold returns true for sphere", () => { + const mesh = makeSphere(); + expect(lagrange.core.isVertexManifold(mesh)).toBe(true); + mesh.delete(); + }); + + test("isEdgeManifold returns true for sphere", () => { + const mesh = makeSphere(); + expect(lagrange.core.isEdgeManifold(mesh)).toBe(true); + mesh.delete(); + }); + + test("isClosed returns true for sphere", () => { + const mesh = makeSphere(); + expect(lagrange.core.isClosed(mesh)).toBe(true); + mesh.delete(); + }); + + test("isClosed returns false for single triangle", () => { + const mesh = makeTriangleMesh(); + expect(lagrange.core.isClosed(mesh)).toBe(false); + mesh.delete(); + }); + + test("computeEuler returns 2 for sphere (V-E+F=2)", () => { + const mesh = makeSphere(); + expect(lagrange.core.computeEuler(mesh)).toBe(2); + mesh.delete(); + }); + + test("computeVertexValence adds valence attribute", () => { + const mesh = makeSphere(); + lagrange.core.computeVertexValence(mesh); + expect(mesh.hasAttribute("@vertex_valence")).toBe(true); + mesh.delete(); + }); + + test("computeGreedyColoring adds color attribute", () => { + const mesh = makeSphere(); + lagrange.core.computeGreedyColoring(mesh); + expect(mesh.hasAttribute("@color_id")).toBe(true); + mesh.delete(); + }); + + test("removeDuplicateFacets removes exact duplicates", () => { + const mesh = makeTriangleMesh(); + // Add same triangle again + mesh.addTriangle(0, 1, 2); + expect(mesh.getNumFacets()).toBe(2); + lagrange.core.removeDuplicateFacets(mesh); + expect(mesh.getNumFacets()).toBe(1); + mesh.delete(); + }); + + test("removeIsolatedVertices cleans up", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addVertex([99, 99, 99]); // isolated + mesh.addTriangle(0, 1, 2); + expect(mesh.getNumVertices()).toBe(4); + lagrange.core.removeIsolatedVertices(mesh); + expect(mesh.getNumVertices()).toBe(3); + mesh.delete(); + }); + + test("removeNullAreaFacets removes degenerate triangles", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertex([0, 0, 0]); + mesh.addVertex([1, 0, 0]); + mesh.addVertex([0, 1, 0]); + mesh.addVertex([0, 0, 0]); // collinear with v0 + mesh.addTriangle(0, 1, 2); // valid + mesh.addTriangle(0, 3, 0); // degenerate (zero area) + lagrange.core.removeNullAreaFacets(mesh); + expect(mesh.getNumFacets()).toBe(1); + mesh.delete(); + }); + + test("removeTopologicallyDegenerateFacets runs without error", () => { + const mesh = makeSphere(); + const facets = mesh.getNumFacets(); + lagrange.core.removeTopologicallyDegenerateFacets(mesh); + expect(mesh.getNumFacets()).toBe(facets); // sphere has no degenerate facets + mesh.delete(); + }); + + test("resolveNonmanifoldness runs without error", () => { + const mesh = makeSphere(); + lagrange.core.resolveNonmanifoldness(mesh); + expect(lagrange.core.isManifold(mesh)).toBe(true); + mesh.delete(); + }); + + test("flipFacets reverses winding", () => { + const mesh = makeTriangleMesh(); + const v0 = mesh.getFacetVertex(0, 0); + const v1 = mesh.getFacetVertex(0, 1); + const v2 = mesh.getFacetVertex(0, 2); + mesh.flipFacets(); + // After flip, winding is reversed: [0,1,2] → [2,1,0] + expect(mesh.getFacetVertex(0, 0)).toBe(v2); + expect(mesh.getFacetVertex(0, 1)).toBe(v1); + expect(mesh.getFacetVertex(0, 2)).toBe(v0); + mesh.delete(); + }); + + test("addVertices adds multiple vertices at once", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertices([0, 0, 0, 1, 0, 0, 0, 1, 0]); + expect(mesh.getNumVertices()).toBe(3); + mesh.delete(); + }); + + test("removeVertices removes specified vertices", () => { + const mesh = new lagrange.core.SurfaceMesh(3); + mesh.addVertices([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]); + mesh.addTriangle(0, 1, 2); + expect(mesh.getNumVertices()).toBe(4); + mesh.removeVertices([3]); // remove isolated vertex + expect(mesh.getNumVertices()).toBe(3); + mesh.delete(); + }); + + test("removeFacets removes specified facets", () => { + const a = makeTriangleMesh(); + const b = makeTriangleMesh(); + const mesh = lagrange.core.combineMeshes([a, b]); + expect(mesh.getNumFacets()).toBe(2); + mesh.removeFacets([0]); + expect(mesh.getNumFacets()).toBe(1); + mesh.delete(); + a.delete(); + b.delete(); + }); +}); diff --git a/modules/core/js/ts/core.ts b/modules/core/js/ts/core.ts new file mode 100644 index 00000000..c489ea61 --- /dev/null +++ b/modules/core/js/ts/core.ts @@ -0,0 +1,407 @@ +/** + * Core mesh type and the operations that run on it. + */ + +/** + * A triangle/polygon mesh: vertices, facets, and attached attributes + * (positions, normals, UVs, per-vertex/facet scalars, ...). The central + * data type of the library — most functions take or return a `SurfaceMesh`. + * + * Create one with `new lagrange.core.SurfaceMesh(dimension)` (usually `3`) + * and populate it via `addVertices` / `addTriangles`, or load one from a + * file buffer via `lagrange.io.loadMeshFromBuffer(...)`. + * + * Backed by WASM heap memory. Call {@link delete} when finished to free it + * (JS garbage collection does not reclaim it). + * + * Terminology: + * - **vertex**: a unique 3D point. + * - **facet**: a polygon (triangle, quad, ...) made of vertex references. + * - **corner**: one occurrence of a vertex inside a facet. Attributes that + * differ between adjacent facets (seam UVs, hard-edge normals) live at + * corner level. + * - **edge**: oriented pair of vertices; only populated for some queries. + */ +export interface SurfaceMesh { + getNumVertices(): number; + getNumFacets(): number; + getNumCorners(): number; + getNumEdges(): number; + /** Dimension of vertex positions (`3` for 3D meshes, `2` for 2D). */ + getDimension(): number; + /** True if every facet has exactly 3 vertices. */ + isTriangleMesh(): boolean; + /** True if every facet has exactly 4 vertices. */ + isQuadMesh(): boolean; + /** True if every facet has the same vertex count. */ + isRegular(): boolean; + /** True if facets have varying vertex counts (mixed tris/quads/...). */ + isHybrid(): boolean; + /** Vertex count per facet for regular meshes; `0` for hybrid meshes. */ + getVertexPerFacet(): number; + /** Vertex count of a specific facet. */ + getFacetSize(facetId: number): number; + /** Vertex id of the `localVertexId`-th corner of `facetId`. */ + getFacetVertex(facetId: number, localVertexId: number): number; + /** First corner id belonging to `facetId` (inclusive). */ + getFacetCornerBegin(facetId: number): number; + /** Corner id one past the last corner of `facetId` (exclusive). */ + getFacetCornerEnd(facetId: number): number; + /** Vertex id the given corner refers to. */ + getCornerVertex(cornerId: number): number; + /** Facet the given corner belongs to. */ + getCornerFacet(cornerId: number): number; + /** Copy of one vertex's coordinates. Length equals {@link getDimension}. */ + getPosition(vertexId: number): Float32Array; + /** Copy of the vertex ids that form `facetId`. */ + getFacetVertices(facetId: number): Uint32Array; + /** Release unused capacity reserved by prior `add*` calls. */ + shrinkToFit(): void; + /** Drop all facets. Vertices are kept. */ + clearFacets(): void; + /** Drop all vertices and facets. */ + clearVertices(): void; + /** Whether a named attribute exists on the mesh. */ + hasAttribute(name: string): boolean; + /** + * Append one vertex. `vertex.length` must equal {@link getDimension} + * (typically `3`). + */ + addVertex(vertex: Float32Array | ArrayLike): void; + /** Append a single triangle from three vertex ids. */ + addTriangle(v0: number, v1: number, v2: number): void; + /** + * Append many triangles from a flat vertex-id buffer: + * `[t0v0, t0v1, t0v2, t1v0, t1v1, t1v2, ...]`. Length must be a + * multiple of 3. + */ + addTriangles(indices: Uint32Array | ArrayLike): void; + /** + * Append many vertices from a flat coordinate buffer. Length must be + * a multiple of {@link getDimension}. + */ + addVertices(coords: Float64Array | ArrayLike): void; + /** Remove the listed vertices; remaining vertices and facets are reindexed. */ + removeVertices(indices: number[] | Uint32Array): void; + /** Remove the listed facets. */ + removeFacets(indices: number[] | Uint32Array): void; + /** + * Reverse facet orientation (winding order). Pass a list of facet ids to + * flip only those, or omit to flip every facet. + */ + flipFacets(indices?: number[] | Uint32Array): void; + /** + * Return a new `SurfaceMesh` that is an independent copy of this one. + * + * Underlying attribute buffers are shared via copy-on-write until either + * mesh is mutated, so the clone is cheap. Required when you want to + * modify a derived mesh without affecting the original — plain JS + * assignment (`let b = a`) only copies the handle to the same WASM + * object. + * + * Pass `{ strip: true }` to drop all non-reserved attributes + * (normals, UVs, tangents, user attributes, ...) and keep only the core + * topology (vertex positions, facet indices, corner connectivity). + * Useful to hand the mesh to an algorithm that would otherwise preserve + * stale derived data. + * + * The returned mesh owns its own WASM handle and must be freed with + * {@link delete}. + */ + clone(opts?: { strip?: boolean }): SurfaceMesh; + /** Free the underlying WASM memory. The mesh must not be used afterwards. */ + delete(): void; +} + +/** Sentinel returned in place of a scalar value that is not set or not valid. */ +export const invalidScalar: number = Infinity; + +/** Sentinel returned in place of a vertex / facet / corner id that is missing or invalid. */ +export const invalidIndex: number = 0xffffffff; + +/** + * Construct meshes, measure them, clean them up, and compute per-element + * attributes. Accessible as `lagrange.core`. + */ +export interface CoreModule { + /** + * {@link SurfaceMesh} constructor. Use as `new lagrange.core.SurfaceMesh(3)` + * for a 3D mesh, or `2` for a 2D mesh. + */ + SurfaceMesh: new (dimension: number) => SurfaceMesh; + /** + * Return a new mesh in which every attribute is per-vertex — any per-corner + * seams (hard-edge normals, UV charts) are resolved by duplicating the + * vertices they sit on. + * + * Required for GPU rendering: a unified index buffer is what WebGL/WebGPU + * expect. Chain with `bindings.meshToBabylonMeshDataView64(...)` for + * zero-copy upload. + */ + unifyIndexBuffer(mesh: SurfaceMesh): SurfaceMesh; + /** + * Fill topological holes whose boundary has at most `maxHoleSize` vertices. + * Modifies `mesh` in place. Set `triangulateHoles` to triangulate the new + * patches; otherwise they are inserted as a single polygon. + */ + closeSmallHoles( + mesh: SurfaceMesh, + maxHoleSize: number, + triangulateHoles?: boolean, + ): void; + /** Remove degenerate (non-triangle / zero-area) facets. In-place. Triangle meshes only. */ + removeDegenerateFacets(mesh: SurfaceMesh): void; + /** Merge vertices that share the same position. In-place. */ + removeDuplicateVertices(mesh: SurfaceMesh): void; + /** + * Subdivide edges longer than `maxEdgeLength` (Euclidean length). + * Set `recursive` to keep splitting until no edge exceeds the threshold. + * In-place. + */ + splitLongEdges( + mesh: SurfaceMesh, + maxEdgeLength: number, + recursive: boolean, + ): void; + + // --- Normals --- + + /** + * Attach per-vertex normals. Equivalent to fully smooth shading. + * In-place: adds a normal attribute. + */ + computeVertexNormal(mesh: SurfaceMesh, opts?: VertexNormalOptions): void; + /** + * Attach per-facet normals. Equivalent to flat shading. + * In-place: adds a normal attribute. + */ + computeFacetNormal(mesh: SurfaceMesh): void; + /** + * Attach indexed (per-corner) normals with crease-based smoothing: + * edges whose dihedral angle exceeds `featureAngleThreshold` (radians) + * stay as hard edges, the rest are smoothed. This is the "edge split" + * style of normal used by most DCC tools. In-place. + */ + computeNormal( + mesh: SurfaceMesh, + featureAngleThreshold: number, + opts?: NormalOptions, + ): void; + /** + * Attach tangent / bitangent attributes for normal mapping. + * Requires the mesh to already have UVs and normals. + * In-place. + */ + computeTangentBitangent( + mesh: SurfaceMesh, + opts?: TangentBitangentOptions, + ): void; + + // --- Mesh operations --- + + /** Convert every polygon with more than 3 vertices into triangles. In-place. */ + triangulatePolygonalFacets(mesh: SurfaceMesh): void; + /** Merge several meshes into a single mesh. Returns the combined result. */ + combineMeshes( + meshes: SurfaceMesh[], + opts?: CombineMeshesOptions, + ): SurfaceMesh; + /** + * Label connected components of the mesh. Returns the number of components + * and attaches a per-facet component-id attribute. + */ + computeComponents(mesh: SurfaceMesh, opts?: ComponentOptions): number; + /** + * Flip facet winding so normals point outward (or inward, via options). + * Works per connected component. In-place. + */ + orientOutward(mesh: SurfaceMesh, opts?: OrientOptions): void; + /** Translate and scale the mesh to fit the unit cube centered at the origin. In-place. */ + normalizeMesh(mesh: SurfaceMesh): void; + /** Attach a per-facet surface-area attribute. In-place. */ + computeFacetArea(mesh: SurfaceMesh): void; + + // --- Topology queries --- + + /** True when the mesh is both edge- and vertex-manifold. */ + isManifold(mesh: SurfaceMesh): boolean; + /** True when every vertex has a disk-like neighborhood. */ + isVertexManifold(mesh: SurfaceMesh): boolean; + /** True when every edge is shared by at most two facets. */ + isEdgeManifold(mesh: SurfaceMesh): boolean; + /** True when the mesh has no boundary edges (watertight). */ + isClosed(mesh: SurfaceMesh): boolean; + /** Euler characteristic: `V - E + F`. */ + computeEuler(mesh: SurfaceMesh): number; + + // --- Seam edges, valence, coloring --- + + /** + * Mark edges that sit on a seam of the given indexed attribute + * (different attribute values on the two sides). Adds a per-edge + * boolean attribute. In-place. + */ + computeSeamEdges(mesh: SurfaceMesh, indexedAttributeId: number): void; + /** Attach a per-vertex valence (incident-edge count) attribute. In-place. */ + computeVertexValence(mesh: SurfaceMesh): void; + /** + * Color facets (or vertices) such that no neighbors share a color, + * using a greedy algorithm. Useful for partitioning work or visualizing + * topology. Adds a per-element color-id attribute. In-place. + */ + computeGreedyColoring(mesh: SurfaceMesh, opts?: GreedyColoringOptions): void; + + // --- Additional mesh cleanup --- + + /** Remove facets that duplicate another facet. In-place. */ + removeDuplicateFacets(mesh: SurfaceMesh, opts?: RemoveDuplicateFacetsOptions): void; + /** Remove vertices that no facet references. In-place. */ + removeIsolatedVertices(mesh: SurfaceMesh): void; + /** Remove facets whose area is below the threshold. In-place. */ + removeNullAreaFacets(mesh: SurfaceMesh, opts?: RemoveNullAreaFacetsOptions): void; + /** Collapse edges shorter than `threshold`. In-place. */ + removeShortEdges(mesh: SurfaceMesh, threshold?: number): void; + /** Remove facets that repeat a vertex (e.g. `[a, b, a]`). In-place. */ + removeTopologicallyDegenerateFacets(mesh: SurfaceMesh): void; + /** + * Make the mesh manifold by duplicating non-manifold edges and vertices. + * In-place. + */ + resolveNonmanifoldness(mesh: SurfaceMesh): void; + /** Make vertices manifold by duplicating (edges are left as-is). In-place. */ + resolveVertexNonmanifoldness(mesh: SurfaceMesh): void; +} + +export const coreModuleKeys = [ + "SurfaceMesh", + "unifyIndexBuffer", + "closeSmallHoles", + "removeDegenerateFacets", + "removeDuplicateVertices", + "splitLongEdges", + "computeVertexNormal", + "computeFacetNormal", + "computeNormal", + "computeTangentBitangent", + "triangulatePolygonalFacets", + "combineMeshes", + "computeComponents", + "orientOutward", + "normalizeMesh", + "computeFacetArea", + "isManifold", + "isVertexManifold", + "isEdgeManifold", + "isClosed", + "computeEuler", + "computeSeamEdges", + "computeVertexValence", + "computeGreedyColoring", + "removeDuplicateFacets", + "removeIsolatedVertices", + "removeNullAreaFacets", + "removeShortEdges", + "removeTopologicallyDegenerateFacets", + "resolveNonmanifoldness", + "resolveVertexNonmanifoldness", +] as const satisfies readonly (keyof CoreModule)[]; + +/** + * How a corner's normal is weighted when averaging into a vertex normal. + * - `"uniform"`: every corner contributes equally. + * - `"cornerTriangleArea"`: weight by incident triangle area. + * - `"angle"`: weight by corner angle (least sensitive to tessellation). + */ +export type NormalWeightingType = "uniform" | "cornerTriangleArea" | "angle"; + +export interface VertexNormalOptions { + /** Corner-weighting scheme. Default: `"angle"`. */ + weightType?: NormalWeightingType; + /** Recompute weighted corner normals even if a cached attribute exists. Default: `false`. */ + recomputeWeightedCornerNormals?: boolean; + /** Keep weighted corner normals as a separate attribute after computation. Default: `false`. */ + keepWeightedCornerNormals?: boolean; + /** Merge vertices within this distance before computing. Default: `0` (exact match only). */ + distanceTolerance?: number; +} + +export interface NormalOptions { + /** Corner-weighting scheme. Default: `"angle"`. */ + weightType?: NormalWeightingType; + /** Recompute facet normals even if cached. Default: `false`. */ + recomputeFacetNormals?: boolean; + /** Keep facet normals as a separate attribute. Default: `false`. */ + keepFacetNormals?: boolean; + /** Merge vertices within this distance before computing. Default: `0`. */ + distanceTolerance?: number; + /** + * Vertex ids that should always be treated as sharp (e.g. the tip of a cone). + * Normals there will never be smoothed regardless of dihedral angle. + */ + coneVertices?: number[]; +} + +export interface TangentBitangentOptions { + /** Emit tangent as `vec4`, with the 4th component encoding bitangent sign. Default: `false`. */ + padWithSign?: boolean; + /** Gram-Schmidt orthogonalize the bitangent against tangent and normal. Default: `false`. */ + orthogonalizeBitangent?: boolean; + /** Keep an existing tangent attribute if already present on the mesh. Default: `false`. */ + keepExistingTangent?: boolean; +} + +export interface CombineMeshesOptions { + /** Carry attributes from inputs into the combined mesh. Default: `true`. */ + preserveAttributes?: boolean; +} + +/** + * How connectivity is traversed: + * - `"edge"`: two facets are connected iff they share an edge. + * - `"vertex"`: two facets are connected iff they share any vertex. + */ +export type ConnectivityType = "vertex" | "edge"; + +export interface ComponentOptions { + /** Traversal mode. Default: `"edge"`. */ + connectivityType?: ConnectivityType; + /** + * Element ids (edges or vertices, depending on {@link connectivityType}) + * that act as walls — they block component growth across them. + */ + blockerElements?: number[]; +} + +export interface OrientOptions { + /** `true` → normals face outward; `false` → inward. Default: `true`. */ + positive?: boolean; +} + +export interface SeamEdgesOptions {} + +export interface VertexValenceOptions {} + +/** Which mesh element to color. */ +export type ColoringElementType = "vertex" | "facet"; + +export interface GreedyColoringOptions { + /** Element to color. Default: `"facet"`. */ + elementType?: ColoringElementType; + /** Upper bound on the number of distinct colors. Default: `8`. */ + numColorUsed?: number; +} + +export interface RemoveDuplicateFacetsOptions { + /** + * If `true`, two facets with the same vertices but opposite winding + * are considered distinct. Default: `false`. + */ + considerOrientation?: boolean; +} + +export interface RemoveNullAreaFacetsOptions { + /** Facets with area at or below this threshold are removed. Default: `0`. */ + nullAreaThreshold?: number; + /** Also remove vertices that become unreferenced after the removal. Default: `false`. */ + removeIsolatedVertices?: boolean; +} diff --git a/modules/core/python/include/lagrange/python/eigen_utils.h b/modules/core/python/include/lagrange/python/eigen_utils.h index 89ea10eb..e7b0deb2 100644 --- a/modules/core/python/include/lagrange/python/eigen_utils.h +++ b/modules/core/python/include/lagrange/python/eigen_utils.h @@ -12,6 +12,7 @@ #pragma once #include #include +#include #include @@ -41,7 +42,8 @@ Point to_eigen_point(const GenericPoint& p) if (std::holds_alternative(p)) { auto lst = std::get(p); if (lst.size() != Dim) { - throw std::runtime_error(fmt::format("Point list must have exactly {} elements.", Dim)); + throw std::runtime_error( + lagrange::format("Point list must have exactly {} elements.", Dim)); } for (int i = 0; i < Dim; ++i) { q(i) = nb::cast(lst[i]); diff --git a/modules/core/python/scripts/meshstat.py b/modules/core/python/scripts/meshstat.py index 34532ca3..2561d546 100755 --- a/modules/core/python/scripts/meshstat.py +++ b/modules/core/python/scripts/meshstat.py @@ -185,22 +185,50 @@ def print_extra_info(mesh, info): print_property("num isolated vertices", num_isolated_vertices, 0) # UV check - if ( - mesh.is_triangle_mesh - and mesh.get_matching_attribute_ids(usage=lagrange.AttributeUsage.UV) != [] - ): - uv_attr_id = mesh.get_matching_attribute_id(usage=lagrange.AttributeUsage.UV) - if not mesh.is_attribute_indexed(uv_attr_id): - uv_attr_id = lagrange.map_attribute( - mesh, uv_attr_id, "_indexed_uv", lagrange.AttributeElement.Indexed + 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_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("num flipped UV facets", num_flipped_uv, 0) - info["num_flipped_uv"] = num_flipped_uv + 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): diff --git a/modules/core/python/src/bind_attribute.h b/modules/core/python/src/bind_attribute.h index ced874e5..da883e47 100644 --- a/modules/core/python/src/bind_attribute.h +++ b/modules/core/python/src/bind_attribute.h @@ -21,6 +21,7 @@ #include #include #include +#include #include namespace lagrange::python { @@ -140,7 +141,7 @@ void bind_attribute(nanobind::module_& m) if (nb::try_cast(value, tensor)) { if (tensor.dtype() != nb::dtype()) { throw nb::type_error( - fmt::format( + lagrange::format( "Tensor has a unexpected dtype. Expecting {}.", internal::string_from_scalar()) .c_str()); @@ -199,7 +200,7 @@ void bind_attribute(nanobind::module_& m) if (nb::try_cast(value, tensor)) { if (tensor.dtype() != nb::dtype()) { throw nb::type_error( - fmt::format( + lagrange::format( "Tensor has a unexpected dtype. Expecting {}.", internal::string_from_scalar()) .c_str()); diff --git a/modules/core/python/src/bind_mesh_cleanup.h b/modules/core/python/src/bind_mesh_cleanup.h index bc06e7f9..091321a7 100644 --- a/modules/core/python/src/bind_mesh_cleanup.h +++ b/modules/core/python/src/bind_mesh_cleanup.h @@ -130,13 +130,24 @@ E.g. quad (0,0,1,1) is degenerate, while (1,1,2,3) is not. m.def( "remove_short_edges", - &remove_short_edges, + [](MeshType& mesh, + double threshold, + std::optional vertex_importance_attribute) { + lagrange::RemoveShortEdgesOptions opts; + opts.threshold = threshold; + if (vertex_importance_attribute.has_value()) + opts.vertex_importance_attribute_name = vertex_importance_attribute.value(); + remove_short_edges(mesh, opts); + }, "mesh"_a, + nb::kw_only(), "threshold"_a = 0, + "vertex_importance_attribute"_a = nb::none(), R"(Remove short edges from a mesh. :param mesh: Input mesh (modified in place). -:param threshold: Minimum edge length below which edges are considered short.)"); +:param threshold: Minimum edge length below which edges are considered short. +:param vertex_importance_attribute: Optional vertex attribute name for importance values used to determine which vertex to keep during edge collapse.)"); m.def( "resolve_vertex_nonmanifoldness", diff --git a/modules/core/python/src/bind_surface_mesh.h b/modules/core/python/src/bind_surface_mesh.h index 945ce01e..842113cc 100644 --- a/modules/core/python/src/bind_surface_mesh.h +++ b/modules/core/python/src/bind_surface_mesh.h @@ -32,6 +32,7 @@ #include #include #include +#include // clang-format on namespace lagrange::python { @@ -965,7 +966,7 @@ void bind_surface_mesh(nanobind::module_& m) [&](MeshType& self, AttributeId id, bool sharing) { la_runtime_assert( !self.is_attribute_indexed(id), - fmt::format( + lagrange::format( "Attribute {} is indexed! Please use `indexed_attribute` property " "instead.", id)); @@ -985,7 +986,7 @@ void bind_surface_mesh(nanobind::module_& m) [&](MeshType& self, std::string_view name, bool sharing) { la_runtime_assert( !self.is_attribute_indexed(name), - fmt::format( + lagrange::format( "Attribute \"{}\" is indexed! Please use `indexed_attribute` property " "instead.", name)); @@ -1005,7 +1006,7 @@ void bind_surface_mesh(nanobind::module_& m) [&](MeshType& self, AttributeId id, bool sharing) { la_runtime_assert( self.is_attribute_indexed(id), - fmt::format( + lagrange::format( "Attribute {} is not indexed! Please use `attribute` property instead.", id)); if (!sharing) ensure_attribute_is_not_shared(self, id); @@ -1024,7 +1025,7 @@ void bind_surface_mesh(nanobind::module_& m) [&](MeshType& self, std::string_view name, bool sharing) { la_runtime_assert( self.is_attribute_indexed(name), - fmt::format( + lagrange::format( "Attribute \"{}\" is not indexed! Please use `attribute` property instead.", name)); if (!sharing) ensure_attribute_is_not_shared(self, self.get_attribute_id(name)); @@ -1745,7 +1746,7 @@ If not provided, the edges are initialized in an arbitrary order. auto value = self.mesh->get_metadata(id); fmt::format_to(std::back_inserter(r), " {}: {},\n", name, value); } - return fmt::format("MetaData(\n{})", r); + return lagrange::format("MetaData(\n{})", r); }); surface_mesh_class.def_prop_ro( diff --git a/modules/core/python/src/bind_utilities.h b/modules/core/python/src/bind_utilities.h index ba7c9fd1..8a372d54 100644 --- a/modules/core/python/src/bind_utilities.h +++ b/modules/core/python/src/bind_utilities.h @@ -33,8 +33,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -58,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -601,7 +604,7 @@ Vertices listed in `cone_vertices` are considered as cone vertices, which is alw } else if (scheme == "centroid_fan") { opt.scheme = lagrange::TriangulationOptions::Scheme::CentroidFan; } else { - throw Error(fmt::format("Unsupported triangulation scheme {}", scheme)); + throw Error(lagrange::format("Unsupported triangulation scheme {}", scheme)); } lagrange::triangulate_polygonal_facets(mesh, opt); }, @@ -1248,7 +1251,7 @@ Basel: Birkhäuser Basel, 2008. 175-188. } else if (method == "None" || method == "none") { reorder_method = ReorderingMethod::None; } else { - throw std::runtime_error(fmt::format("Invalid reordering method: {}", method)); + throw std::runtime_error(lagrange::format("Invalid reordering method: {}", method)); } lagrange::reorder_mesh(mesh, reorder_method); @@ -1875,6 +1878,43 @@ The input mesh must be a triangle mesh. :returns: The id of the new attribute.)"); + m.def( + "get_unique_attribute_name", + [](const MeshType& mesh, + std::string_view name, + std::string separator, + std::string postfix, + int max_increment, + bool emit_warning) { + UniqueAttributeNameOptions options; + options.separator = std::move(separator); + options.postfix = std::move(postfix); + options.max_increment = max_increment; + options.emit_warning = emit_warning; + return get_unique_attribute_name(mesh, name, options); + }, + "mesh"_a, + "name"_a, + "separator"_a = UniqueAttributeNameOptions().separator, + "postfix"_a = UniqueAttributeNameOptions().postfix, + "max_increment"_a = UniqueAttributeNameOptions().max_increment, + "emit_warning"_a = UniqueAttributeNameOptions().emit_warning, + R"(Get a unique attribute name for a mesh. + +If the desired name does not exist on the mesh it is returned as-is. If it +already exists, a suffix of the form ``{separator}{count}{postfix}`` is appended +until a unique name is found. An exception is raised if no unique name can be +found after ``max_increment`` attempts. + +:param mesh: The input mesh. +:param name: The desired attribute name. +:param separator: Separator between the base name and counter (default: "."). +:param postfix: Postfix to append after the counter (default: ""). +:param max_increment: Maximum number of attempts to find a unique name (default: 1000). +:param emit_warning: Whether to log a warning when a collision is detected (default: True). + +:returns: A unique attribute name.)"); + m.def( "compute_mesh_covariance", [](MeshType& mesh, @@ -1929,7 +1969,7 @@ The input mesh must be a triangle mesh. options.search_type = SelectFacetsByNormalSimilarityOptions::SearchType::DFS; else throw std::runtime_error( - fmt::format("Invalid search type: {}", search_type.value())); + lagrange::format("Invalid search type: {}", search_type.value())); } if (num_smooth_iterations.has_value()) options.num_smooth_iterations = num_smooth_iterations.value(); @@ -2089,7 +2129,7 @@ The input mesh must be a triangle mesh. options.connectivity_type = UVChartOptions::ConnectivityType::Edge; } else { throw std::runtime_error( - fmt::format("Invalid connectivity type: {}", connectivity_type)); + lagrange::format("Invalid connectivity type: {}", connectivity_type)); } return compute_uv_charts(mesh, options); }, @@ -2106,6 +2146,32 @@ The input mesh must be a triangle mesh. @returns: A list of chart ids for each vertex.)"); + m.def( + "disconnect_uv_charts", + [](MeshType& mesh, + std::string_view uv_attribute_name, + std::string_view chart_id_attribute_name) { + DisconnectUVChartsOptions options; + options.uv_attribute_name = uv_attribute_name; + options.chart_id_attribute_name = chart_id_attribute_name; + return disconnect_uv_charts(mesh, options); + }, + "mesh"_a, + "uv_attribute_name"_a = DisconnectUVChartsOptions().uv_attribute_name, + "chart_id_attribute_name"_a = DisconnectUVChartsOptions().chart_id_attribute_name, + R"(Disconnect UV charts by duplicating UV vertices shared across different charts. + +After this operation, no two facets belonging to different UV charts will share a UV vertex +index. Without any input chart id attribute, this eliminates non-manifold UV vertices (pinch +points) where charts touch at a single vertex. + +:param mesh: Input 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, chart ids + are computed automatically using edge connectivity on the UV mesh. + +:returns: The number of UV vertices that were duplicated.)"); + m.def( "uv_mesh_view", [](const MeshType& mesh, std::string_view uv_attribute_name) { diff --git a/modules/core/python/src/tensor_utils.cpp b/modules/core/python/src/tensor_utils.cpp index 6b7f4331..609d4c7e 100644 --- a/modules/core/python/src/tensor_utils.cpp +++ b/modules/core/python/src/tensor_utils.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -52,7 +53,7 @@ bool check_shape(const Shape& shape, size_t expected_size) return shape[0] == expected_size; else return false; - default: throw Error(fmt::format("{}-dimensional tensor is not supported", ndim)); + default: throw Error(lagrange::format("{}-dimensional tensor is not supported", ndim)); } } @@ -70,7 +71,7 @@ bool check_shape(const Shape& shape, size_t expected_rows, size_t expected_cols) case 2: return (expected_rows == dynamic || expected_rows == shape[0]) && (expected_cols == dynamic || expected_cols == shape[1]); - default: throw Error(fmt::format("{}-dimensional tensor is not supported", ndim)); + default: throw Error(lagrange::format("{}-dimensional tensor is not supported", ndim)); } } @@ -81,7 +82,7 @@ bool is_dense(const Shape& shape, const Stride& stride) case 1: return static_cast(shape[0]) == 0 || static_cast(stride[0]) == 1; case 2: return static_cast(stride[0]) == shape[1] && static_cast(stride[1]) == 1; - default: throw Error(fmt::format("{}-dimensional tensor is not supported", ndim)); + default: throw Error(lagrange::format("{}-dimensional tensor is not supported", ndim)); } } diff --git a/modules/core/python/tests/test_disconnect_uv_charts.py b/modules/core/python/tests/test_disconnect_uv_charts.py new file mode 100644 index 00000000..a2c88a0d --- /dev/null +++ b/modules/core/python/tests/test_disconnect_uv_charts.py @@ -0,0 +1,183 @@ +# +# 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 + + +def _make_mesh_with_indexed_uv(vertices, facets, uv_values, uv_indices): + """Helper to create a mesh with an indexed UV attribute.""" + 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 + + +class TestDisconnectUVCharts: + def test_single_chart_no_duplicates(self): + """Two triangles sharing an edge in UV space -> single chart, no duplicates.""" + 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_duped = lagrange.disconnect_uv_charts(mesh, uv_attribute_name="uv") + assert num_duped == 0 + attr = mesh.indexed_attribute("uv") + assert attr.values.num_elements == 4 + + def test_two_separate_charts(self): + """Two triangles with completely separate UV indices -> no shared UV vertex.""" + 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], [0.5, 0], [1, 0.5], [0.5, 1]], + uv_indices=[[0, 1, 2], [3, 4, 5]], + ) + num_duped = lagrange.disconnect_uv_charts(mesh, uv_attribute_name="uv") + assert num_duped == 0 + attr = mesh.indexed_attribute("uv") + assert attr.values.num_elements == 6 + + def test_pinch_point(self): + """Two triangles sharing a single UV vertex (pinch point) -> one duplicate.""" + mesh = _make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0.5, 0.5, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [2, 3, 4]], + uv_values=[[0, 0], [1, 0], [0.5, 0.5], [0, 1], [1, 1]], + uv_indices=[[0, 1, 2], [2, 3, 4]], + ) + num_duped = lagrange.disconnect_uv_charts(mesh, uv_attribute_name="uv") + assert num_duped == 1 + attr = mesh.indexed_attribute("uv") + assert attr.values.num_elements == 6 # 5 original + 1 duplicate + + # The two triangles should no longer share any UV index. + indices = attr.indices.data.flatten() + tri0 = set(indices[:3]) + tri1 = set(indices[3:6]) + assert tri0.isdisjoint(tri1) + + def test_three_charts_at_single_vertex(self): + """Three triangles meeting at a single UV vertex -> 2 duplicates.""" + mesh = _make_mesh_with_indexed_uv( + vertices=[ + [0, 0, 0], + [1, 0, 0], + [0.5, 1, 0], + [-1, 0, 0], + [-0.5, 1, 0], + [0, -1, 0], + [1, -1, 0], + ], + facets=[[0, 1, 2], [0, 3, 4], [0, 5, 6]], + uv_values=[ + [0, 0], + [1, 0], + [0.5, 1], + [-1, 0], + [-0.5, 1], + [0, -1], + [1, -1], + ], + uv_indices=[[0, 1, 2], [0, 3, 4], [0, 5, 6]], + ) + num_duped = lagrange.disconnect_uv_charts(mesh, uv_attribute_name="uv") + assert num_duped == 2 + attr = mesh.indexed_attribute("uv") + assert attr.values.num_elements == 9 # 7 + 2 + + def test_with_chart_id_attribute(self): + """Force separate charts via an external 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]], + ) + # Force different chart ids despite shared edge. + mesh.create_attribute( + "@chart_id", + element=lagrange.AttributeElement.Facet, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.array([0, 1], dtype=np.uint32), + ) + num_duped = lagrange.disconnect_uv_charts( + mesh, + uv_attribute_name="uv", + chart_id_attribute_name="@chart_id", + ) + assert num_duped == 2 # UV vertices 1 and 2 are shared + attr = mesh.indexed_attribute("uv") + assert attr.values.num_elements == 6 # 4 + 2 + + def test_uv_values_preserved(self): + """Check that duplicated UV values match the original.""" + pinch_uv = [0.5, 0.5] + mesh = _make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0.5, 0.5, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [2, 3, 4]], + uv_values=[[0, 0], [1, 0], pinch_uv, [0, 1], [1, 1]], + uv_indices=[[0, 1, 2], [2, 3, 4]], + ) + lagrange.disconnect_uv_charts(mesh, uv_attribute_name="uv") + + attr = mesh.indexed_attribute("uv") + values = attr.values.data + indices = attr.indices.data.flatten() + + # The pinch point was at corner 2 of tri0 and corner 0 of tri1. + idx_tri0 = indices[2] + idx_tri1 = indices[3] + assert idx_tri0 != idx_tri1 + np.testing.assert_allclose(values[idx_tri0], pinch_uv) + np.testing.assert_allclose(values[idx_tri1], pinch_uv) + + def test_already_separated_noop(self): + """Charts that are already separated should not change.""" + 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]], + ) + indices_before = mesh.indexed_attribute("uv").indices.data.copy() + num_duped = lagrange.disconnect_uv_charts(mesh, uv_attribute_name="uv") + assert num_duped == 0 + np.testing.assert_array_equal(mesh.indexed_attribute("uv").indices.data, indices_before) + + def test_default_uv_attribute_name(self): + """When no uv_attribute_name is given, auto-detect the first indexed UV.""" + mesh = _make_mesh_with_indexed_uv( + vertices=[[0, 0, 0], [1, 0, 0], [0.5, 0.5, 0], [0, 1, 0], [1, 1, 0]], + facets=[[0, 1, 2], [2, 3, 4]], + uv_values=[[0, 0], [1, 0], [0.5, 0.5], [0, 1], [1, 1]], + uv_indices=[[0, 1, 2], [2, 3, 4]], + ) + # Call without specifying uv_attribute_name + num_duped = lagrange.disconnect_uv_charts(mesh) + assert num_duped == 1 diff --git a/modules/core/python/tests/test_get_unique_attribute_name.py b/modules/core/python/tests/test_get_unique_attribute_name.py new file mode 100644 index 00000000..172daa6d --- /dev/null +++ b/modules/core/python/tests/test_get_unique_attribute_name.py @@ -0,0 +1,118 @@ +# +# 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 TestGetUniqueAttributeName: + def test_unused_name_returned_unchanged(self, single_triangle): + mesh = single_triangle + result = lagrange.get_unique_attribute_name(mesh, "color") + assert result == "color" + + def test_existing_name_gets_suffix(self, single_triangle): + mesh = single_triangle + mesh.create_attribute( + "color", + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.zeros(mesh.num_vertices, dtype=np.float64), + ) + result = lagrange.get_unique_attribute_name(mesh, "color") + assert result == "color.0" + + def test_multiple_existing_names_get_incremented_suffix(self, single_triangle): + mesh = single_triangle + for suffix in ["color", "color.0", "color.1"]: + mesh.create_attribute( + suffix, + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.zeros(mesh.num_vertices, dtype=np.float64), + ) + result = lagrange.get_unique_attribute_name(mesh, "color") + assert result == "color.2" + + def test_raises_after_exhausting_attempts(self, single_triangle): + mesh = single_triangle + # Create 'color' and 'color.0' through 'color.999' (1001 attributes total). + names = ["color"] + [f"color.{i}" for i in range(1000)] + for name in names: + mesh.create_attribute( + name, + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.zeros(mesh.num_vertices, dtype=np.float64), + ) + with pytest.raises(Exception): + lagrange.get_unique_attribute_name(mesh, "color") + + def test_custom_separator(self, single_triangle): + mesh = single_triangle + mesh.create_attribute( + "color", + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.zeros(mesh.num_vertices, dtype=np.float64), + ) + result = lagrange.get_unique_attribute_name(mesh, "color", separator="_") + assert result == "color_0" + + def test_custom_postfix(self, single_triangle): + mesh = single_triangle + mesh.create_attribute( + "color", + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.zeros(mesh.num_vertices, dtype=np.float64), + ) + result = lagrange.get_unique_attribute_name(mesh, "color", postfix=".bak") + assert result == "color.0.bak" + + def test_custom_separator_and_postfix(self, single_triangle): + mesh = single_triangle + mesh.create_attribute( + "color", + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.zeros(mesh.num_vertices, dtype=np.float64), + ) + result = lagrange.get_unique_attribute_name(mesh, "color", separator="_", postfix=".tmp") + assert result == "color_0.tmp" + + def test_custom_max_increment(self, single_triangle): + mesh = single_triangle + # Create 'color' and 'color.0' through 'color.9' (11 attributes total). + names = ["color"] + [f"color.{i}" for i in range(10)] + for name in names: + mesh.create_attribute( + name, + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.zeros(mesh.num_vertices, dtype=np.float64), + ) + # Should raise with max_increment=10 since we need to try up to 10 + with pytest.raises(Exception): + lagrange.get_unique_attribute_name(mesh, "color", max_increment=10) + + def test_disable_warning(self, single_triangle): + mesh = single_triangle + mesh.create_attribute( + "color", + element=lagrange.AttributeElement.Vertex, + usage=lagrange.AttributeUsage.Scalar, + initial_values=np.zeros(mesh.num_vertices, dtype=np.float64), + ) + # This should not emit a warning (testing mainly for no crash) + result = lagrange.get_unique_attribute_name(mesh, "color", emit_warning=False) + assert result == "color.0" diff --git a/modules/core/python/tests/test_remove_short_edges.py b/modules/core/python/tests/test_remove_short_edges.py index 0e43cc78..f9d27741 100644 --- a/modules/core/python/tests/test_remove_short_edges.py +++ b/modules/core/python/tests/test_remove_short_edges.py @@ -15,11 +15,11 @@ class TestRemoveShortEdges: def test_triangle(self, single_triangle): mesh = single_triangle - lagrange.remove_short_edges(mesh, 0.1) + lagrange.remove_short_edges(mesh, threshold=0.1) assert mesh.num_vertices == 3 assert mesh.num_facets == 1 - lagrange.remove_short_edges(mesh, 10) + lagrange.remove_short_edges(mesh, threshold=10) assert mesh.num_vertices == 0 assert mesh.num_facets == 0 @@ -38,6 +38,6 @@ def test_mixed_elements(self): mesh.add_quad(4, 5, 7, 6) mesh.add_triangle(0, 1, 4) mesh.add_triangle(4, 1, 5) - lagrange.remove_short_edges(mesh, 0.2) + lagrange.remove_short_edges(mesh, threshold=0.2) assert mesh.num_facets == 2 assert mesh.num_vertices == 6 diff --git a/modules/core/src/Attribute.cpp b/modules/core/src/Attribute.cpp index 1341ab2b..3dd7ef30 100644 --- a/modules/core/src/Attribute.cpp +++ b/modules/core/src/Attribute.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -625,12 +626,11 @@ void Attribute::growth_check(size_t new_cap) throw Error("Attribute policy prevents growing external buffer"); case AttributeGrowthPolicy::AllowWithinCapacity: if (new_cap > m_const_view.size()) { - throw Error( - fmt::format( - "Attribute policy prevents growing external buffer beyond capacity ({} / " - "{})", - new_cap, - m_const_view.size())); + throw Error(format( + "Attribute policy prevents growing external buffer beyond capacity ({} / " + "{})", + new_cap, + m_const_view.size())); } break; case AttributeGrowthPolicy::WarnAndCopy: diff --git a/modules/core/src/ExactPredicates.cpp b/modules/core/src/ExactPredicates.cpp index bb15cc7f..f6c8e94d 100644 --- a/modules/core/src/ExactPredicates.cpp +++ b/modules/core/src/ExactPredicates.cpp @@ -28,7 +28,7 @@ std::unique_ptr ExactPredicates::create(const std::string& engi } } -short ExactPredicates::collinear3D(double p1[3], double p2[3], double p3[3]) const +short ExactPredicates::collinear3D(const double p1[3], const double p2[3], const double p3[3]) const { for (int k = 0; k < 3; ++k) { double q1[2]; diff --git a/modules/core/src/ExactPredicatesShewchuk.cpp b/modules/core/src/ExactPredicatesShewchuk.cpp index b51fcc36..6c7fe14e 100644 --- a/modules/core/src/ExactPredicatesShewchuk.cpp +++ b/modules/core/src/ExactPredicatesShewchuk.cpp @@ -34,32 +34,39 @@ ExactPredicatesShewchuk::ExactPredicatesShewchuk() std::call_once(once_flag, []() { lagrange::exactinit(); }); } -short ExactPredicatesShewchuk::orient2D(double p1[2], double p2[2], double p3[2]) const +short ExactPredicatesShewchuk::orient2D(const double p1[2], const double p2[2], const double p3[2]) + const { auto r = ::lagrange::orient2d(p1, p2, p3); return (r == 0) ? 0 : ((r > 0) ? 1 : -1); } -short ExactPredicatesShewchuk::orient3D(double p1[3], double p2[3], double p3[3], double p4[3]) - const +short ExactPredicatesShewchuk::orient3D( + const double p1[3], + const double p2[3], + const double p3[3], + const double p4[3]) const { auto r = ::lagrange::orient3d(p1, p2, p3, p4); return (r == 0) ? 0 : ((r > 0) ? 1 : -1); } -short ExactPredicatesShewchuk::incircle(double p1[2], double p2[2], double p3[2], double p4[2]) - const +short ExactPredicatesShewchuk::incircle( + const double p1[2], + const double p2[2], + const double p3[2], + const double p4[2]) const { auto r = ::lagrange::incircle(p1, p2, p3, p4); return (r == 0) ? 0 : ((r > 0) ? 1 : -1); } short ExactPredicatesShewchuk::insphere( - double p1[3], - double p2[3], - double p3[3], - double p4[3], - double p5[3]) const + const double p1[3], + const double p2[3], + const double p3[3], + const double p4[3], + const double p5[3]) const { auto r = ::lagrange::insphere(p1, p2, p3, p4, p5); return (r == 0) ? 0 : ((r > 0) ? 1 : -1); diff --git a/modules/core/src/SurfaceMesh.cpp b/modules/core/src/SurfaceMesh.cpp index a40888e0..9874f50d 100644 --- a/modules/core/src/SurfaceMesh.cpp +++ b/modules/core/src/SurfaceMesh.cpp @@ -34,6 +34,7 @@ #include #include #include +#include // clang-format on #include @@ -179,10 +180,10 @@ struct SurfaceMesh::AttributeManager auto it_old = m_name_to_id.find(old_key); auto it_new = m_name_to_id.find(new_key); if (it_old == m_name_to_id.end()) { - throw Error(fmt::format("Source attribute '{}' does not exist", old_name)); + throw Error(format("Source attribute '{}' does not exist", old_name)); } if (it_new != m_name_to_id.end()) { - throw Error(fmt::format("Target attribute '{}' already exist", new_name)); + throw Error(format("Target attribute '{}' already exist", new_name)); } else { AttributeId id = it_old->second; m_name_to_id.erase(it_old); @@ -290,7 +291,7 @@ struct SurfaceMesh::AttributeManager m_attributes.emplace_back(); } } else { - la_runtime_assert(false, fmt::format("Attribute '{}' already exist!", name)); + la_runtime_assert(false, format("Attribute '{}' already exist!", name)); } return it->second; } @@ -348,7 +349,7 @@ AttributeId SurfaceMesh::get_attribute_id(std::string_view name) { auto ret = m_attributes->get_id(name); if (ret == invalid_attribute_id()) { - throw Error(fmt::format("Attribute '{}' does not exist.", name)); + throw Error(format("Attribute '{}' does not exist.", name)); } return ret; } @@ -395,9 +396,7 @@ void SurfaceMesh::set_attribute_default_internal(std::string_view attr.set_default_value(invalid()); } else { throw Error( - fmt::format( - "Attribute name '{}' is not a valid reserved attribute name", - name)); + format("Attribute name '{}' is not a valid reserved attribute name", name)); } } } @@ -415,9 +414,7 @@ AttributeId SurfaceMesh::create_attribute( AttributeCreatePolicy policy) { if (policy == AttributeCreatePolicy::ErrorIfReserved) { - la_runtime_assert( - !starts_with(name, "$"), - fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); } return create_attribute_internal( name, @@ -463,7 +460,7 @@ AttributeId SurfaceMesh::create_attribute_internal( if (usage == AttributeUsage::Position) { la_runtime_assert( num_channels == get_dimension(), - fmt::format( + format( "Invalid number of channels for {} attribute: should be {}.", internal::to_string(usage), get_dimension())); @@ -473,7 +470,7 @@ AttributeId SurfaceMesh::create_attribute_internal( usage == AttributeUsage::Bitangent) { la_runtime_assert( num_channels == get_dimension() || num_channels == get_dimension() + 1, - fmt::format( + format( "Invalid number of channels for {} attribute: should be {} or {} + 1.", internal::to_string(usage), get_dimension(), @@ -536,7 +533,7 @@ AttributeId SurfaceMesh::create_attribute_from( const SurfaceMesh& source_mesh, std::string_view source_name) { - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); if (source_name.empty()) source_name = name; const AttributeId source_id = source_mesh.get_attribute_id(source_name); const AttributeBase& source_attr = source_mesh.m_attributes->read_base(source_id); @@ -559,7 +556,7 @@ AttributeId SurfaceMesh::wrap_as_attribute( span values_view) { la_runtime_assert(element != AttributeElement::Indexed, "Element type must not be Indexed"); - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); const size_t num_elements = get_num_elements_internal(element); return wrap_as_attribute_internal( @@ -581,7 +578,7 @@ AttributeId SurfaceMesh::wrap_as_attribute( SharedSpan shared_values) { la_runtime_assert(element != AttributeElement::Indexed, "Element type must not be Indexed"); - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); const size_t num_elements = get_num_elements_internal(element); return wrap_as_attribute_internal( @@ -603,7 +600,7 @@ AttributeId SurfaceMesh::wrap_as_const_attribute( span values_view) { la_runtime_assert(element != AttributeElement::Indexed, "Element type must not be Indexed"); - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); const size_t num_elements = get_num_elements_internal(element); return wrap_as_attribute_internal( @@ -625,7 +622,7 @@ AttributeId SurfaceMesh::wrap_as_const_attribute( SharedSpan shared_values) { la_runtime_assert(element != AttributeElement::Indexed, "Element type must not be Indexed"); - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); const size_t num_elements = get_num_elements_internal(element); return wrap_as_attribute_internal( @@ -647,7 +644,7 @@ AttributeId SurfaceMesh::wrap_as_indexed_attribute( span values_view, span indices_view) { - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); return wrap_as_attribute_internal( name, AttributeElement::Indexed, @@ -668,7 +665,7 @@ AttributeId SurfaceMesh::wrap_as_indexed_attribute( SharedSpan shared_values, SharedSpan shared_indices) { - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); return wrap_as_attribute_internal( name, AttributeElement::Indexed, @@ -689,7 +686,7 @@ AttributeId SurfaceMesh::wrap_as_indexed_attribute( span values_view, SharedSpan shared_indices) { - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); return wrap_as_attribute_internal( name, AttributeElement::Indexed, @@ -710,7 +707,7 @@ AttributeId SurfaceMesh::wrap_as_indexed_attribute( SharedSpan shared_values, span indices_view) { - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); return wrap_as_attribute_internal( name, AttributeElement::Indexed, @@ -731,7 +728,7 @@ AttributeId SurfaceMesh::wrap_as_const_indexed_attribute( span values_view, span indices_view) { - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); return wrap_as_attribute_internal( name, AttributeElement::Indexed, @@ -752,7 +749,7 @@ AttributeId SurfaceMesh::wrap_as_const_indexed_attribute( SharedSpan shared_values, SharedSpan shared_indices) { - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); return wrap_as_attribute_internal( name, AttributeElement::Indexed, @@ -773,7 +770,7 @@ AttributeId SurfaceMesh::wrap_as_const_indexed_attribute( span values_view, SharedSpan shared_indices) { - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); return wrap_as_attribute_internal( name, AttributeElement::Indexed, @@ -794,7 +791,7 @@ AttributeId SurfaceMesh::wrap_as_const_indexed_attribute( SharedSpan shared_values, span indices_view) { - la_runtime_assert(!starts_with(name, "$"), fmt::format("Attribute name is reserved: {}", name)); + la_runtime_assert(!starts_with(name, "$"), format("Attribute name is reserved: {}", name)); return wrap_as_attribute_internal( name, AttributeElement::Indexed, @@ -1024,7 +1021,7 @@ AttributeId SurfaceMesh::duplicate_attribute( { la_runtime_assert( !starts_with(new_name, "$"), - fmt::format("Attribute name is reserved: {}", new_name)); + format("Attribute name is reserved: {}", new_name)); return create_attribute_from(new_name, *this, old_name); } @@ -1035,7 +1032,7 @@ void SurfaceMesh::rename_attribute( { la_runtime_assert( !starts_with(new_name, "$"), - fmt::format("Attribute name is reserved: {}", new_name)); + format("Attribute name is reserved: {}", new_name)); m_attributes->rename(old_name, new_name); } @@ -1068,12 +1065,11 @@ void SurfaceMesh::delete_attribute( } else if (name == s_reserved_names.next_corner_around_vertex()) { m_reserved_ids.next_corner_around_vertex() = invalid_attribute_id(); } else { - throw Error( - fmt::format("Attribute name '{}' is not a valid reserved attribute name", name)); + throw Error(format("Attribute name '{}' is not a valid reserved attribute name", name)); } } size_t num_deleted = m_attributes->erase(name); - la_runtime_assert(num_deleted == 1, fmt::format("Attribute {} does not exist", name)); + la_runtime_assert(num_deleted == 1, format("Attribute {} does not exist", name)); } template @@ -2651,6 +2647,11 @@ void SurfaceMesh::update_edges_range_internal( template void SurfaceMesh::clear_edges() { + if (!has_edges()) { + la_debug_assert(m_num_edges == 0); + logger().trace("Mesh already has no edge information, skipping clear_edges()"); + return; + } delete_attribute(s_reserved_names.corner_to_edge(), AttributeDeletePolicy::Force); delete_attribute(s_reserved_names.edge_to_first_corner(), AttributeDeletePolicy::Force); delete_attribute(s_reserved_names.next_corner_around_edge(), AttributeDeletePolicy::Force); @@ -2687,7 +2688,7 @@ auto SurfaceMesh::get_edge_vertices(Index e) const -> std::array< la_debug_assert(m_reserved_ids.edge_to_first_corner() != invalid_attribute_id()); const Index c = get_attribute(m_reserved_ids.edge_to_first_corner()).get(e); if (c == invalid()) { - throw Error(fmt::format("Invalid corner id for edge: {}", e)); + throw Error(format("Invalid corner id for edge: {}", e)); } Index f = get_corner_facet(c); Index lv = c - get_facet_corner_begin(f); diff --git a/modules/core/src/compute_tangent_bitangent.cpp b/modules/core/src/compute_tangent_bitangent.cpp index 5dfa7ec7..ae5f8c32 100644 --- a/modules/core/src/compute_tangent_bitangent.cpp +++ b/modules/core/src/compute_tangent_bitangent.cpp @@ -26,6 +26,7 @@ #include #include #include +#include // clang-format on namespace lagrange { @@ -491,13 +492,12 @@ void corner_tangent_bitangent_raw( // Alexa, Marc, and Max Wardetzky. "Discrete Laplacians on general // polygonal meshes." ACM SIGGRAPH 2011 papers. 2011. 1-10. // https://ddg.math.uni-goettingen.de/pub/Polygonal_Laplace.pdf - throw Error( - fmt::format( - "Facet {} has {} vertices. Only facets with 3 and 4 vertices are " - "supported " - "at the moment.", - f, - facet.size())); + throw Error(format( + "Facet {} has {} vertices. Only facets with 3 and 4 vertices are " + "supported " + "at the moment.", + f, + facet.size())); } }(); @@ -572,7 +572,7 @@ TangentBitangentResult compute_tangent_bitangent( mesh.get_attribute_base(options.tangent_attribute_name).get_element_type(); la_runtime_assert( tangent_element_type == lagrange::AttributeElement::Corner, - fmt::format( + format( "compute_tangent_bitangent with keep_existing_tangent enabled: " "input tangent is of element_type {}, while output element_type is {}.", internal::to_string(tangent_element_type), @@ -632,7 +632,7 @@ TangentBitangentResult compute_tangent_bitangent( mesh.template get_attribute(options.tangent_attribute_name); la_runtime_assert( tangent_attrib.get_element_type() == lagrange::AttributeElement::Corner, - fmt::format( + format( "Invalid tangent attribute element_type {}. Only Indexed and Corner " "accepted. ", internal::to_string(tangent_attrib.get_element_type()))); diff --git a/modules/core/src/compute_uv_charts.cpp b/modules/core/src/compute_uv_charts.cpp index d7b723f5..06712ef0 100644 --- a/modules/core/src/compute_uv_charts.cpp +++ b/modules/core/src/compute_uv_charts.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -24,16 +25,25 @@ size_t compute_uv_charts(SurfaceMesh& mesh, const UVChartOptions& { UVMeshOptions uv_mesh_options; uv_mesh_options.uv_attribute_name = options.uv_attribute_name; - SurfaceMesh uv_mesh = uv_mesh_view(mesh, uv_mesh_options); ComponentOptions component_options; component_options.connectivity_type = options.connectivity_type; component_options.output_attribute_name = options.output_attribute_name; - auto num_charts = compute_components(uv_mesh, component_options); + using OtherScalar = std::conditional_t, double, float>; - // Transfer chart ids back to the input mesh - mesh.create_attribute_from(options.output_attribute_name, uv_mesh); + size_t num_charts; + if (uv_attribute_id(mesh, uv_mesh_options)) { + auto uv_mesh = uv_mesh_view(mesh, uv_mesh_options); + num_charts = compute_components(uv_mesh, component_options); + mesh.create_attribute_from(options.output_attribute_name, uv_mesh); + } else if (uv_attribute_id(mesh, uv_mesh_options)) { + auto uv_mesh = uv_mesh_view(mesh, uv_mesh_options); + num_charts = compute_components(uv_mesh, component_options); + mesh.create_attribute_from(options.output_attribute_name, uv_mesh); + } else { + throw Error("compute_uv_charts: no suitable UV attribute found."); + } return num_charts; } diff --git a/modules/core/src/compute_uv_tile_list.cpp b/modules/core/src/compute_uv_tile_list.cpp index d60006d3..60053b5f 100644 --- a/modules/core/src/compute_uv_tile_list.cpp +++ b/modules/core/src/compute_uv_tile_list.cpp @@ -16,6 +16,7 @@ #include #include +#include #include namespace lagrange { diff --git a/modules/core/src/disconnect_uv_charts.cpp b/modules/core/src/disconnect_uv_charts.cpp new file mode 100644 index 00000000..f6aab39c --- /dev/null +++ b/modules/core/src/disconnect_uv_charts.cpp @@ -0,0 +1,183 @@ +/* + * 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 +#include +#include +#include + +namespace lagrange { + +namespace { + +template +size_t disconnect_uv_charts_impl( + SurfaceMesh& mesh, + AttributeId uv_attr_id, + span chart_ids) +{ + auto& uv_attr = mesh.template ref_indexed_attribute(uv_attr_id); + auto old_values = matrix_view(uv_attr.values()); + auto uv_indices = vector_ref(uv_attr.indices()); + + 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(); + + 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; + for (Index col = 0; col < num_cols; ++col) { + v[col] = old_values(uv_idx, col); + } + new_values.push_back(v); + } + uv_indices[c] = it->second; + } + } + + size_t num_duplicated = new_values.size() > static_cast(num_old_values) + ? new_values.size() - static_cast(num_old_values) + : 0; + + // Rewrite UV values + uv_attr.values().resize_elements(new_values.size()); + auto new_val_ref = matrix_ref(uv_attr.values()); + for (size_t i = 0; i < new_values.size(); ++i) { + for (Index c = 0; c < num_cols; ++c) { + new_val_ref(i, c) = new_values[i][c]; + } + } + + if (num_duplicated > 0) { + logger().info( + "Disconnected UV charts: duplicated {} UV vertices ({} -> {}).", + num_duplicated, + num_old_values, + new_values.size()); + } + + return num_duplicated; +} + +} // namespace + +template +size_t disconnect_uv_charts( + SurfaceMesh& mesh, + const DisconnectUVChartsOptions& options) +{ + // Resolve UV attribute name (indexed UV attributes only) + UVMeshOptions uv_mesh_options; + 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."); + } + + 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); + } + }); + + 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); + } + + return num_duplicated; +} + +#define LA_X_disconnect_uv_charts(_, Scalar, Index) \ + template LA_CORE_API size_t disconnect_uv_charts( \ + SurfaceMesh&, \ + const DisconnectUVChartsOptions&); +LA_SURFACE_MESH_X(disconnect_uv_charts, 0) + +} // namespace lagrange diff --git a/modules/core/src/get_unique_attribute_name.cpp b/modules/core/src/get_unique_attribute_name.cpp new file mode 100644 index 00000000..656ae4e0 --- /dev/null +++ b/modules/core/src/get_unique_attribute_name.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 + +namespace lagrange { + +template +std::string get_unique_attribute_name( + const SurfaceMesh& mesh, + std::string_view name, + const UniqueAttributeNameOptions& options) +{ + if (!mesh.has_attribute(name)) { + return std::string(name); + } else { + std::string new_name; + for (int cnt = 0; cnt < options.max_increment; ++cnt) { + new_name = format("{}{}{}{}", name, options.separator, cnt, options.postfix); + if (!mesh.has_attribute(new_name)) { + if (options.emit_warning) { + logger().warn( + "Attribute '{}' already exists. Using '{}' instead.", + name, + new_name); + } + return new_name; + } + } + throw Error(format("Could not assign a unique attribute name for: {}", name)); + } +} + +#define LA_X_get_unique_attribute_name(_, Scalar, Index) \ + template LA_CORE_API std::string get_unique_attribute_name( \ + const SurfaceMesh&, \ + std::string_view, \ + const UniqueAttributeNameOptions&); +LA_SURFACE_MESH_X(get_unique_attribute_name, 0) + +} // namespace lagrange diff --git a/modules/core/src/internal/cpu_features.cpp b/modules/core/src/internal/cpu_features.cpp index de300a6e..d3ce1e10 100644 --- a/modules/core/src/internal/cpu_features.cpp +++ b/modules/core/src/internal/cpu_features.cpp @@ -25,6 +25,7 @@ // SIMD extension querying is only available on x86. #if LAGRANGE_TARGET_PLATFORM(x86_64) #if LAGRANGE_TARGET_OS(WINDOWS) + #include // Visual Studio defines a builtin function for CPUID, so use that if possible. #define GETCPUID(a, b, c, d, a_inp, c_inp) \ { \ diff --git a/modules/core/src/internal/extract_submeshes_by_group.cpp b/modules/core/src/internal/extract_submeshes_by_group.cpp new file mode 100644 index 00000000..3146f088 --- /dev/null +++ b/modules/core/src/internal/extract_submeshes_by_group.cpp @@ -0,0 +1,311 @@ +/* + * 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 "extract_submeshes_by_group.h" + +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include +#include + +namespace lagrange::internal { + +namespace { + +// Thread-local scratch state reused across groups processed by the same thread. +template +struct ThreadLocalState +{ + // Vertex old-to-new remapping (size V, entries reset per group). + std::vector vertex_old2new; + + // Per-group extraction buffers (cleared and reused each iteration). + std::vector vertex_new2old; + std::vector facet_new2old; + std::vector corner_new2old; + std::vector local_connectivity; + std::vector facet_sizes; // hybrid meshes only + + // Scratch for indexed attribute compaction. + std::vector value_old2new; + std::vector value_new2old; + std::vector output_indices; + + void clear_group_state() + { + vertex_new2old.clear(); + facet_new2old.clear(); + corner_new2old.clear(); + local_connectivity.clear(); + facet_sizes.clear(); + } +}; + +} // namespace + +template +std::vector> extract_submeshes_by_group( + const SurfaceMesh& mesh, + size_t num_groups, + span facet_indices, + span group_offsets, + const SubmeshOptions& options) +{ + const Index num_vertices = mesh.get_num_vertices(); + const bool is_regular = mesh.is_regular(); + const Index vertex_per_facet = + is_regular && mesh.get_num_facets() > 0 ? mesh.get_facet_size(0) : 0; + const Index dim = mesh.get_dimension(); + + std::vector> results(num_groups); + + tbb::enumerable_thread_specific> tls([&]() { + ThreadLocalState s; + s.vertex_old2new.assign(num_vertices, invalid()); + return s; + }); + + tbb::parallel_for(size_t{0}, num_groups, [&](size_t g) { + auto& local = tls.local(); + local.clear_group_state(); + + // ------------------------------------------------------------------- + // Phase 1: Extract group topology + // ------------------------------------------------------------------- + + auto& vertex_old2new = local.vertex_old2new; + const size_t begin = static_cast(group_offsets[g]); + const size_t end = static_cast(group_offsets[g + 1]); + const size_t num_facets_g = end - begin; + + local.facet_new2old.reserve(num_facets_g); + Index next_local_vertex = 0; + + for (size_t idx = begin; idx < end; ++idx) { + const Index fid = facet_indices[idx]; + local.facet_new2old.push_back(fid); + + auto f = mesh.get_facet_vertices(fid); + const Index fsize = static_cast(f.size()); + if (!is_regular) { + local.facet_sizes.push_back(fsize); + } + + const Index source_corner_begin = mesh.get_facet_corner_begin(fid); + + for (Index lv = 0; lv < fsize; ++lv) { + const Index source_vid = f[lv]; + if (vertex_old2new[source_vid] == invalid()) { + vertex_old2new[source_vid] = next_local_vertex++; + local.vertex_new2old.push_back(source_vid); + } + local.local_connectivity.push_back(vertex_old2new[source_vid]); + local.corner_new2old.push_back(source_corner_begin + lv); + } + } + + // Reset vertex_old2new (cost proportional to this group, not V). + for (const Index source_vid : local.vertex_new2old) { + vertex_old2new[source_vid] = invalid(); + } + + // ------------------------------------------------------------------- + // Phase 2: Build output mesh + // ------------------------------------------------------------------- + + const Index num_verts_g = static_cast(local.vertex_new2old.size()); + const Index num_facets_g_idx = static_cast(local.facet_new2old.size()); + + SurfaceMesh out(dim); + + // Add vertices and copy positions. + out.add_vertices(num_verts_g); + { + auto in_vertices = vertex_view(mesh); + auto out_vertices = vertex_ref(out); + for (Index i = 0; i < num_verts_g; ++i) { + out_vertices.row(i) = in_vertices.row(local.vertex_new2old[i]); + } + } + + // Add facets from pre-built local connectivity. + if (num_facets_g_idx > 0) { + if (is_regular) { + out.add_polygons(num_facets_g_idx, vertex_per_facet); + auto out_facets = facet_ref(out); + std::copy( + local.local_connectivity.begin(), + local.local_connectivity.end(), + out_facets.data()); + } else { + Index conn_offset = 0; + out.add_hybrid( + num_facets_g_idx, + [&](Index fi) { return local.facet_sizes[fi]; }, + [&](Index /*fi*/, span f) { + std::copy( + local.local_connectivity.data() + conn_offset, + local.local_connectivity.data() + conn_offset + + static_cast(f.size()), + f.begin()); + conn_offset += static_cast(f.size()); + }); + out.compress_if_regular(); + } + } + + // Create source mapping attributes. + if (!options.source_vertex_attr_name.empty()) { + out.template create_attribute( + options.source_vertex_attr_name, + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1, + {local.vertex_new2old.data(), static_cast(num_verts_g)}); + } + if (!options.source_facet_attr_name.empty()) { + out.template create_attribute( + options.source_facet_attr_name, + AttributeElement::Facet, + AttributeUsage::Scalar, + 1, + {local.facet_new2old.data(), static_cast(num_facets_g_idx)}); + } + + // Map attributes. + if (options.map_attributes) { + // Vertex attributes. + map_attributes( + mesh, + out, + {local.vertex_new2old.data(), local.vertex_new2old.size()}); + + // Facet attributes. + map_attributes( + mesh, + out, + {local.facet_new2old.data(), local.facet_new2old.size()}); + + // Corner attributes. + map_attributes( + mesh, + out, + {local.corner_new2old.data(), local.corner_new2old.size()}); + + // Indexed attributes: build compact per-group value tables. + // Scratch vectors live in TLS to amortize allocation across groups and attributes. + seq_foreach_named_attribute_read( + mesh, + [&](std::string_view name, auto&& attr) { + using AttributeType = std::decay_t; + using ValueType = typename AttributeType::ValueType; + if (out.attr_name_is_reserved(name)) return; + + const auto& src_values = attr.values(); + const auto& src_indices = attr.indices(); + const Index num_channels = static_cast(attr.get_num_channels()); + const Index num_corners_g = static_cast(local.corner_new2old.size()); + const size_t num_src_values = + static_cast(src_values.get_num_elements()); + + // Grow value_old2new lazily; reset only entries touched by the previous + // attribute (avoids O(total_indexed_values) clear per attribute per group). + if (local.value_old2new.size() < num_src_values) { + local.value_old2new.resize(num_src_values, invalid()); + } + local.value_new2old.clear(); + local.output_indices.resize(num_corners_g); + + for (Index ci = 0; ci < num_corners_g; ++ci) { + const Index src_ci = local.corner_new2old[ci]; + const Index src_val_id = src_indices.get(src_ci); + if (local.value_old2new[src_val_id] == invalid()) { + local.value_old2new[src_val_id] = + static_cast(local.value_new2old.size()); + local.value_new2old.push_back(src_val_id); + } + local.output_indices[ci] = local.value_old2new[src_val_id]; + } + + // Create indexed attribute with compact values. + auto id = out.template create_attribute( + name, + Indexed, + attr.get_usage(), + num_channels); + auto& target_attr = out.template ref_indexed_attribute(id); + + // Resize and fill compact values. + const Index num_compact_values = static_cast(local.value_new2old.size()); + target_attr.values().resize_elements(num_compact_values); + for (Index i = 0; i < num_compact_values; ++i) { + const Index src_val_id = local.value_new2old[i]; + for (Index ch = 0; ch < num_channels; ++ch) { + target_attr.values().ref(i, ch) = src_values.get(src_val_id, ch); + } + } + + // Fill output indices. + auto& target_indices = target_attr.indices(); + for (Index ci = 0; ci < num_corners_g; ++ci) { + target_indices.ref(ci) = local.output_indices[ci]; + } + + // Reset value_old2new for the next attribute + for (const Index old_id : local.value_new2old) { + local.value_old2new[old_id] = invalid(); + } + }); + + // Edge attributes: warn and skip, matching extract_submesh behavior. + { + bool has_edge_attr = false; + seq_foreach_attribute_read(mesh, [&](auto&&) { + has_edge_attr = true; + }); + if (has_edge_attr) { + logger().warn( + "`separate_by_facet_groups`: Edge attributes remapping is not supported."); + } + } + } + + results[g] = std::move(out); + }); + + return results; +} + +#define LA_X_extract_submeshes_by_group(_, Scalar, Index) \ + template LA_CORE_API std::vector> extract_submeshes_by_group( \ + const SurfaceMesh&, \ + size_t, \ + span, \ + span, \ + const SubmeshOptions&); + +LA_SURFACE_MESH_X(extract_submeshes_by_group, 0) + +} // namespace lagrange::internal diff --git a/modules/core/src/internal/extract_submeshes_by_group.h b/modules/core/src/internal/extract_submeshes_by_group.h new file mode 100644 index 00000000..6ca42bec --- /dev/null +++ b/modules/core/src/internal/extract_submeshes_by_group.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::internal { + +/// +/// Extract multiple submeshes defined by facet groups in a single linear pass. +/// +/// This avoids the O(G*V) cost of calling extract_submesh once per group. +/// +/// @note Users should call separate_by_facet_groups instead of this function directly. +/// +/// @param mesh Source mesh. +/// @param num_groups Number of groups. +/// @param facet_indices Source of all facet ids sorted by group. +/// @param group_offsets Array of size num_groups+1 such that +/// facet_indices[group_offsets[g]:group_offsets[g+1]] +/// is the range of facet indices for group g. +/// @param options Submesh extraction options. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return One SurfaceMesh per group. +/// +template +std::vector> extract_submeshes_by_group( + const SurfaceMesh& mesh, + size_t num_groups, + span facet_indices, + span group_offsets, + const SubmeshOptions& options); + +} // namespace lagrange::internal diff --git a/modules/core/src/internal/find_attribute_utils.cpp b/modules/core/src/internal/find_attribute_utils.cpp index 401285d6..4cc6ffa1 100644 --- a/modules/core/src/internal/find_attribute_utils.cpp +++ b/modules/core/src/internal/find_attribute_utils.cpp @@ -21,6 +21,7 @@ #include #include #include +#include namespace lagrange::internal { @@ -45,26 +46,26 @@ CheckAttributeResult check_attribute( { check_that( mesh.template is_attribute_type(id), - fmt::format("Attribute type should be {}", string_from_scalar())); + format("Attribute type should be {}", string_from_scalar())); { const auto& attr = mesh.get_attribute_base(id); check_that( attr.get_usage() == expected_usage, - fmt::format( + format( "Attribute usage should be {}, not {}", to_string(expected_usage), to_string(attr.get_usage()))); check_that( expected_element.test(attr.get_element_type()), - fmt::format( + format( "Attribute element type should be {}, not {}", to_string(expected_element), to_string(attr.get_element_type()))); if (expected_channels != 0) { check_that( attr.get_num_channels() == expected_channels, - fmt::format( + format( "Attribute should have {} channels, not {}", expected_channels, attr.get_num_channels())); diff --git a/modules/core/src/internal/get_uv_attribute.cpp b/modules/core/src/internal/get_uv_attribute.cpp index 0bb2f891..683b3527 100644 --- a/modules/core/src/internal/get_uv_attribute.cpp +++ b/modules/core/src/internal/get_uv_attribute.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace lagrange::internal { @@ -25,7 +26,8 @@ template AttributeId get_uv_id( const SurfaceMesh& mesh, std::string_view uv_attribute_name, - UVMeshOptions::ElementTypes element_types) + UVMeshOptions::ElementTypes element_types, + internal::TypeMismatchPolicy type_mismatch) { AttributeId uv_attr_id; if (uv_attribute_name.empty()) { @@ -67,10 +69,18 @@ AttributeId get_uv_id( } } else { uv_attr_id = mesh.get_attribute_id(uv_attribute_name); - const auto& attr = mesh.get_attribute_base(uv_attr_id); la_runtime_assert( - attr.get_value_type() == make_attribute_value_type(), - "UV attribute value type does not match the requested UVScalar type."); + uv_attr_id != invalid_attribute_id(), + format("Specified UV attribute does not exist: {}", uv_attribute_name)); + const auto& attr = mesh.get_attribute_base(uv_attr_id); + if (attr.get_value_type() != make_attribute_value_type()) { + if (type_mismatch == internal::TypeMismatchPolicy::Graceful) { + return invalid_attribute_id(); + } + la_runtime_assert( + false, + "UV attribute value type does not match the requested UVScalar type."); + } la_runtime_assert( attr.get_num_channels() == 2, "UV attribute must have exactly 2 channels."); @@ -141,7 +151,8 @@ std::tuple, VectorView> ref_uv_attribute( template LA_CORE_API AttributeId get_uv_id( \ const SurfaceMesh&, \ std::string_view, \ - UVMeshOptions::ElementTypes); \ + UVMeshOptions::ElementTypes, \ + TypeMismatchPolicy); \ template LA_CORE_API std::tuple, ConstVectorView> \ get_uv_attribute( \ const SurfaceMesh&, \ diff --git a/modules/core/src/internal/map_attributes.cpp b/modules/core/src/internal/map_attributes.cpp index 912da818..06be57c7 100644 --- a/modules/core/src/internal/map_attributes.cpp +++ b/modules/core/src/internal/map_attributes.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -173,10 +174,9 @@ void map_attributes( map_attribute_injective(name, std::forward(attr)); break; default: - throw Error( - fmt::format( - "Unsupported collision policy {}", - int(options.collision_policy_integral))); + throw Error(format( + "Unsupported collision policy {}", + int(options.collision_policy_integral))); } } }; diff --git a/modules/core/src/isoline.cpp b/modules/core/src/isoline.cpp index 41a982ac..f35a71e4 100644 --- a/modules/core/src/isoline.cpp +++ b/modules/core/src/isoline.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -286,10 +287,9 @@ SurfaceMesh isoline_internal( using ValueType = typename AttributeType::ValueType; if (!(attr.get_element_type() == AttributeElement::Vertex || attr.get_element_type() == AttributeElement::Indexed)) { - throw Error( - fmt::format( - "Isoline attribute element type should be Vertex or Indexed, not {}", - internal::to_string(attr.get_element_type()))); + throw Error(format( + "Isoline attribute element type should be Vertex or Indexed, not {}", + internal::to_string(attr.get_element_type()))); } if constexpr (AttributeType::IsIndexed) { if constexpr (std::is_same_v) { diff --git a/modules/core/src/map_attribute.cpp b/modules/core/src/map_attribute.cpp index 7f8600d1..9763465a 100644 --- a/modules/core/src/map_attribute.cpp +++ b/modules/core/src/map_attribute.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -302,12 +303,12 @@ AttributeId map_attribute_in_place( } else { std::string new_name; for (int cnt = 0; cnt < 1000; ++cnt) { - new_name = fmt::format("{}.{}", name, cnt); + new_name = format("{}.{}", name, cnt); if (!mesh.has_attribute(new_name)) { return new_name; } } - throw Error(fmt::format("Could not assign a unique attribute name for: {}", name)); + throw Error(format("Could not assign a unique attribute name for: {}", name)); } }; diff --git a/modules/core/src/mesh_cleanup/remove_short_edges.cpp b/modules/core/src/mesh_cleanup/remove_short_edges.cpp index 478b265b..937f451c 100644 --- a/modules/core/src/mesh_cleanup/remove_short_edges.cpp +++ b/modules/core/src/mesh_cleanup/remove_short_edges.cpp @@ -11,66 +11,430 @@ */ #include +#include #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 +#include #include namespace lagrange { +namespace { + +/// +/// Compute vertex importance for edge collapse decisions. +/// Boundary vertices are more important than interior vertices. +/// Among boundary vertices, those with larger boundary angles are more important. +/// Among interior vertices, those with larger max absolute dihedral angles are more important. +/// template -void remove_short_edges(SurfaceMesh& mesh, Scalar threshold) +AttributeId compute_vertex_importance_for_collapse( + SurfaceMesh& mesh, + AttributeId dihedral_id, + std::string_view output_attribute_name) { - DisjointSets clusters; - std::vector vertex_map; + mesh.initialize_edges(); + const Index num_vertices = mesh.get_num_vertices(); + + // Create output attribute + auto importance_id = mesh.template create_attribute( + output_attribute_name, + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1); + auto importance = attribute_vector_ref(mesh, importance_id); + + auto edge_dihedrals = attribute_vector_view(mesh, dihedral_id); + auto vertices = vertex_view(mesh); + + // Compute importance for each vertex + tbb::parallel_for(Index(0), num_vertices, [&](Index v) { + SmallVector boundary_edges; + + // Check if vertex is on boundary + bool on_boundary = false; + mesh.foreach_edge_around_vertex_with_duplicates(v, [&](Index e) { + if (mesh.is_boundary_edge(e)) { + boundary_edges.push_back(e); + on_boundary = true; + } + }); + + if (on_boundary) { + // Boundary vertex: importance = 1000 + boundary_edge_angle + + Scalar boundary_angle; + if (boundary_edges.size() == 2) { + // Get the other vertices of the two boundary edges + auto [e0_v0, e0_v1] = mesh.get_edge_vertices(boundary_edges[0]); + auto [e1_v0, e1_v1] = mesh.get_edge_vertices(boundary_edges[1]); + Index v_other0 = (e0_v0 == v) ? e0_v1 : e0_v0; + Index v_other1 = (e1_v0 == v) ? e1_v1 : e1_v0; + + // Compute angle between the two boundary edges + Eigen::Vector3 vec0 = vertices.row(v_other0) - vertices.row(v); + Eigen::Vector3 vec1 = vertices.row(v_other1) - vertices.row(v); + boundary_angle = angle_between(vec0, vec1); + } else { + // For vertices with != 2 boundary edges, use a default large angle + boundary_angle = Scalar(lagrange::internal::pi); + } + + importance[v] = Scalar(1000) + boundary_angle; + } else { + // Interior vertex: importance = sum_abs_dihedral + Scalar sum_abs_dihedral = 0; + mesh.foreach_edge_around_vertex_with_duplicates(v, [&](Index e) { + Scalar abs_dihedral = std::abs(edge_dihedrals[e]); + sum_abs_dihedral += abs_dihedral; + }); + + importance[v] = sum_abs_dihedral; + } + }); + + return importance_id; +} + +/// +/// Check whether collapsing an edge (removing @p remove, keeping @p keep) would rotate any +/// 1-ring facet's normal by more than the allowed threshold. +/// +/// @param mesh Input mesh (edges must be initialized). +/// @param keep Vertex to retain after the collapse. +/// @param remove Vertex to eliminate after the collapse. +/// @param cos_threshold cos(max_normal_deviation_angle). The collapse is rejected for a facet +/// when dot(n_before, n_after) < cos_threshold * |n_before| * |n_after|. +/// @param facet_normal_id Attribute id of the per-facet normal attribute. When valid, the +/// stored (unit) normals are used as n_before, giving well-defined +/// directions even for nearly-degenerate facets. When invalid, n_before +/// is computed on the fly via Newell's method. +/// +/// @returns true if the collapse is safe, false if it should be skipped. +/// +template +bool is_collapse_normal_valid( + const SurfaceMesh& mesh, + Index keep, + Index remove, + Scalar cos_threshold, + AttributeId facet_normal_id) +{ + auto vertices = vertex_view(mesh); + const Eigen::Vector3 keep_pos = vertices.row(keep); + bool valid = true; + + const bool use_normal_attr = facet_normal_id != invalid(); + + mesh.foreach_facet_around_vertex(remove, [&](Index fi) { + if (!valid) return; + const Index num_corners = mesh.get_facet_size(fi); + if (num_corners < 3) return; + + // Facets that also contain 'keep' become degenerate after the collapse and + // are cleaned up by remove_topologically_degenerate_facets — skip them. + for (Index lv = 0; lv < num_corners; ++lv) { + if (mesh.get_facet_vertex(fi, lv) == keep) return; + } + + // n_before: use the stored facet normal when available (well-defined even for + // nearly-degenerate geometry), otherwise fall back to Newell's method. + Eigen::Vector3 n_before; + if (use_normal_attr) { + n_before = attribute_matrix_view(mesh, facet_normal_id).row(fi); + } else { + n_before = Eigen::Vector3::Zero(); + for (Index lv = 0; lv < num_corners; ++lv) { + const Index va = mesh.get_facet_vertex(fi, lv); + const Index vb = mesh.get_facet_vertex(fi, (lv + 1) % num_corners); + n_before += Eigen::Vector3(vertices.row(va)) + .cross(Eigen::Vector3(vertices.row(vb))); + } + // Skip facets with no meaningful normal. + if (n_before.norm() < Scalar(1e-10)) return; + } + + // n_after: always simulated via Newell's method with 'remove' moved to keep_pos. + Eigen::Vector3 n_after = Eigen::Vector3::Zero(); + for (Index lv = 0; lv < num_corners; ++lv) { + const Index va = mesh.get_facet_vertex(fi, lv); + const Index vb = mesh.get_facet_vertex(fi, (lv + 1) % num_corners); + const Eigen::Vector3 a = + (va == remove) ? keep_pos : Eigen::Vector3(vertices.row(va)); + const Eigen::Vector3 b = + (vb == remove) ? keep_pos : Eigen::Vector3(vertices.row(vb)); + n_after += a.cross(b); + } + + const Scalar len_after = n_after.norm(); + // Skip facets that become degenerate after the collapse since their normal directions are + // not meaningful. + if (len_after < Scalar(1e-10)) return; + + // Reject if the normal rotates beyond the allowed angle. + // Written as a product to avoid normalizing the vectors. + const Scalar len_before = n_before.norm(); + if (n_before.dot(n_after) < cos_threshold * len_before * len_after) { + valid = false; + } + }); + + return valid; +} + +} // namespace + +template +void remove_short_edges(SurfaceMesh& mesh, const RemoveShortEdgesOptions& options) +{ + // Pre-allocate reusable buffers to avoid repeated allocations in inner loops + DisjointSets vertex_map; + std::vector vertex_processed; + std::vector + facet_touched; // tracks which facets already have one vertex updated this batch + std::vector> short_edges; // (length, edge_id) + std::vector index_map; + + AttributeId edge_length_id = invalid(); + AttributeId dihedral_id = invalid(); + AttributeId importance_id = invalid(); + AttributeId facet_normal_id = invalid(); + bool importance_existed = false; + std::string temp_importance_name; + + // Check if user-provided importance attribute exists + if (!options.vertex_importance_attribute_name.empty() && + mesh.has_attribute(options.vertex_importance_attribute_name)) { + importance_id = mesh.get_attribute_id(options.vertex_importance_attribute_name); + importance_existed = true; + + // Validate attribute + const auto& attr = mesh.get_attribute_base(importance_id); + if (attr.get_element_type() != AttributeElement::Vertex) { + throw std::runtime_error("Vertex importance attribute must be a per-vertex attribute"); + } + } else { + // Compute dihedral angles (reuses attribute if it exists) + DihedralAngleOptions dihedral_options; + dihedral_options.keep_facet_normals = false; + dihedral_id = compute_dihedral_angles(mesh, dihedral_options); + + if (options.vertex_importance_attribute_name.empty()) { + temp_importance_name = "@vertex_collapse_importance"; + } else { + temp_importance_name = std::string(options.vertex_importance_attribute_name); + } + + importance_id = + compute_vertex_importance_for_collapse(mesh, dihedral_id, temp_importance_name); + } + + const Scalar cos_normal_threshold = + static_cast(std::cos(options.max_normal_deviation_angle)); + + // Compute per-facet normals for the normal-flip guard (reuses attribute if it exists). + // Skipped when the guard is disabled (cos_threshold == -1, i.e. angle == pi). + if (cos_normal_threshold > Scalar(-1)) { + facet_normal_id = compute_facet_normal(mesh); + } + + // Ensure all temporary attributes are deleted on exit, regardless of how the loop terminates. + auto cleanup = make_scope_guard([&] { + if (edge_length_id != invalid()) { + mesh.delete_attribute(edge_length_id); + } + if (facet_normal_id != invalid()) { + mesh.delete_attribute(facet_normal_id); + } + if (dihedral_id != invalid()) { + mesh.delete_attribute(dihedral_id); + } + if (importance_id != invalid() && !importance_existed) { + mesh.delete_attribute(importance_id); + } + }); + while (true) { + mesh.initialize_edges(); const Index num_vertices = mesh.get_num_vertices(); + const Index num_edges = mesh.get_num_edges(); + + if (num_edges == 0) break; + + // Compute edge lengths (reuses attribute if it exists) + edge_length_id = compute_edge_lengths(mesh); + + // The facet normal should haven been propogated to the updated mesh. + if (cos_normal_threshold > Scalar(-1)) { + la_debug_assert(facet_normal_id != invalid()); + } + auto edge_lengths = attribute_vector_view(mesh, edge_length_id); + + // Find all short edges first + // Reserve capacity to avoid reallocations + short_edges.clear(); + short_edges.reserve(num_edges / 10); // Estimate ~10% short edges + for (Index eid = 0; eid < num_edges; eid++) { + if (edge_lengths[eid] <= static_cast(options.threshold)) { + short_edges.emplace_back(edge_lengths[eid], eid); + } + } + + if (short_edges.empty()) break; + + // Access importance values + auto vertex_importance = attribute_vector_view(mesh, importance_id); + + // Sort by length (ascending) - shorter edges have higher priority + tbb::parallel_sort(short_edges.begin(), short_edges.end()); + + // Process edges: mark vertices for collapse and build vertex mapping const Index num_facets = mesh.get_num_facets(); - auto vertices = vertex_view(mesh); - auto corner_vertex = mesh.get_corner_to_vertex().get_all(); - - clusters.init(num_vertices); - bool has_short_edges = false; - for (Index fi = 0; fi < num_facets; fi++) { - const Index c_begin = mesh.get_facet_corner_begin(fi); - const Index c_end = mesh.get_facet_corner_end(fi); - for (Index ci = c_begin; ci < c_end; ci++) { - Index cj = (ci + 1) == c_end ? c_begin : ci + 1; - auto v0 = corner_vertex[ci]; - auto v1 = corner_vertex[cj]; - if (v0 == v1) continue; - - Scalar l = (vertices.row(v0) - vertices.row(v1)).norm(); - if (l <= threshold) { - if (clusters.find(v0) == v0 && clusters.find(v1) == v1) { - has_short_edges = true; - clusters.merge(v0, v1); + vertex_processed.assign(num_vertices, false); + facet_touched.assign(num_facets, false); + vertex_map.init(num_vertices); + + bool has_collapse = false; + for (const auto& [length, eid] : short_edges) { + auto [v0, v1] = mesh.get_edge_vertices(eid); + + // Skip degenerate edges + if (v0 == v1) continue; + + // Only collapse if neither vertex has participated in a collapse this iteration + if (!vertex_processed[v0] && !vertex_processed[v1]) { + // Special case: both vertices on boundary, but edge is interior + // This would create a topologically invalid collapse, so skip it + bool edge_boundary = mesh.is_boundary_edge(eid); + if (!edge_boundary) { + // Check if both vertices are on boundary + bool v0_boundary = false; + bool v1_boundary = false; + mesh.foreach_edge_around_vertex_with_duplicates(v0, [&](Index e) { + if (mesh.is_boundary_edge(e)) v0_boundary = true; + }); + mesh.foreach_edge_around_vertex_with_duplicates(v1, [&](Index e) { + if (mesh.is_boundary_edge(e)) v1_boundary = true; + }); + + if (v0_boundary && v1_boundary) { + continue; // Skip this collapse } } + + // Use importance to decide which vertex to keep + Index keep, remove; + if (vertex_importance[v0] >= vertex_importance[v1]) { + keep = v0; + remove = v1; + } else { + keep = v1; + remove = v0; + } + + // Batch-conflict guard: skip if any surviving facet around 'remove' + // already has a vertex being updated by an earlier collapse this batch. + // This ensures at most one vertex update per facet per batch iteration, + // preventing combined flips invisible to the per-edge normal check. + bool has_facet_conflict = false; + mesh.foreach_facet_around_vertex(remove, [&](Index fi) { + if (has_facet_conflict) return; + // Facets that contain 'keep' become degenerate — skip them. + for (Index lv = 0; lv < mesh.get_facet_size(fi); ++lv) { + if (mesh.get_facet_vertex(fi, lv) == keep) return; + } + if (facet_touched[fi]) has_facet_conflict = true; + }); + if (has_facet_conflict) continue; + + // Normal-flip guard: skip the collapse if it would rotate any 1-ring + // facet's normal by more than max_normal_deviation_angle. + if (!is_collapse_normal_valid( + mesh, + keep, + remove, + cos_normal_threshold, + facet_normal_id)) { + continue; + } + + vertex_map.merge(keep, remove); + vertex_processed[v0] = true; + vertex_processed[v1] = true; + has_collapse = true; + + // Mark surviving facets around 'remove' as touched so no second + // collapse modifies them within this batch. + mesh.foreach_facet_around_vertex(remove, [&](Index fi) { + for (Index lv = 0; lv < mesh.get_facet_size(fi); ++lv) { + if (mesh.get_facet_vertex(fi, lv) == keep) return; + } + facet_touched[fi] = true; + }); } } - if (!has_short_edges) break; + if (!has_collapse) break; - vertex_map.resize(num_vertices); - clusters.extract_disjoint_set_indices(vertex_map); - remap_vertices(mesh, {vertex_map.data(), vertex_map.size()}); + // Update positions to precisely control the remapped vertex locations after collapse + auto vertices = vertex_ref(mesh); + for (Index vi = 0; vi < num_vertices; vi++) { + Index v_rep = vertex_map.find(vi); + vertices.row(vi) = vertices.row(v_rep); + } + + // Compact vertex mapping to ensure contiguous vertex IDs after collapse + index_map.assign(num_vertices, invalid()); + vertex_map.extract_disjoint_set_indices(index_map); + + // Batch collapse using remap_vertices + remap_vertices(mesh, {index_map.data(), index_map.size()}); } + + // Clean up topologically degenerate facets and isolated vertices remove_topologically_degenerate_facets(mesh); remove_isolated_vertices(mesh); } +template +void remove_short_edges(SurfaceMesh& mesh, Scalar threshold) +{ + RemoveShortEdgesOptions options; + options.threshold = static_cast(threshold); + remove_short_edges(mesh, options); +} + #define LA_X_remove_short_edges(_, Scalar, Index) \ template LA_CORE_API void remove_short_edges( \ SurfaceMesh&, \ - Scalar); + Scalar); \ + template LA_CORE_API void remove_short_edges( \ + SurfaceMesh&, \ + const RemoveShortEdgesOptions&); LA_SURFACE_MESH_X(remove_short_edges, 0) } // namespace lagrange diff --git a/modules/core/src/remap_vertices.cpp b/modules/core/src/remap_vertices.cpp index 6c2681da..42dfc2d9 100644 --- a/modules/core/src/remap_vertices.cpp +++ b/modules/core/src/remap_vertices.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -113,10 +114,9 @@ void remap_attribute( case MappingPolicy::KeepFirst: remap_keep_first(attr, new_to_old, num_out_elements); break; case MappingPolicy::Error: remap_injective(attr, new_to_old, num_out_elements); break; default: - throw Error( - fmt::format( - "Unsupported integer collision policy {}", - static_cast(options.collision_policy_integral))); + throw Error(format( + "Unsupported integer collision policy {}", + static_cast(options.collision_policy_integral))); } } else { switch (options.collision_policy_float) { @@ -124,10 +124,9 @@ void remap_attribute( case MappingPolicy::KeepFirst: remap_keep_first(attr, new_to_old, num_out_elements); break; case MappingPolicy::Error: remap_injective(attr, new_to_old, num_out_elements); break; default: - throw Error( - fmt::format( - "Unsupported float collision policy {}", - static_cast(options.collision_policy_float))); + throw Error(format( + "Unsupported float collision policy {}", + static_cast(options.collision_policy_float))); } } } diff --git a/modules/core/src/separate_by_facet_groups.cpp b/modules/core/src/separate_by_facet_groups.cpp index b2766b9c..6d4b1478 100644 --- a/modules/core/src/separate_by_facet_groups.cpp +++ b/modules/core/src/separate_by_facet_groups.cpp @@ -9,16 +9,11 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +#include "internal/extract_submeshes_by_group.h" + #include -#include #include -// clang-format off -#include -#include -#include -// clang-format on - #include #include #include @@ -49,18 +44,12 @@ std::vector> separate_by_facet_groups( group_offsets[0] = 0; la_debug_assert(group_offsets.back() == num_facets); - std::vector> results(num_groups); - - // Note: When extracting many small submeshes, this does not scale very well (does a pass over - // the whole mesh for each component to extract...). - SubmeshOptions submesh_options(options); - tbb::parallel_for((size_t)0, num_groups, [&](size_t i) { - span selected_facets( - facet_indices.data() + group_offsets[i], - static_cast(group_offsets[i + 1] - group_offsets[i])); - results[i] = extract_submesh(mesh, selected_facets, submesh_options); - }); - return results; + return internal::extract_submeshes_by_group( + mesh, + num_groups, + {facet_indices.data(), facet_indices.size()}, + {group_offsets.data(), group_offsets.size()}, + SubmeshOptions(options)); } template @@ -82,9 +71,10 @@ std::vector> separate_by_facet_groups( function_ref get_facet_group, const SeparateByFacetGroupsOptions& options) { - std::vector facet_group_indices(num_groups); - for (size_t i = 0; i < num_groups; i++) { - facet_group_indices[i] = get_facet_group(static_cast(i)); + const Index num_facets = mesh.get_num_facets(); + std::vector facet_group_indices(num_facets); + for (Index i = 0; i < num_facets; i++) { + facet_group_indices[i] = get_facet_group(i); } return separate_by_facet_groups( mesh, diff --git a/modules/core/src/thicken_and_close_mesh.cpp b/modules/core/src/thicken_and_close_mesh.cpp index e02c9615..3c1a8d5d 100644 --- a/modules/core/src/thicken_and_close_mesh.cpp +++ b/modules/core/src/thicken_and_close_mesh.cpp @@ -16,12 +16,13 @@ #include #include #include +#include +#include #include #include #include #include -#include namespace lagrange { @@ -257,7 +258,7 @@ void offset_values( for (Index is = 0; is + 1 < num_segments; ++is) { std::copy(src.begin(), src.end(), tmp_values.data() + is * nc); } - logger().debug("New values: {}", fmt::join(tmp_values, ", ")); + logger().debug("New values: {}", join(tmp_values, ", ")); values_.insert_elements(tmp_values); } } @@ -434,7 +435,7 @@ SurfaceMesh thicken_and_close_mesh( [&](Index f) { return mesh.get_facet_size(f); }, [&](Index c) { return mesh.get_next_corner_around_facet(c); }); } else { - throw Error(fmt::format("Attribute '{}' is not indexed.", name)); + throw Error(format("Attribute '{}' is not indexed.", name)); } }); } diff --git a/modules/core/src/transform_mesh.cpp b/modules/core/src/transform_mesh.cpp index 385ed777..0f7d1f5a 100644 --- a/modules/core/src/transform_mesh.cpp +++ b/modules/core/src/transform_mesh.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -27,74 +28,12 @@ // clang-format off #include #include -#include #include +#include // clang-format on namespace lagrange { -namespace { - -template -Scalar sub(const Eigen::Matrix4& matrix, int i1, int i2, int i3, int j1, int j2, int j3) -{ - return matrix(i1, j1) * (matrix(i2, j2) * matrix(i3, j3) - matrix(i2, j3) * matrix(i3, j2)); -} - -template -inline Scalar minor4x4(const Eigen::Matrix4& matrix) -{ - int i1 = (i == 0 ? 1 : 0); - int i2 = (i <= 1 ? 2 : 1); - int i3 = (i == 3 ? 2 : 3); - int j1 = (j == 0 ? 1 : 0); - int j2 = (j <= 1 ? 2 : 1); - int j3 = (j == 3 ? 2 : 3); - return sub(matrix, i1, i2, i3, j1, j2, j3) + sub(matrix, i2, i3, i1, j1, j2, j3) + - sub(matrix, i3, i1, i2, j1, j2, j3); -} - -template -Eigen::Matrix4 cofactor(const Eigen::Matrix4& matrix) -{ - Eigen::Matrix4 result; - result(0, 0) = minor4x4<0, 0>(matrix); - result(0, 1) = -minor4x4<0, 1>(matrix); - result(0, 2) = minor4x4<0, 2>(matrix); - result(0, 3) = -minor4x4<0, 3>(matrix); - result(2, 0) = minor4x4<2, 0>(matrix); - result(2, 1) = -minor4x4<2, 1>(matrix); - result(2, 2) = minor4x4<2, 2>(matrix); - result(2, 3) = -minor4x4<2, 3>(matrix); - result(1, 0) = -minor4x4<1, 0>(matrix); - result(1, 1) = minor4x4<1, 1>(matrix); - result(1, 2) = -minor4x4<1, 2>(matrix); - result(1, 3) = minor4x4<1, 3>(matrix); - result(3, 0) = -minor4x4<3, 0>(matrix); - result(3, 1) = minor4x4<3, 1>(matrix); - result(3, 2) = -minor4x4<3, 2>(matrix); - result(3, 3) = minor4x4<3, 3>(matrix); - return result; -} - -template -Eigen::Matrix3 compute_cotransform( - const Eigen::Transform& transform) -{ - return cofactor(transform.matrix()).template topLeftCorner<3, 3>(); -} - -template -Eigen::Matrix2 compute_cotransform( - const Eigen::Transform& transform) -{ - Eigen::Matrix4 matrix = Eigen::Matrix4::Identity(); - matrix.template topLeftCorner<3, 3>() = transform.matrix().template topLeftCorner<3, 3>(); - return cofactor(matrix).template topLeftCorner<2, 2>(); -} - -} // namespace - template void transform_mesh_internal( SurfaceMesh& mesh, @@ -104,7 +43,7 @@ void transform_mesh_internal( { la_runtime_assert(mesh.get_dimension() == Dimension, "Mesh dimension doesn't match transform"); - auto cotransform = compute_cotransform(transform); + auto cotransform = compute_normal_cotransform(transform); bool is_reflection = (transform.linear().determinant() < 0); @@ -180,11 +119,10 @@ void transform_mesh_internal( } else { type_name = internal::value_type_name(attr_read); } - throw Error( - fmt::format( - "Invalid attribute value type ({}) for attribute usage: {}", - type_name, - internal::to_string(attr_read.get_usage()))); + throw Error(format( + "Invalid attribute value type ({}) for attribute usage: {}", + type_name, + internal::to_string(attr_read.get_usage()))); } }); diff --git a/modules/core/src/triangulate_polygonal_facets.cpp b/modules/core/src/triangulate_polygonal_facets.cpp index 89ae0833..4fce7398 100644 --- a/modules/core/src/triangulate_polygonal_facets.cpp +++ b/modules/core/src/triangulate_polygonal_facets.cpp @@ -161,7 +161,10 @@ void append_triangles_from_polygon( } template -void triangulate_polygonal_facets_earcut(SurfaceMesh& mesh) +void triangulate_polygonal_facets_earcut( + SurfaceMesh& mesh, + bool preserve_edges, + bool preserve_points) { LAGRANGE_ZONE_SCOPED; @@ -187,8 +190,14 @@ void triangulate_polygonal_facets_earcut(SurfaceMesh& mesh) if (facet_size != 3) { need_removal = true; } - if (facet_size <= 2) { - to_remove[f] = true; + if (facet_size == 1) { + if (!preserve_points) { + to_remove[f] = true; + } + } else if (facet_size == 2) { + if (!preserve_edges) { + to_remove[f] = true; + } } else if (facet_size == 4) { // Triangulate quad to_remove[f] = true; @@ -299,7 +308,10 @@ void triangulate_polygonal_facets_earcut(SurfaceMesh& mesh) } template -void triangulate_polygonal_facets_centroid_fan(SurfaceMesh& mesh) +void triangulate_polygonal_facets_centroid_fan( + SurfaceMesh& mesh, + bool preserve_edges, + bool preserve_points) { if (mesh.is_triangle_mesh()) { return; @@ -321,7 +333,8 @@ void triangulate_polygonal_facets_centroid_fan(SurfaceMesh& mesh) for (Index fid = 0; fid < old_num_facets; ++fid) { const auto facet_size = mesh.get_facet_size(fid); - if (facet_size != 3) { + if (facet_size != 3 && !(preserve_edges && facet_size == 2) && + !(preserve_points && facet_size == 1)) { auto f = mesh.get_facet_vertices(fid); facets_to_remove.push_back(fid); @@ -522,9 +535,14 @@ void triangulate_polygonal_facets( const TriangulationOptions& options) { switch (options.scheme) { - case TriangulationOptions::Scheme::Earcut: triangulate_polygonal_facets_earcut(mesh); break; + case TriangulationOptions::Scheme::Earcut: + triangulate_polygonal_facets_earcut(mesh, options.preserve_edges, options.preserve_points); + break; case TriangulationOptions::Scheme::CentroidFan: - triangulate_polygonal_facets_centroid_fan(mesh); + triangulate_polygonal_facets_centroid_fan( + mesh, + options.preserve_edges, + options.preserve_points); break; } } diff --git a/modules/core/src/unify_index_buffer.cpp b/modules/core/src/unify_index_buffer.cpp index f2007149..cae5f31f 100644 --- a/modules/core/src/unify_index_buffer.cpp +++ b/modules/core/src/unify_index_buffer.cpp @@ -222,7 +222,7 @@ SurfaceMesh unify_index_buffer( attr.get_num_channels()); auto& out_attr = output_mesh.template ref_attribute(name); - out_attr.resize_elements(num_unique_corners); + out_attr.resize_elements(num_unique_corners + num_isolated_vertices); for (auto i : range(num_unique_corners)) { auto cid = corner_groups[corner_group_indices[i]]; @@ -230,6 +230,8 @@ SurfaceMesh unify_index_buffer( auto target_value = out_attr.ref_row(i); std::copy(source_value.begin(), source_value.end(), target_value.begin()); } + // Note: isolated vertices are left at the default value since they + // have no corners to source indexed attribute values from. } else { // Copy over unselected index attribute. logger().debug( diff --git a/modules/core/src/utils/DisjointSets.cpp b/modules/core/src/utils/DisjointSets.cpp index 7ccef844..4d796827 100644 --- a/modules/core/src/utils/DisjointSets.cpp +++ b/modules/core/src/utils/DisjointSets.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -74,7 +75,7 @@ size_t DisjointSets::extract_disjoint_set_indices(span ind const size_t num_entries = size(); la_runtime_assert( index_map.size() >= num_entries, - fmt::format("Index map must be large enough to hold {} entries!", num_entries)); + format("Index map must be large enough to hold {} entries!", num_entries)); constexpr IndexType invalid_index = invalid(); std::fill(index_map.begin(), index_map.end(), invalid_index); IndexType counter = 0; diff --git a/modules/core/src/utils/assert.cpp b/modules/core/src/utils/assert.cpp index 97b5ff5f..2cc13f06 100644 --- a/modules/core/src/utils/assert.cpp +++ b/modules/core/src/utils/assert.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #if !defined(LA_ASSERT_DEBUG_BREAK) @@ -78,7 +79,7 @@ bool assertion_failed( std::string_view message) { // Insert a breakpoint programmatically to automatically step into the debugger - auto msg = fmt::format( + auto msg = format( "Assertion failed: \"{}\"{}{}\n" "\tIn file: {}, line {};\n" "\tIn function: {};", diff --git a/modules/core/src/utils/triangle_triangle_intersection.cpp b/modules/core/src/utils/triangle_triangle_intersection.cpp new file mode 100644 index 00000000..38a25938 --- /dev/null +++ b/modules/core/src/utils/triangle_triangle_intersection.cpp @@ -0,0 +1,722 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include + +#include +#include + +namespace lagrange { + +namespace internal { + +namespace { + +/// Check if the 2D triangle (a,b,c) is degenerate +inline bool is_degenerate_2d( + const double a[2], + const double b[2], + const double c[2], + const ExactPredicatesShewchuk& pred) +{ + return pred.orient2D(a, b, c) == 0; +} + +/// Check if the 3D triangle (a,b,c) is degenerate +bool is_degenerate_3d( + const double a[3], + const double b[3], + const double c[3], + const ExactPredicatesShewchuk& pred) +{ + double a_xy[2] = {a[0], a[1]}, b_xy[2] = {b[0], b[1]}, c_xy[2] = {c[0], c[1]}; + double a_yz[2] = {a[1], a[2]}, b_yz[2] = {b[1], b[2]}, c_yz[2] = {c[1], c[2]}; + double a_zx[2] = {a[2], a[0]}, b_zx[2] = {b[2], b[0]}, c_zx[2] = {c[2], c[0]}; + + auto o_xy = pred.orient2D(a_xy, b_xy, c_xy); + auto o_yz = pred.orient2D(a_yz, b_yz, c_yz); + auto o_zx = pred.orient2D(a_zx, b_zx, c_zx); + + return o_xy == 0 && o_yz == 0 && o_zx == 0; +} + +/// Compute the dominant axis for projection in the coplanar case, based on the bounding box of the +/// two triangles. +/// +/// @note The bbox diagonal computation is not exact, but numerical error here has minimal impact on +/// the correctness of the algorithm. When two axis have very close bbox diagonals, the choice of +/// projection axis using either one should be fine. +int coplanar_projection_axis(const double t1[3][3], const double t2[3][3]) +{ + double bbox_min[3] = {t1[0][0], t1[0][1], t1[0][2]}; + double bbox_max[3] = {t1[0][0], t1[0][1], t1[0][2]}; + for (int i = 0; i < 3; ++i) { + for (int k = 0; k < 3; ++k) { + bbox_min[k] = std::min(bbox_min[k], t1[i][k]); + bbox_max[k] = std::max(bbox_max[k], t1[i][k]); + bbox_min[k] = std::min(bbox_min[k], t2[i][k]); + bbox_max[k] = std::max(bbox_max[k], t2[i][k]); + } + } + double bbox_diag[3] = { + bbox_max[0] - bbox_min[0], + bbox_max[1] - bbox_min[1], + bbox_max[2] - bbox_min[2]}; + int seg_axis = 0; + if (bbox_diag[1] < bbox_diag[seg_axis]) seg_axis = 1; + if (bbox_diag[2] < bbox_diag[seg_axis]) seg_axis = 2; + return seg_axis; +} + +/// +/// Check if two 2D segments [a0,a1] and [b0,b1] intersect. +/// Orientation decisions use exact predicates; the collinear-overlap check uses +/// standard floating-point interval arithmetic. +/// +/// @param include_boundary If true, touching at endpoints/boundaries counts as intersection. +/// If false, only proper interior crossings count. +/// +bool segments_intersect_2d( + const double a0[2], + const double a1[2], + const double b0[2], + const double b1[2], + const ExactPredicatesShewchuk& pred, + bool include_boundary) +{ + // Check if segments intersect using orientation tests + short o1 = pred.orient2D(a0, a1, b0); + short o2 = pred.orient2D(a0, a1, b1); + short o3 = pred.orient2D(b0, b1, a0); + short o4 = pred.orient2D(b0, b1, a1); + + // Case 1: Proper crossing - segments cross in their interiors + // Both endpoints of each segment are on opposite sides of the other segment + if (o1 * o2 < 0 && o3 * o4 < 0) { + return true; + } + + // Case 2: Collinear segments - need to check if they actually overlap + if (o1 == 0 && o2 == 0 && o3 == 0 && o4 == 0) { + // All four orientations are zero - segments are collinear + // Need to check if they overlap on the line + + // Pick the axis with the largest bounding-box extent over all 4 points + double extent0 = + std::max({a0[0], a1[0], b0[0], b1[0]}) - std::min({a0[0], a1[0], b0[0], b1[0]}); + double extent1 = + std::max({a0[1], a1[1], b0[1], b1[1]}) - std::min({a0[1], a1[1], b0[1], b1[1]}); + int axis = (extent1 > extent0) ? 1 : 0; + + // Get intervals for both segments on the dominant axis + double a_min = std::min(a0[axis], a1[axis]); + double a_max = std::max(a0[axis], a1[axis]); + double b_min = std::min(b0[axis], b1[axis]); + double b_max = std::max(b0[axis], b1[axis]); + + // Check for overlap + if (include_boundary) { + // Touching at endpoints counts as intersection + return !(a_max < b_min || b_max < a_min); + } else { + // Only interior overlap counts + return std::max(a_min, b_min) < std::min(a_max, b_max); + } + } + + // Case 3: One or more points on the supporting line of the other segment + // Need to check if the point actually lies on the segment (not just the line) + + // Helper: check if point p lies on segment [a, b] (when already known to be collinear) + auto point_on_segment = [](const double p[2], const double a[2], const double b[2]) -> bool { + // For collinear points, p is on segment [a,b] if it's between a and b + // Use coordinate-wise check + bool x_between = (p[0] >= std::min(a[0], b[0]) && p[0] <= std::max(a[0], b[0])); + bool y_between = (p[1] >= std::min(a[1], b[1]) && p[1] <= std::max(a[1], b[1])); + return x_between && y_between; + }; + + // Helper: check if point p is strictly inside segment [a, b] (not at endpoints) + auto point_strictly_inside_segment = + [](const double p[2], const double a[2], const double b[2]) -> bool { + // Point must not equal either endpoint + bool not_a = (p[0] != a[0] || p[1] != a[1]); + bool not_b = (p[0] != b[0] || p[1] != b[1]); + if (!not_a || !not_b) return false; + + // Point must be strictly between endpoints on both axes + bool x_strictly_between = (p[0] > std::min(a[0], b[0]) && p[0] < std::max(a[0], b[0])); + bool y_strictly_between = (p[1] > std::min(a[1], b[1]) && p[1] < std::max(a[1], b[1])); + + // Handle degenerate cases (segment is a point or parallel to axis) + bool x_degenerate = (a[0] == b[0]); + bool y_degenerate = (a[1] == b[1]); + + if (x_degenerate && y_degenerate) return false; // Segment is a point + if (x_degenerate) return y_strictly_between && (p[0] == a[0]); + if (y_degenerate) return x_strictly_between && (p[1] == a[1]); + + return x_strictly_between && y_strictly_between; + }; + + // Check which endpoints are on which segments + bool b0_on_a = (o1 == 0) && point_on_segment(b0, a0, a1); + bool b1_on_a = (o2 == 0) && point_on_segment(b1, a0, a1); + bool a0_on_b = (o3 == 0) && point_on_segment(a0, b0, b1); + bool a1_on_b = (o4 == 0) && point_on_segment(a1, b0, b1); + + // Check for nested segments + // Segment B is nested in A if both endpoints of B are on segment A + bool b_nested_in_a = b0_on_a && b1_on_a; + // Segment A is nested in B if both endpoints of A are on segment B + bool a_nested_in_b = a0_on_b && a1_on_b; + + if (b_nested_in_a || a_nested_in_b) { + // One segment is completely nested in the other + // This counts as intersection even in strict mode + return true; + } + + // Check for any actual contact + bool has_contact = b0_on_a || b1_on_a || a0_on_b || a1_on_b; + + if (!has_contact) { + // No actual contact - points are on supporting lines but outside segments + return false; + } + + // Case 4: There is actual contact (point on segment) but not nested + if (!include_boundary) { + // Strict mode: only count if one endpoint is strictly inside the other segment + bool b0_strictly_inside_a = (o1 == 0) && point_strictly_inside_segment(b0, a0, a1); + bool b1_strictly_inside_a = (o2 == 0) && point_strictly_inside_segment(b1, a0, a1); + bool a0_strictly_inside_b = (o3 == 0) && point_strictly_inside_segment(a0, b0, b1); + bool a1_strictly_inside_b = (o4 == 0) && point_strictly_inside_segment(a1, b0, b1); + + return b0_strictly_inside_a || b1_strictly_inside_a || a0_strictly_inside_b || + a1_strictly_inside_b; + } + + // include_boundary = true: contact counts as intersection + return true; +} + +/// +/// Check if a segment [a, b] intersects a triangle [t0, t1, t2] when all points are +/// known to be coplanar. Projects to 2D using the triangle's normal for the orientation tests. +/// +bool segment_intersects_triangle_coplanar( + const double* a, + const double* b, + const double* t0, + const double* t1, + const double* t2, + const ExactPredicatesShewchuk& pred, + bool include_boundary) +{ + // Pick projection axis from the bbox of all 5 input points (segment endpoints + + // triangle vertices). Using the triangle's normal here would fail when the triangle is + // degenerate (zero normal) or near-degenerate along an axis that actually separates the + // segment and triangle in 3D — the smallest-extent axis would be discarded by the + // projection, causing false positives. + double t_seg[3][3] = {{a[0], a[1], a[2]}, {b[0], b[1], b[2]}, {b[0], b[1], b[2]}}; + double t_tri[3][3] = {{t0[0], t0[1], t0[2]}, {t1[0], t1[1], t1[2]}, {t2[0], t2[1], t2[2]}}; + int axis = coplanar_projection_axis(t_seg, t_tri); + int i0 = (axis + 1) % 3; + int i1 = (axis + 2) % 3; + + // When T is degenerate (collinear), the caller's coplanarity precondition (d_a==d_b==0 + // from orient3D) is vacuous: orient3D against a collinear triangle returns 0 for any + // 4th point. Verify true 3D coplanarity explicitly; if (a,b) is skew to T's line, no + // intersection. If coplanar, reduce to 2D segment-segment against each T edge — the + // point_location/edge-crossing logic below would otherwise misreport a point on T's + // supporting line (but outside T's span) as a boundary hit. + if (is_degenerate_3d(t0, t1, t2, pred)) { + if (pred.orient3D(a, b, t0, t1) != 0 || pred.orient3D(a, b, t1, t2) != 0 || + pred.orient3D(a, b, t0, t2) != 0) { + return false; + } + double a2_d[2] = {a[i0], a[i1]}; + double b2_d[2] = {b[i0], b[i1]}; + double t_pts[3][2] = {{t0[i0], t0[i1]}, {t1[i0], t1[i1]}, {t2[i0], t2[i1]}}; + for (int i = 0; i < 3; ++i) { + if (segments_intersect_2d( + a2_d, + b2_d, + t_pts[i], + t_pts[(i + 1) % 3], + pred, + include_boundary)) { + return true; + } + } + return false; + } + + double a2[2] = {a[i0], a[i1]}; + double b2[2] = {b[i0], b[i1]}; + double t0_2[2] = {t0[i0], t0[i1]}; + double t1_2[2] = {t1[i0], t1[i1]}; + double t2_2[2] = {t2[i0], t2[i1]}; + + // Classify a 2D point relative to the triangle. + // Returns 1 = strictly interior, 0 = on boundary, -1 = outside. + auto point_location = [&](double p[2]) -> int { + short o1 = pred.orient2D(t0_2, t1_2, p); + short o2 = pred.orient2D(t1_2, t2_2, p); + short o3 = pred.orient2D(t2_2, t0_2, p); + bool inside = (o1 >= 0 && o2 >= 0 && o3 >= 0) || (o1 <= 0 && o2 <= 0 && o3 <= 0); + if (!inside) return -1; + return (o1 != 0 && o2 != 0 && o3 != 0) ? 1 : 0; + }; + + // Check if either endpoint is inside (or on the boundary of) the triangle + int a_loc = point_location(a2); + int b_loc = point_location(b2); + if (include_boundary) { + if (a_loc >= 0 || b_loc >= 0) return true; + } else { + if (a_loc == 1 || b_loc == 1) return true; + } + + // Check if the segment crosses any edge of the triangle. + // In strict mode, skip collinear pairs so that a shared edge (or partial overlap + // along a triangle edge) does not count as an intersection. + double* tri_verts[3] = {t0_2, t1_2, t2_2}; + for (int i = 0; i < 3; ++i) { + double* ea = tri_verts[i]; + double* eb = tri_verts[(i + 1) % 3]; + + if (!include_boundary) { + short co1 = pred.orient2D(a2, b2, ea); + short co2 = pred.orient2D(a2, b2, eb); + short co3 = pred.orient2D(ea, eb, a2); + short co4 = pred.orient2D(ea, eb, b2); + if (co1 == 0 && co2 == 0 && co3 == 0 && co4 == 0) continue; + } + + if (segments_intersect_2d(a2, b2, ea, eb, pred, include_boundary)) { + return true; + } + } + + return false; +} + +/// +/// Check if triangles intersect when coplanar using 2D projection. +/// +bool coplanar_triangles_intersect( + double t1[3][3], + double t2[3][3], + const ExactPredicatesShewchuk& pred, + bool include_boundary) +{ + // When t1 is degenerate (collinear/coincident vertices, zero-area), handle it early. + // A degenerate t1 has no interior, so strict mode never intersects. + // In boundary mode, reduce to: segment-vs-triangle (if t2 is non-degenerate) or + // segment-vs-segment (if both are degenerate). Both are done with the existing + // segment_intersects_triangle_coplanar / segments_intersect_2d helpers. + if (is_degenerate_3d(t1[0], t1[1], t1[2], pred)) { + if (!include_boundary) return false; + + if (!is_degenerate_3d(t2[0], t2[1], t2[2], pred)) { + // t2 is non-degenerate: check each edge of degenerate t1 against t2 + for (int i = 0; i < 3; ++i) { + if (segment_intersects_triangle_coplanar( + t1[i], + t1[(i + 1) % 3], + t2[0], + t2[1], + t2[2], + pred, + include_boundary)) + return true; + } + return false; + } + + // Both degenerate: segment-segment check. + int seg_axis = coplanar_projection_axis(t1, t2); + int si0 = (seg_axis + 1) % 3, si1 = (seg_axis + 2) % 3; + for (int i = 0; i < 3; ++i) { + double a0[2] = {t1[i][si0], t1[i][si1]}; + double a1[2] = {t1[(i + 1) % 3][si0], t1[(i + 1) % 3][si1]}; + for (int j = 0; j < 3; ++j) { + double b0[2] = {t2[j][si0], t2[j][si1]}; + double b1[2] = {t2[(j + 1) % 3][si0], t2[(j + 1) % 3][si1]}; + if (segments_intersect_2d(a0, a1, b0, b1, pred, include_boundary)) return true; + } + } + return false; + } + + int axis = coplanar_projection_axis(t1, t2); + int i0 = (axis + 1) % 3; + int i1 = (axis + 2) % 3; + + // Project triangles to 2D + double t1_2d[3][2], t2_2d[3][2]; + for (int i = 0; i < 3; ++i) { + t1_2d[i][0] = t1[i][i0]; + t1_2d[i][1] = t1[i][i1]; + t2_2d[i][0] = t2[i][i0]; + t2_2d[i][1] = t2[i][i1]; + } + + // Check if any vertex of t2 is inside t1 + for (int i = 0; i < 3; ++i) { + short o1 = pred.orient2D(t1_2d[0], t1_2d[1], t2_2d[i]); + short o2 = pred.orient2D(t1_2d[1], t1_2d[2], t2_2d[i]); + short o3 = pred.orient2D(t1_2d[2], t1_2d[0], t2_2d[i]); + + if (include_boundary) { + // Boundary mode: point inside or on boundary + if ((o1 >= 0 && o2 >= 0 && o3 >= 0) || (o1 <= 0 && o2 <= 0 && o3 <= 0)) { + return true; + } + } else { + // Strict mode: point must be in interior (not on boundary) + if ((o1 > 0 && o2 > 0 && o3 > 0) || (o1 < 0 && o2 < 0 && o3 < 0)) { + return true; + } + } + } + + // Check if any vertex of t1 is inside t2 + for (int i = 0; i < 3; ++i) { + short o1 = pred.orient2D(t2_2d[0], t2_2d[1], t1_2d[i]); + short o2 = pred.orient2D(t2_2d[1], t2_2d[2], t1_2d[i]); + short o3 = pred.orient2D(t2_2d[2], t2_2d[0], t1_2d[i]); + + if (include_boundary) { + // Boundary mode: point inside or on boundary + if ((o1 >= 0 && o2 >= 0 && o3 >= 0) || (o1 <= 0 && o2 <= 0 && o3 <= 0)) { + return true; + } + } else { + // Strict mode: point must be in interior (not on boundary) + if ((o1 > 0 && o2 > 0 && o3 > 0) || (o1 < 0 && o2 < 0 && o3 < 0)) { + return true; + } + } + } + + // Check if any edges intersect + // Skip collinear edge pairs (e.g., when triangles share an edge) + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + // Get edge endpoints + const double* e1_a = t1_2d[i]; + const double* e1_b = t1_2d[(i + 1) % 3]; + const double* e2_a = t2_2d[j]; + const double* e2_b = t2_2d[(j + 1) % 3]; + + if (!include_boundary) { + // Check if edges are collinear (all 4 cross-orientation tests return 0) + short o1 = pred.orient2D(e1_a, e1_b, e2_a); + short o2 = pred.orient2D(e1_a, e1_b, e2_b); + short o3 = pred.orient2D(e2_a, e2_b, e1_a); + short o4 = pred.orient2D(e2_a, e2_b, e1_b); + + bool edges_collinear = (o1 == 0 && o2 == 0 && o3 == 0 && o4 == 0); + + // Skip collinear edge pairs ONLY when include_boundary=false + if (edges_collinear) { + continue; + } + } + + // Test edge pair + if (segments_intersect_2d(e1_a, e1_b, e2_a, e2_b, pred, include_boundary)) { + return true; + } + } + } + + // Nesting / duplication check (strict mode only): + // The vertex-in-triangle tests above only detect strict interior containment, and the + // collinear-edge skip can prevent crossing detection for identical or nested triangles. + // If a non-degenerate triangle's vertices are all non-outside the other triangle + // (on boundary or inside), their interiors overlap — e.g. duplicate triangles or one + // triangle fully enclosing the other with its vertices landing on the boundary. + // The non-degeneracy guard prevents false positives when a degenerate (zero-area) + // triangle's coincident vertices happen to fall inside the other triangle. + if (!include_boundary) { + auto is_nondegenerate = [&](const double tri[3][2]) { + return !is_degenerate_2d(tri[0], tri[1], tri[2], pred); + }; + auto all_inside_or_on = [&](const double verts[3][2], const double tri[3][2]) { + for (int i = 0; i < 3; ++i) { + short o1 = pred.orient2D(tri[0], tri[1], verts[i]); + short o2 = pred.orient2D(tri[1], tri[2], verts[i]); + short o3 = pred.orient2D(tri[2], tri[0], verts[i]); + bool non_outside = + (o1 >= 0 && o2 >= 0 && o3 >= 0) || (o1 <= 0 && o2 <= 0 && o3 <= 0); + if (!non_outside) return false; + } + return true; + }; + if ((is_nondegenerate(t2_2d) && all_inside_or_on(t2_2d, t1_2d)) || + (is_nondegenerate(t1_2d) && all_inside_or_on(t1_2d, t2_2d))) + return true; + } + + return false; +} + +} // anonymous namespace + +bool triangle_triangle_intersection_3d( + double t1_v0[3], + double t1_v1[3], + double t1_v2[3], + double t2_v0[3], + double t2_v1[3], + double t2_v2[3], + bool include_boundary) +{ + ExactPredicatesShewchuk pred; + + // Compute signed distances of triangle 2 vertices from the plane of triangle 1 + short d2_v0 = pred.orient3D(t1_v0, t1_v1, t1_v2, t2_v0); + short d2_v1 = pred.orient3D(t1_v0, t1_v1, t1_v2, t2_v1); + short d2_v2 = pred.orient3D(t1_v0, t1_v1, t1_v2, t2_v2); + + // Quick exit: t2 entirely on one side of t1's plane + if (d2_v0 == d2_v1 && d2_v1 == d2_v2 && d2_v0 != 0) { + return false; + } + + // Compute signed distances of triangle 1 vertices from the plane of triangle 2 + short d1_v0 = pred.orient3D(t2_v0, t2_v1, t2_v2, t1_v0); + short d1_v1 = pred.orient3D(t2_v0, t2_v1, t2_v2, t1_v1); + short d1_v2 = pred.orient3D(t2_v0, t2_v1, t2_v2, t1_v2); + + // Quick exit: t1 entirely on one side of t2's plane + if (d1_v0 == d1_v1 && d1_v1 == d1_v2 && d1_v0 != 0) { + return false; + } + + // Handle all-zero d2_v*: either truly coplanar or t1 is degenerate (collinear/coincident). + // When t1 is degenerate, orient3D always returns 0 for any 4th point, so d2_v* == 0 + // even when t2 is not in t1's plane. Verify true coplanarity by requiring d1_v* also + // all zero (non-degenerate t1 and t2 sharing a plane implies t1 in t2's plane too). + if ((d2_v0 == 0 && d2_v1 == 0 && d2_v2 == 0) && (d1_v0 == 0 && d1_v1 == 0 && d1_v2 == 0)) { + // Both d* sets are all-zero. This means either: + // (a) the triangles are truly coplanar, or + // (b) both are degenerate — orient3D returns 0 for any 4th point when the first + // three are collinear, so non-coplanar (skew) degenerate pairs reach here too. + // Disambiguate: if both are degenerate, run explicit coplanarity checks using mixed + // orient3D calls (one vertex pair from each triangle). Any non-zero result means the + // segments are skew and share no point — return false for both modes. + if (is_degenerate_3d(t1_v0, t1_v1, t1_v2, pred) && + is_degenerate_3d(t2_v0, t2_v1, t2_v2, pred)) { + bool coplanar = pred.orient3D(t1_v0, t1_v1, t2_v0, t2_v1) == 0 && + pred.orient3D(t1_v0, t1_v2, t2_v0, t2_v1) == 0 && + pred.orient3D(t1_v0, t1_v1, t2_v0, t2_v2) == 0; + if (!coplanar) return false; + } + + // Truly coplanar: check 2D intersection + double t1[3][3] = { + {t1_v0[0], t1_v0[1], t1_v0[2]}, + {t1_v1[0], t1_v1[1], t1_v1[2]}, + {t1_v2[0], t1_v2[1], t1_v2[2]}}; + double t2[3][3] = { + {t2_v0[0], t2_v0[1], t2_v0[2]}, + {t2_v1[0], t2_v1[1], t2_v1[2]}, + {t2_v2[0], t2_v2[1], t2_v2[2]}}; + return coplanar_triangles_intersect(t1, t2, pred, include_boundary); + } else if (!include_boundary) { + if (d2_v0 == 0 && d2_v1 == 0 && d2_v2 == 0) { + // t1 is degenerate (collinear). It has no interior, so no strict intersection. + return false; + } + if (d1_v0 == 0 && d1_v1 == 0 && d1_v2 == 0) { + // t2 is degenerate (collinear). It has no interior, so no strict intersection. + return false; + } + // When neither triangle is degenerate or boundary=ON: + // fall through to edge-based checks to detect boundary contact. + } + + // Algorithm: For each edge of T1, check if it intersects T2 + // And vice versa for T2's edges with T1 + + // Helper function: test if edge (a, b) intersects triangle T using orient3D + // Track boundary contact types across all edge tests to detect the case where + // the intersection segment has both endpoints on triangle boundaries. + // has_vertex_contact: some edge passed through a triangle vertex (zeros==2) + // has_genuine_edge_contact: some straddling edge hit a triangle edge interior + // (zeros==1, d_a*d_b<0, o-check confirmed inside) + bool has_vertex_contact = false; + bool has_genuine_edge_contact = false; + + auto edge_intersects_triangle = + [&pred, include_boundary, &has_vertex_contact, &has_genuine_edge_contact]( + const double* a, + const double* b, + const double* t0, + const double* t1, + const double* t2, + short d_a, + short d_b) -> bool { + // If both endpoints are on the same side of T's plane, no intersection + if (d_a * d_b > 0) return false; + + // If both endpoints are on the plane, test the segment against the triangle in 2D + if (d_a == 0 && d_b == 0) { + return segment_intersects_triangle_coplanar(a, b, t0, t1, t2, pred, include_boundary); + } + + // If one or both endpoints are on the plane, or they straddle the plane: + // Test which side of each triangle edge (in 3D) the segment (a,b) lies on. + // Each orient3D orients the tetrahedron formed by (a,b) and one directed edge of T. + // Counting zeros classifies the contact point: + // zeros == 0 => interior-to-interior crossing (all signs identical) + // zeros == 1 => landing on a triangle edge interior (boundary contact) + // zeros == 2 => landing on a triangle vertex (boundary contact) + // zeros == 3 => impossible: all-coplanar case handled above + + short o1 = pred.orient3D(a, b, t0, t1); + short o2 = pred.orient3D(a, b, t1, t2); + short o3 = pred.orient3D(a, b, t2, t0); + + // Count zeros + int zeros = (o1 == 0) + (o2 == 0) + (o3 == 0); + + if (zeros == 3) { + // Unreachable: the all-coplanar (d_a==0 && d_b==0) case is handled above. + la_debug_assert( + false, + "Unexpected: all three edge tests are zero but segment not coplanar"); + return false; + } + + if (zeros == 2) { + // The segment passes through a vertex of T (boundary contact). + // Two zeros means the segment is coplanar with two edges of T, forcing it + // through their shared vertex. + has_vertex_contact = true; + return include_boundary; + } + + if (zeros == 1) { + // Confirm the contact is within the edge interior, not on its extension. + bool on_edge; + if (o1 == 0) + on_edge = (o2 * o3 > 0); + else if (o2 == 0) + on_edge = (o1 * o3 > 0); + else + on_edge = (o1 * o2 > 0); + + if (include_boundary) return on_edge; + + // In strict mode: record genuine straddling hits on T's edge interior for the + // shared-boundary-segment check at the end. + if (on_edge && d_a * d_b < 0) has_genuine_edge_contact = true; + return false; + } + + // No zeros - all three must have same sign for interior intersection + return (o1 > 0 && o2 > 0 && o3 > 0) || (o1 < 0 && o2 < 0 && o3 < 0); + }; + + // Check each edge of T1 against T2 + if (edge_intersects_triangle(t1_v0, t1_v1, t2_v0, t2_v1, t2_v2, d1_v0, d1_v1)) return true; + if (edge_intersects_triangle(t1_v1, t1_v2, t2_v0, t2_v1, t2_v2, d1_v1, d1_v2)) return true; + if (edge_intersects_triangle(t1_v2, t1_v0, t2_v0, t2_v1, t2_v2, d1_v2, d1_v0)) return true; + + // Check each edge of T2 against T1 + if (edge_intersects_triangle(t2_v0, t2_v1, t1_v0, t1_v1, t1_v2, d2_v0, d2_v1)) return true; + if (edge_intersects_triangle(t2_v1, t2_v2, t1_v0, t1_v1, t1_v2, d2_v1, d2_v2)) return true; + if (edge_intersects_triangle(t2_v2, t2_v0, t1_v0, t1_v1, t1_v2, d2_v2, d2_v0)) return true; + + // Special case: intersection segment has both endpoints on triangle boundaries. + // If one endpoint is a shared vertex (vertex contact) and the other is an interior + // point of an opposite edge (genuine edge contact), the segment is non-degenerate + // and implies strict interior overlap. + return has_vertex_contact && has_genuine_edge_contact; +} + +} // namespace internal + +template +bool triangle_triangle_intersection( + span t1_v0, + span t1_v1, + span t1_v2, + span t2_v0, + span t2_v1, + span t2_v2, + IncludeBoundaryIntersection boundary) +{ + // Convert to double for exact predicates + double t1_v0_d[3] = { + static_cast(t1_v0[0]), + static_cast(t1_v0[1]), + static_cast(t1_v0[2])}; + double t1_v1_d[3] = { + static_cast(t1_v1[0]), + static_cast(t1_v1[1]), + static_cast(t1_v1[2])}; + double t1_v2_d[3] = { + static_cast(t1_v2[0]), + static_cast(t1_v2[1]), + static_cast(t1_v2[2])}; + double t2_v0_d[3] = { + static_cast(t2_v0[0]), + static_cast(t2_v0[1]), + static_cast(t2_v0[2])}; + double t2_v1_d[3] = { + static_cast(t2_v1[0]), + static_cast(t2_v1[1]), + static_cast(t2_v1[2])}; + double t2_v2_d[3] = { + static_cast(t2_v2[0]), + static_cast(t2_v2[1]), + static_cast(t2_v2[2])}; + + return internal::triangle_triangle_intersection_3d( + t1_v0_d, + t1_v1_d, + t1_v2_d, + t2_v0_d, + t2_v1_d, + t2_v2_d, + boundary == IncludeBoundaryIntersection::Yes); +} + +// Explicit template instantiations +template LA_CORE_API bool triangle_triangle_intersection( + span, + span, + span, + span, + span, + span, + IncludeBoundaryIntersection); + +template LA_CORE_API bool triangle_triangle_intersection( + span, + span, + span, + span, + span, + span, + IncludeBoundaryIntersection); + +} // namespace lagrange diff --git a/modules/core/src/uv_mesh.cpp b/modules/core/src/uv_mesh.cpp index 48b351d6..7cfb597e 100644 --- a/modules/core/src/uv_mesh.cpp +++ b/modules/core/src/uv_mesh.cpp @@ -128,12 +128,31 @@ SurfaceMesh uv_mesh_view( return uv_mesh; } -#define LA_X_uv_mesh_view(UVScalar, Scalar, Index) \ - template LA_CORE_API SurfaceMesh uv_mesh_ref( \ - SurfaceMesh&, \ - const UVMeshOptions&); \ - template LA_CORE_API SurfaceMesh uv_mesh_view( \ - const SurfaceMesh&, \ +template +std::optional uv_attribute_id( + const SurfaceMesh& mesh, + const UVMeshOptions& options) +{ + AttributeId uv_attr_id = internal::get_uv_id( + mesh, + options.uv_attribute_name, + options.element_types, + internal::TypeMismatchPolicy::Graceful); + if (uv_attr_id == invalid_attribute_id()) { + return std::nullopt; + } + return uv_attr_id; +} + +#define LA_X_uv_mesh_view(UVScalar, Scalar, Index) \ + template LA_CORE_API SurfaceMesh uv_mesh_ref( \ + SurfaceMesh&, \ + const UVMeshOptions&); \ + template LA_CORE_API SurfaceMesh uv_mesh_view( \ + const SurfaceMesh&, \ + const UVMeshOptions&); \ + template LA_CORE_API std::optional uv_attribute_id( \ + const SurfaceMesh&, \ const UVMeshOptions&); #define LA_X_uv_mesh_view_aux(_, UVScalar) LA_SURFACE_MESH_X(uv_mesh_view, UVScalar) LA_SURFACE_MESH_SCALAR_X(uv_mesh_view_aux, 0) diff --git a/modules/core/tests/fmt/test_fmt.cpp b/modules/core/tests/fmt/test_fmt.cpp index 5699a8d9..d7148544 100644 --- a/modules/core/tests/fmt/test_fmt.cpp +++ b/modules/core/tests/fmt/test_fmt.cpp @@ -29,7 +29,7 @@ TEST_CASE("Format Matrix", "[fmt]") -4.f, 5.17f, 6.f, 7.f, 8.000000002f, 9.999999999f; // clang-format on -#if FMT_VERSION >= 100200 +#if defined(SPDLOG_USE_STD_FORMAT) || FMT_VERSION >= 100200 // This will format without error spdlog::info("{:.2f}\n", test); #elif (FMT_VERSION >= 100000) || (FMT_VERSION >= 90000 && !defined(FMT_DEPRECATED_OSTREAM)) || \ diff --git a/modules/core/tests/mesh_cleanup/test_remove_short_edges.cpp b/modules/core/tests/mesh_cleanup/test_remove_short_edges.cpp index 9ecc5bf5..45f98322 100644 --- a/modules/core/tests/mesh_cleanup/test_remove_short_edges.cpp +++ b/modules/core/tests/mesh_cleanup/test_remove_short_edges.cpp @@ -176,6 +176,183 @@ TEST_CASE("remove_short_edges", "[surface][cleanup]") REQUIRE(mesh.get_num_facets() == 2); REQUIRE(mesh.get_num_vertices() == 6); } + + SECTION("custom importance attribute") + { + // Use the same tet geometry as the "tet" test, but with custom importance + mesh.add_vertex({0, 0, 0}); // v0 + mesh.add_vertex({1, 0, 0}); // v1 + mesh.add_vertex({0, 1, 0}); // v2 + mesh.add_vertex({0, 0, -0.1}); // v3 - short edge to v0 + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 0, 3); + mesh.add_triangle(2, 1, 3); + mesh.add_triangle(0, 2, 3); + + // Create custom importance: make v0 low and v3 high + // Without custom importance, default behavior would keep v0 (interior with higher dihedral) + // With custom importance, we should keep v3 + auto importance_id = mesh.template create_attribute( + "my_importance", + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1); + auto importance = mesh.template ref_attribute(importance_id).ref_all(); + importance[0] = 1.0; // v0: low importance + importance[1] = 50.0; // v1: medium importance + importance[2] = 50.0; // v2: medium importance + importance[3] = 100.0; // v3: high importance + + RemoveShortEdgesOptions options; + options.threshold = 0.5; + options.vertex_importance_attribute_name = "my_importance"; + remove_short_edges(mesh, options); + + REQUIRE(mesh.get_num_facets() == 2); + REQUIRE(mesh.get_num_vertices() == 3); + + // Verify the importance attribute still exists + REQUIRE(mesh.has_attribute("my_importance")); + auto final_importance = attribute_vector_view(mesh, "my_importance"); + REQUIRE(final_importance.size() == 3); + + // Check that at least one vertex kept high importance (or averaged high) + Scalar max_importance = final_importance.maxCoeff(); + REQUIRE(max_importance >= 50.0); + } + + SECTION("options with default importance") + { + // Test using options struct without custom importance + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({0, 0, -0.1}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 0, 3); + mesh.add_triangle(2, 1, 3); + mesh.add_triangle(0, 2, 3); + + RemoveShortEdgesOptions options; + options.threshold = 0.5; + remove_short_edges(mesh, options); + + REQUIRE(mesh.get_num_facets() == 2); + REQUIRE(mesh.get_num_vertices() == 3); + + // The importance attribute should have been cleaned up + REQUIRE(!mesh.has_attribute("@vertex_collapse_importance")); + } + + SECTION("normal flip guard — tight threshold blocks flip-inducing collapse") + { + // A short edge (keep_v → remove_v, length = 2*eps) straddles the x-axis. + // + // A(-1,0,0) -------- B(1,0,0) + // \ remove_v / + // \ (0,+eps,0) / <- face (A, B, remove_v): normal +Z + // \ / + // keep_v(0,-eps,0) + // + // After collapsing remove_v onto keep_v, the face (A, B, remove_v) becomes + // (A, B, keep_v) whose normal points -Z — a full 180° flip. + // A threshold of pi/4 must reject this collapse. + // A threshold of pi (disabled) must allow it. + // + // We assign high importance to keep_v via a custom attribute so that + // keep_v is always retained and remove_v is always eliminated. + + constexpr Scalar eps = Scalar(0.004); // edge length = 2*eps = 0.008 < 0.01 + + auto make_mesh = [&]() { + SurfaceMesh m; + m.add_vertex({-1, 0, 0}); // 0 = A + m.add_vertex({1, 0, 0}); // 1 = B + m.add_vertex({0, -eps, 0}); // 2 = keep_v + m.add_vertex({0, eps, 0}); // 3 = remove_v + m.add_vertex({0, 0, 1}); // 4 = C (connectivity anchor) + + // The face that flips: (A, B, remove_v) → (A, B, keep_v) + m.add_triangle(0, 1, 3); // normal +Z before, -Z after + + // Face containing both keep_v and remove_v → becomes degenerate, + // is excluded from the normal check and cleaned up afterwards. + m.add_triangle(2, 3, 4); + + // Extra face so keep_v has a 1-ring (prevents it from being isolated). + m.add_triangle(0, 2, 1); // normal -Z, does not involve remove_v + + // Custom importance: keep_v >> remove_v so the assignment is deterministic. + auto imp_id = m.template create_attribute( + "imp", + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1); + auto imp = m.template ref_attribute(imp_id).ref_all(); + imp[0] = 100; // A + imp[1] = 100; // B + imp[2] = 200; // keep_v — highest, always retained + imp[3] = 1; // remove_v — lowest, always eliminated + imp[4] = 100; // C + return m; + }; + + // Case 1: tight threshold (45°) — collapse must be blocked. + { + auto m = make_mesh(); + RemoveShortEdgesOptions opts; + opts.threshold = Scalar(0.01); + opts.max_normal_deviation_angle = lagrange::internal::pi / 4; + opts.vertex_importance_attribute_name = "imp"; + const Index nf = m.get_num_facets(); + remove_short_edges(m, opts); + REQUIRE(m.get_num_facets() == nf); + } + + // Case 2: guard disabled (pi) — collapse must proceed. + { + auto m = make_mesh(); + RemoveShortEdgesOptions opts; + opts.threshold = Scalar(0.01); + opts.max_normal_deviation_angle = lagrange::internal::pi; + opts.vertex_importance_attribute_name = "imp"; + const Index nf = m.get_num_facets(); + remove_short_edges(m, opts); + REQUIRE(m.get_num_facets() < nf); + } + } + + SECTION("normal flip guard — safe collapse is still performed") + { + // Two triangles that share a nearly-duplicate vertex (v2 ≈ v3). + // Collapsing the short edge v2-v3 is planar — normals do not change — + // so it must proceed even with a tight threshold. + // + // v0(0,0,0) ---- v2(1,0,0) + // \ / | + // \ / | + // \ / v3(1,0,eps) <- nearly identical to v2 + // \ / / + // v1(0,1,0) + + constexpr Scalar eps = Scalar(0.001); // v2-v3 edge length ≈ eps < 0.01 + + mesh.add_vertex({0, 0, 0}); // v0 + mesh.add_vertex({0, 1, 0}); // v1 + mesh.add_vertex({1, 0, 0}); // v2 + mesh.add_vertex({1, 0, eps}); // v3 — short edge to v2 + + mesh.add_triangle(0, 2, 1); // face using v2 + mesh.add_triangle(1, 2, 3); // face using both v2 and v3 + + RemoveShortEdgesOptions opts; + opts.threshold = Scalar(0.01); + opts.max_normal_deviation_angle = lagrange::internal::pi / 6; // 30° — tight + remove_short_edges(mesh, opts); + + // v3 must have been merged into v2: vertex count drops. + REQUIRE(mesh.get_num_vertices() < 4); + } } TEST_CASE("remove_short_edges benchmark", "[surface][cleanup][!benchmark]") diff --git a/modules/core/tests/test_assert.cpp b/modules/core/tests/test_assert.cpp index a7546593..ffc858ba 100644 --- a/modules/core/tests/test_assert.cpp +++ b/modules/core/tests/test_assert.cpp @@ -17,6 +17,7 @@ #include #include #include +#include // clang-format on TEST_CASE("Assert", "[next]") @@ -25,12 +26,12 @@ TEST_CASE("Assert", "[next]") la_runtime_assert(true, "This is true"); LA_REQUIRE_THROWS(la_runtime_assert(false)); LA_REQUIRE_THROWS(la_runtime_assert(false, "This is false")); - LA_REQUIRE_THROWS(la_runtime_assert(false, fmt::format("Complex message: {}", 10))); + LA_REQUIRE_THROWS(la_runtime_assert(false, lagrange::format("Complex message: {}", 10))); // We want to prevent the macro from taking 3+ arguments: // la_runtime_assert(true, "This should not compile", 0); - la_runtime_assert(true, fmt::format("Hello {}", "world")); + la_runtime_assert(true, lagrange::format("Hello {}", "world")); // The assert macro can be used in an expression: int a = 2; diff --git a/modules/core/tests/test_compute_barycentric_coordinates.cpp b/modules/core/tests/test_compute_barycentric_coordinates.cpp new file mode 100644 index 00000000..86290369 --- /dev/null +++ b/modules/core/tests/test_compute_barycentric_coordinates.cpp @@ -0,0 +1,490 @@ +/* + * 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 + +namespace { + +using Catch::Matchers::WithinAbs; +constexpr double eps = 1e-10; + +/// Verify that barycentric coordinates are valid: +/// - Sum to 1 +/// - Reconstruct the query point +template +void verify_barycentric( + const Eigen::Matrix& v0, + const Eigen::Matrix& v1, + const Eigen::Matrix& v2, + const Eigen::Matrix& p, + const Eigen::Matrix& bary) +{ + // Barycentric coordinates must sum to 1. + REQUIRE_THAT(static_cast(bary.sum()), WithinAbs(1.0, eps)); + + // Reconstruct point from barycentric coordinates. + auto reconstructed = bary(0) * v0 + bary(1) * v1 + bary(2) * v2; + for (int d = 0; d < Dim; ++d) { + REQUIRE_THAT( + static_cast(reconstructed(d)), + WithinAbs(static_cast(p(d)), eps)); + } +} + +} // namespace + +TEST_CASE("compute_barycentric_coordinates: vertices at origin", "[core][barycentric]") +{ + // Triangle with a vertex at the origin — the old implementation fails here because the + // matrix [v0|v1|v2] has a zero column, making it singular. + Eigen::Vector3d v0(0, 0, 0); + Eigen::Vector3d v1(1, 0, 0); + Eigen::Vector3d v2(0, 1, 0); + + SECTION("query at v0") + { + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, v0); + verify_barycentric(v0, v1, v2, v0, bary); + REQUIRE_THAT(bary(0), WithinAbs(1.0, eps)); + REQUIRE_THAT(bary(1), WithinAbs(0.0, eps)); + REQUIRE_THAT(bary(2), WithinAbs(0.0, eps)); + } + + SECTION("query at v1") + { + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, v1); + verify_barycentric(v0, v1, v2, v1, bary); + REQUIRE_THAT(bary(0), WithinAbs(0.0, eps)); + REQUIRE_THAT(bary(1), WithinAbs(1.0, eps)); + REQUIRE_THAT(bary(2), WithinAbs(0.0, eps)); + } + + SECTION("query at centroid") + { + Eigen::Vector3d p = (v0 + v1 + v2) / 3.0; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + verify_barycentric(v0, v1, v2, p, bary); + REQUIRE_THAT(bary(0), WithinAbs(1.0 / 3.0, eps)); + REQUIRE_THAT(bary(1), WithinAbs(1.0 / 3.0, eps)); + REQUIRE_THAT(bary(2), WithinAbs(1.0 / 3.0, eps)); + } +} + +TEST_CASE("compute_barycentric_coordinates: coplanar z=0", "[core][barycentric]") +{ + // All vertices in the z=0 plane — the old implementation builds a 3x3 matrix with all-zero + // third row, making it singular. + Eigen::Vector3d v0(1, 0, 0); + Eigen::Vector3d v1(3, 0, 0); + Eigen::Vector3d v2(2, 2, 0); + + SECTION("query at centroid") + { + Eigen::Vector3d p = (v0 + v1 + v2) / 3.0; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + verify_barycentric(v0, v1, v2, p, bary); + } + + SECTION("query at v0") + { + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, v0); + verify_barycentric(v0, v1, v2, v0, bary); + REQUIRE_THAT(bary(0), WithinAbs(1.0, eps)); + } + + SECTION("query at midpoint of edge v0-v1") + { + Eigen::Vector3d p = (v0 + v1) / 2.0; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + verify_barycentric(v0, v1, v2, p, bary); + REQUIRE_THAT(bary(0), WithinAbs(0.5, eps)); + REQUIRE_THAT(bary(1), WithinAbs(0.5, eps)); + REQUIRE_THAT(bary(2), WithinAbs(0.0, eps)); + } +} + +TEST_CASE("compute_barycentric_coordinates: coplanar y=0", "[core][barycentric]") +{ + Eigen::Vector3d v0(0, 0, 0); + Eigen::Vector3d v1(4, 0, 0); + Eigen::Vector3d v2(2, 0, 3); + Eigen::Vector3d p = (v0 + v1 + v2) / 3.0; + + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + verify_barycentric(v0, v1, v2, p, bary); +} + +TEST_CASE("compute_barycentric_coordinates: non-axis-aligned plane", "[core][barycentric]") +{ + // Triangle in an arbitrary plane not aligned with any axis. + Eigen::Vector3d v0(1, 2, 3); + Eigen::Vector3d v1(4, 2, 3); + Eigen::Vector3d v2(2.5, 5, 3); + + SECTION("query at centroid") + { + Eigen::Vector3d p = (v0 + v1 + v2) / 3.0; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + verify_barycentric(v0, v1, v2, p, bary); + } + + SECTION("query at 0.2/0.3/0.5 blend") + { + Eigen::Vector3d p = 0.2 * v0 + 0.3 * v1 + 0.5 * v2; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + verify_barycentric(v0, v1, v2, p, bary); + REQUIRE_THAT(bary(0), WithinAbs(0.2, eps)); + REQUIRE_THAT(bary(1), WithinAbs(0.3, eps)); + REQUIRE_THAT(bary(2), WithinAbs(0.5, eps)); + } +} + +TEST_CASE("compute_barycentric_coordinates: general 3D triangle", "[core][barycentric]") +{ + // Triangle that is NOT in any axis-aligned plane (this case already worked). + Eigen::Vector3d v0(1, 0, 0); + Eigen::Vector3d v1(0, 1, 0); + Eigen::Vector3d v2(0, 0, 1); + + SECTION("query at centroid") + { + Eigen::Vector3d p = (v0 + v1 + v2) / 3.0; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + verify_barycentric(v0, v1, v2, p, bary); + } + + SECTION("query at vertices") + { + for (int k = 0; k < 3; ++k) { + const auto& vk = (k == 0) ? v0 : (k == 1) ? v1 : v2; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, vk); + verify_barycentric(v0, v1, v2, vk, bary); + REQUIRE_THAT(bary(k), WithinAbs(1.0, eps)); + REQUIRE_THAT(bary((k + 1) % 3), WithinAbs(0.0, eps)); + REQUIRE_THAT(bary((k + 2) % 3), WithinAbs(0.0, eps)); + } + } +} + +TEST_CASE("compute_barycentric_coordinates: 2D triangle", "[core][barycentric]") +{ + // 2D points (common in UV space). + Eigen::Vector2d v0(0, 0); + Eigen::Vector2d v1(1, 0); + Eigen::Vector2d v2(0.5, 1); + + SECTION("query at centroid") + { + Eigen::Vector2d p = (v0 + v1 + v2) / 3.0; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + verify_barycentric(v0, v1, v2, p, bary); + } + + SECTION("query at v2") + { + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, v2); + verify_barycentric(v0, v1, v2, v2, bary); + REQUIRE_THAT(bary(2), WithinAbs(1.0, eps)); + } +} + +TEST_CASE("compute_barycentric_coordinates: collinear vertices", "[core][barycentric]") +{ + // Degenerate triangle: all three vertices are collinear along the x-axis. + // The least-squares solution should still return coordinates that sum to 1 + // and reconstruct p as well as possible (exact when p is on the line). + Eigen::Vector3d v0(0, 0, 0); + Eigen::Vector3d v1(1, 0, 0); + Eigen::Vector3d v2(2, 0, 0); + + SECTION("query at v0") + { + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, v0); + REQUIRE_THAT(static_cast(bary.sum()), WithinAbs(1.0, eps)); + auto reconstructed = bary(0) * v0 + bary(1) * v1 + bary(2) * v2; + for (int d = 0; d < 3; ++d) { + REQUIRE_THAT( + static_cast(reconstructed(d)), + WithinAbs(static_cast(v0(d)), eps)); + } + } + + SECTION("query at midpoint of v0-v2") + { + Eigen::Vector3d p = (v0 + v2) / 2.0; // same as v1 + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + REQUIRE_THAT(static_cast(bary.sum()), WithinAbs(1.0, eps)); + auto reconstructed = bary(0) * v0 + bary(1) * v1 + bary(2) * v2; + for (int d = 0; d < 3; ++d) { + REQUIRE_THAT( + static_cast(reconstructed(d)), + WithinAbs(static_cast(p(d)), eps)); + } + } + + SECTION("query off the line — best approximation") + { + Eigen::Vector3d p(0.5, 1.0, 0.0); // off the line + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + // Coordinates should still sum to 1. + REQUIRE_THAT(static_cast(bary.sum()), WithinAbs(1.0, eps)); + // Reconstruction won't be exact (p is not on the line), but the x-component should match. + auto reconstructed = bary(0) * v0 + bary(1) * v1 + bary(2) * v2; + REQUIRE_THAT(static_cast(reconstructed(0)), WithinAbs(0.5, eps)); + } +} + +TEST_CASE("compute_barycentric_coordinates: float precision", "[core][barycentric]") +{ + Eigen::Vector3f v0(0.0f, 0.0f, 0.0f); + Eigen::Vector3f v1(1.0f, 0.0f, 0.0f); + Eigen::Vector3f v2(0.0f, 1.0f, 0.0f); + Eigen::Vector3f p = (v0 + v1 + v2) / 3.0f; + + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + REQUIRE_THAT(static_cast(bary.sum()), WithinAbs(1.0, 1e-5)); + auto reconstructed = bary(0) * v0 + bary(1) * v1 + bary(2) * v2; + for (int d = 0; d < 3; ++d) { + REQUIRE_THAT( + static_cast(reconstructed(d)), + WithinAbs(static_cast(p(d)), 1e-5)); + } +} + +TEST_CASE("compute_barycentric_coordinates: fully dynamic VectorXd", "[core][barycentric]") +{ + // Fully dynamic vectors (VectorXd) should work without heap allocation inside the function. + SECTION("3D") + { + Eigen::VectorXd v0(3), v1(3), v2(3), p(3); + v0 << 0, 0, 0; + v1 << 1, 0, 0; + v2 << 0, 1, 0; + p << 0.25, 0.25, 0; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + REQUIRE_THAT(static_cast(bary.sum()), WithinAbs(1.0, eps)); + Eigen::VectorXd reconstructed = bary(0) * v0 + bary(1) * v1 + bary(2) * v2; + for (int d = 0; d < 3; ++d) { + REQUIRE_THAT( + static_cast(reconstructed(d)), + WithinAbs(static_cast(p(d)), eps)); + } + } + + SECTION("2D") + { + Eigen::VectorXd v0(2), v1(2), v2(2), p(2); + v0 << 0, 0; + v1 << 1, 0; + v2 << 0.5, 1; + p = (v0 + v1 + v2) / 3.0; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + REQUIRE_THAT(static_cast(bary.sum()), WithinAbs(1.0, eps)); + Eigen::VectorXd reconstructed = bary(0) * v0 + bary(1) * v1 + bary(2) * v2; + for (int d = 0; d < 2; ++d) { + REQUIRE_THAT( + static_cast(reconstructed(d)), + WithinAbs(static_cast(p(d)), eps)); + } + } + + SECTION("coplanar z=0") + { + Eigen::VectorXd v0(3), v1(3), v2(3), p(3); + v0 << 1, 0, 0; + v1 << 3, 0, 0; + v2 << 2, 2, 0; + p = (v0 + v1 + v2) / 3.0; + auto bary = lagrange::compute_barycentric_coordinates(v0, v1, v2, p); + REQUIRE_THAT(static_cast(bary.sum()), WithinAbs(1.0, eps)); + Eigen::VectorXd reconstructed = bary(0) * v0 + bary(1) * v1 + bary(2) * v2; + for (int d = 0; d < 3; ++d) { + REQUIRE_THAT( + static_cast(reconstructed(d)), + WithinAbs(static_cast(p(d)), eps)); + } + } +} + +// ---- Benchmarks ---- + +namespace { + +/// Fast-path only: 2x2 Gram matrix solve (no degeneracy check, no fallback). +template +auto bary_fast_path_only( + const Eigen::MatrixBase& v0, + const Eigen::MatrixBase& v1, + const Eigen::MatrixBase& v2, + const Eigen::MatrixBase& p) -> Eigen::Matrix +{ + using Scalar = typename PointType::Scalar; + const auto e1 = (v1 - v0).eval(); + const auto e2 = (v2 - v0).eval(); + const auto ep = (p - v0).eval(); + + Eigen::Matrix G; + G(0, 0) = e1.squaredNorm(); + G(0, 1) = e1.dot(e2); + G(1, 0) = G(0, 1); + G(1, 1) = e2.squaredNorm(); + + const Eigen::Matrix b(ep.dot(e1), ep.dot(e2)); + const Eigen::Matrix uv = G.inverse() * b; + + Eigen::Matrix bary; + bary(0) = Scalar(1) - uv(0) - uv(1); + bary(1) = uv(0); + bary(2) = uv(1); + return bary; +} + +/// Slow-path only: constrained least-squares QR solve. +template +auto bary_slow_path_only( + const Eigen::MatrixBase& v0, + const Eigen::MatrixBase& v1, + const Eigen::MatrixBase& v2, + const Eigen::MatrixBase& p) -> Eigen::Matrix +{ + using Scalar = typename PointType::Scalar; + constexpr int Dim = PointType::SizeAtCompileTime; + constexpr int MaxDim = PointType::MaxSizeAtCompileTime; + constexpr int EffectiveMaxDim = (MaxDim != Eigen::Dynamic) ? MaxDim : 3; + constexpr int Rows = (Dim != Eigen::Dynamic) ? Dim + 1 : Eigen::Dynamic; + constexpr int MaxRows = EffectiveMaxDim + 1; + + const Eigen::Index dim = p.size(); + Eigen::Matrix M(dim + 1, 3); + M.row(dim).setOnes(); + for (Eigen::Index d = 0; d < dim; ++d) { + M(d, 0) = v0[d]; + M(d, 1) = v1[d]; + M(d, 2) = v2[d]; + } + + Eigen::Matrix rhs(dim + 1); + for (Eigen::Index d = 0; d < dim; ++d) { + rhs(d) = p[d]; + } + rhs(dim) = Scalar(1); + + return M.colPivHouseholderQr().solve(rhs); +} + +} // namespace + +TEST_CASE("compute_barycentric_coordinates: benchmark", "[core][barycentric][!benchmark]") +{ + // -- Non-degenerate triangles -- + + // 2D fixed-size + Eigen::Vector2d v0_2d(0, 0), v1_2d(1, 0), v2_2d(0.5, 1); + Eigen::Vector2d p_2d = 0.2 * v0_2d + 0.3 * v1_2d + 0.5 * v2_2d; + + // 3D fixed-size + Eigen::Vector3d v0_3d(1, 2, 3), v1_3d(4, 2, 3), v2_3d(2.5, 5, 3); + Eigen::Vector3d p_3d = 0.2 * v0_3d + 0.3 * v1_3d + 0.5 * v2_3d; + + // 3D dynamic (VectorXd) + Eigen::VectorXd v0_dyn(3), v1_dyn(3), v2_dyn(3), p_dyn(3); + v0_dyn << 1, 2, 3; + v1_dyn << 4, 2, 3; + v2_dyn << 2.5, 5, 3; + p_dyn = 0.2 * v0_dyn + 0.3 * v1_dyn + 0.5 * v2_dyn; + + // -- Degenerate triangles (collinear vertices, forces slow path) -- + + // 2D degenerate + Eigen::Vector2d dv0_2d(0, 0), dv1_2d(1, 0), dv2_2d(2, 0); + Eigen::Vector2d dp_2d(0.5, 0); + + // 3D degenerate + Eigen::Vector3d dv0_3d(0, 0, 0), dv1_3d(1, 0, 0), dv2_3d(2, 0, 0); + Eigen::Vector3d dp_3d(0.5, 0, 0); + + // 3D degenerate dynamic + Eigen::VectorXd dv0_dyn(3), dv1_dyn(3), dv2_dyn(3), dp_dyn(3); + dv0_dyn << 0, 0, 0; + dv1_dyn << 1, 0, 0; + dv2_dyn << 2, 0, 0; + dp_dyn << 0.5, 0, 0; + + // -- 2D benchmarks -- + + BENCHMARK("2D fixed: fast-path only") + { + return bary_fast_path_only(v0_2d, v1_2d, v2_2d, p_2d); + }; + + BENCHMARK("2D fixed: slow-path only") + { + return bary_slow_path_only(v0_2d, v1_2d, v2_2d, p_2d); + }; + + BENCHMARK("2D fixed: combined (non-degenerate)") + { + return lagrange::compute_barycentric_coordinates(v0_2d, v1_2d, v2_2d, p_2d); + }; + + BENCHMARK("2D fixed: combined (degenerate)") + { + return lagrange::compute_barycentric_coordinates(dv0_2d, dv1_2d, dv2_2d, dp_2d); + }; + + // -- 3D fixed benchmarks -- + + BENCHMARK("3D fixed: fast-path only") + { + return bary_fast_path_only(v0_3d, v1_3d, v2_3d, p_3d); + }; + + BENCHMARK("3D fixed: slow-path only") + { + return bary_slow_path_only(v0_3d, v1_3d, v2_3d, p_3d); + }; + + BENCHMARK("3D fixed: combined (non-degenerate)") + { + return lagrange::compute_barycentric_coordinates(v0_3d, v1_3d, v2_3d, p_3d); + }; + + BENCHMARK("3D fixed: combined (degenerate)") + { + return lagrange::compute_barycentric_coordinates(dv0_3d, dv1_3d, dv2_3d, dp_3d); + }; + + // -- 3D dynamic benchmarks -- + + BENCHMARK("3D dynamic: fast-path only") + { + return bary_fast_path_only(v0_dyn, v1_dyn, v2_dyn, p_dyn); + }; + + BENCHMARK("3D dynamic: slow-path only") + { + return bary_slow_path_only(v0_dyn, v1_dyn, v2_dyn, p_dyn); + }; + + BENCHMARK("3D dynamic: combined (non-degenerate)") + { + return lagrange::compute_barycentric_coordinates(v0_dyn, v1_dyn, v2_dyn, p_dyn); + }; + + BENCHMARK("3D dynamic: combined (degenerate)") + { + return lagrange::compute_barycentric_coordinates(dv0_dyn, dv1_dyn, dv2_dyn, dp_dyn); + }; +} diff --git a/modules/core/tests/test_compute_normal.cpp b/modules/core/tests/test_compute_normal.cpp index 24f23578..ce492da3 100644 --- a/modules/core/tests/test_compute_normal.cpp +++ b/modules/core/tests/test_compute_normal.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -353,13 +354,13 @@ TEST_CASE("compute_normal nmtest", "[core][normal]" LA_CORP_FLAG) mesh = lagrange::unify_index_buffer(mesh, {nrm_id}); mesh.rename_attribute(nrm_name, "Vertex_Normal"); // match ply attribute name - auto filename = fmt::format("nmtest_normal_{}.ply", angle_threshold_deg); + auto filename = lagrange::format("nmtest_normal_{}.ply", angle_threshold_deg); // Uncomment to save a new output // lagrange::io::save_mesh(filename, mesh); auto expected = lagrange::testing::load_surface_mesh( - fmt::format("corp/core/regression/{}", filename)); + lagrange::format("corp/core/regression/{}", filename)); lagrange::seq_foreach_named_attribute_read( mesh, diff --git a/modules/core/tests/test_compute_tangent_bitangent.cpp b/modules/core/tests/test_compute_tangent_bitangent.cpp index 68506ddf..087699c3 100644 --- a/modules/core/tests/test_compute_tangent_bitangent.cpp +++ b/modules/core/tests/test_compute_tangent_bitangent.cpp @@ -40,6 +40,7 @@ #ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS #include + #include #endif @@ -600,7 +601,7 @@ TEST_CASE("compute_tangent_bitangent nmtest", "[core][tangent]" LA_CORP_FLAG) mesh = lagrange::unify_index_buffer(mesh, {nrm_id, t_id, bt_id}); mesh.rename_attribute(nrm_name, "Vertex_Normal"); // match ply attribute name - auto filename = fmt::format( + auto filename = lagrange::format( "nmtest_{}_{}.ply", lagrange::internal::to_string(output_element_type), angle_threshold_deg); @@ -609,7 +610,7 @@ TEST_CASE("compute_tangent_bitangent nmtest", "[core][tangent]" LA_CORP_FLAG) // lagrange::io::save_mesh(filename, mesh); auto expected = lagrange::testing::load_surface_mesh( - fmt::format("corp/core/regression/{}", filename)); + lagrange::format("corp/core/regression/{}", filename)); lagrange::seq_foreach_named_attribute_read( mesh, diff --git a/modules/core/tests/test_compute_uv_charts.cpp b/modules/core/tests/test_compute_uv_charts.cpp index 9b1df2f6..8056b4bf 100644 --- a/modules/core/tests/test_compute_uv_charts.cpp +++ b/modules/core/tests/test_compute_uv_charts.cpp @@ -62,3 +62,67 @@ TEST_CASE("compute_uv_charts", "[surface][utilities]") REQUIRE(num_charts == 2); } } + +TEST_CASE("compute_uv_charts: different UV scalar type", "[surface][utilities]") +{ + using Scalar = double; + using Index = uint32_t; + using UVScalar = float; + + 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("Single chart with float UVs on double mesh") + { + std::vector uv_values = {0, 0, 1, 0, 0, 1, 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 num_charts = compute_uv_charts(mesh); + REQUIRE(num_charts == 1); + } + + SECTION("Two charts with float UVs on double mesh") + { + std::vector uv_values = {0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1}; + 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()}); + + auto num_charts = compute_uv_charts(mesh); + REQUIRE(num_charts == 2); + } + + SECTION("Single chart with float vertex UVs on double mesh") + { + std::vector uv_values = {0, 0, 1, 0, 0, 1, 1, 1}; + + mesh.template create_attribute( + "uv", + AttributeElement::Vertex, + AttributeUsage::UV, + 2, + uv_values); + + auto num_charts = compute_uv_charts(mesh); + REQUIRE(num_charts == 1); + } +} diff --git a/modules/core/tests/test_disconnect_uv_charts.cpp b/modules/core/tests/test_disconnect_uv_charts.cpp new file mode 100644 index 00000000..77b44045 --- /dev/null +++ b/modules/core/tests/test_disconnect_uv_charts.cpp @@ -0,0 +1,483 @@ +/* + * 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 + +using namespace lagrange; + +namespace { + +using Scalar = double; +using Index = uint32_t; + +// Helper to create an indexed UV attribute on a mesh. +template +void add_indexed_uv( + SurfaceMesh& mesh, + std::vector uv_values, + std::vector uv_indices, + std::string_view name = "uv") +{ + mesh.template create_attribute( + name, + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + {uv_values.data(), uv_values.size()}, + {uv_indices.data(), uv_indices.size()}); +} + +// Helper to read back UV indices from the mesh. +template +std::vector get_uv_indices(SurfaceMesh& mesh, std::string_view name = "uv") +{ + auto& attr = mesh.template get_indexed_attribute(mesh.get_attribute_id(name)); + auto indices = attr.indices().get_all(); + return {indices.begin(), indices.end()}; +} + +// Helper to get the number of UV values. +template +size_t get_num_uv_values(SurfaceMesh& mesh, std::string_view name = "uv") +{ + auto& attr = mesh.template get_indexed_attribute(mesh.get_attribute_id(name)); + return attr.values().get_num_elements(); +} + +} // namespace + +TEST_CASE("disconnect_uv_charts", "[surface][utilities]") +{ + SECTION("Single chart - no duplicates") + { + // Two triangles sharing an edge, single UV chart. + // + // 2---3 + // |\ | + // | \| + // 0---1 + // + 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}); + + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 0); + CHECK(get_num_uv_values(mesh) == 4); + } + + SECTION("Two separate charts - no shared vertices") + { + // Two triangles with completely separate UV indices (no shared UV vertex). + 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); + + // 6 unique UV values, no sharing between triangles. + add_indexed_uv(mesh, {0, 0, 1, 0, 0, 1, 0.5, 0, 1, 0.5, 0.5, 1}, {0, 1, 2, 3, 4, 5}); + + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 0); + CHECK(get_num_uv_values(mesh) == 6); + } + + SECTION("Two charts sharing a UV vertex (pinch point)") + { + // Four triangles arranged as a bowtie in UV space: + // + // 1 3 + // /|\ /| + // / | \ / | + // 0--+--2 / | + // |\ | / | + // | \| / | + // 4--+--5 | + // \ | + // ------4 + // + // Triangles (0,1,2) and (0,4,1) share edge 0-1 -> same chart A + // Triangles (2,3,5) and (2,5,4) share edge 2-5 -> same chart B + // UV vertex 2 is shared between chart A and chart B (pinch point). + // + // Simplified: 2 triangles touching at a single UV vertex. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 0.5, 0}); // shared geometry vertex + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(2, 3, 4); + + // UV vertex 2 is shared between triangles in different charts. + // Triangle 0 corners: UV 0, 1, 2 + // Triangle 1 corners: UV 2, 3, 4 + // Charts: {tri0} and {tri1} (no shared edge in UV space). + add_indexed_uv(mesh, {0, 0, 1, 0, 0.5, 0.5, 0, 1, 1, 1}, {0, 1, 2, 2, 3, 4}); + + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 1); // UV vertex 2 is duplicated + CHECK(get_num_uv_values(mesh) == 6); // 5 original + 1 duplicate + + // Check that the two triangles no longer share any UV index. + auto indices = get_uv_indices(mesh); + // Triangle 0: indices[0..2], Triangle 1: indices[3..5] + std::set tri0_uvs(indices.begin(), indices.begin() + 3); + std::set tri1_uvs(indices.begin() + 3, indices.begin() + 6); + std::vector intersection; + std::set_intersection( + tri0_uvs.begin(), + tri0_uvs.end(), + tri1_uvs.begin(), + tri1_uvs.end(), + std::back_inserter(intersection)); + CHECK(intersection.empty()); + } + + SECTION("Three charts sharing a single UV vertex") + { + // Three triangles meeting at a single UV vertex (vertex 0), no shared edges. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); // shared + 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); + + // All three triangles share UV index 0, but have no shared edges -> 3 charts. + 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}); + + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 2); // vertex 0 duplicated for 2nd and 3rd chart + CHECK(get_num_uv_values(mesh) == 9); // 7 original + 2 duplicates + } + + SECTION("With pre-computed chart_id attribute") + { + // Two triangles sharing an edge in UV space, but with an external chart_id + // that forces them into separate charts. + 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); + + // Shared edge (UV indices 1-2) between the two triangles. + add_indexed_uv(mesh, {0, 0, 1, 0, 0, 1, 1, 1}, {0, 1, 2, 1, 3, 2}); + + // Force different chart ids despite shared edge. + mesh.template create_attribute( + "@chart_id", + AttributeElement::Facet, + AttributeUsage::Scalar, + 1, + std::vector{0, 1}); + + DisconnectUVChartsOptions opts; + opts.chart_id_attribute_name = "@chart_id"; + auto num_duped = disconnect_uv_charts(mesh, opts); + CHECK(num_duped == 2); // UV vertices 1 and 2 are shared and must be duplicated + CHECK(get_num_uv_values(mesh) == 6); // 4 original + 2 duplicates + } + + SECTION("No-op on already separated charts") + { + // Two charts that don't share any UV vertex. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 1, 0}); + mesh.add_vertex({2, 0, 0}); + mesh.add_vertex({3, 0, 0}); + mesh.add_vertex({2.5, 1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(3, 4, 5); + + add_indexed_uv(mesh, {0, 0, 1, 0, 0.5, 1, 2, 0, 3, 0, 2.5, 1}, {0, 1, 2, 3, 4, 5}); + + auto indices_before = get_uv_indices(mesh); + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 0); + CHECK(get_uv_indices(mesh) == indices_before); + } + + SECTION("UV values are preserved after duplication") + { + // Pinch point: check that the duplicated UV value matches the original. + 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); + + const Scalar pinch_u = 0.5, pinch_v = 0.5; + add_indexed_uv(mesh, {0, 0, 1, 0, pinch_u, pinch_v, 0, 1, 1, 1}, {0, 1, 2, 2, 3, 4}); + + disconnect_uv_charts(mesh); + + // The duplicated vertex should have the same UV value as the original. + auto& attr = mesh.template get_indexed_attribute(mesh.get_attribute_id("uv")); + auto values = matrix_view(attr.values()); + auto indices = get_uv_indices(mesh); + + // Get the UV value referenced by each triangle at the pinch position. + // Triangle 0, corner 2 and Triangle 1, corner 0 were both UV index 2. + Index uv_idx_tri0 = indices[2]; + Index uv_idx_tri1 = indices[3]; + CHECK(uv_idx_tri0 != uv_idx_tri1); // They should now be different indices + CHECK(values(uv_idx_tri0, 0) == Catch::Approx(pinch_u)); + CHECK(values(uv_idx_tri0, 1) == Catch::Approx(pinch_v)); + CHECK(values(uv_idx_tri1, 0) == Catch::Approx(pinch_u)); + CHECK(values(uv_idx_tri1, 1) == Catch::Approx(pinch_v)); + } + + SECTION("Multiple facets per chart sharing a duplicated vertex") + { + // Two charts, each with 2 triangles. UV vertex 2 is the pinch point shared by both charts. + // Chart A: triangles 0,1 (share edge 1-2) + // Chart B: triangles 2,3 (share edge 2-3) + // Both charts reference UV vertex 2. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 0.5, 0}); // pinch point + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_vertex({-1, 0, 0}); + mesh.add_vertex({2, 1, 0}); + // Chart A: tri(0,1,2) and tri(0,2,5) — connected via edge 0-2 + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 5); + // Chart B: tri(2,3,4) and tri(2,4,6) — connected via edge 2-4 + mesh.add_triangle(2, 3, 4); + mesh.add_triangle(2, 4, 6); + + // UV: vertex 2 shared across charts A and B, but no shared edges between charts. + add_indexed_uv( + mesh, + {0, 0, 1, 0, 0.5, 0.5, 0, 1, 1, 1, -1, 0, 2, 1}, + {0, 1, 2, 0, 2, 5, 2, 3, 4, 2, 4, 6}); + + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 1); // Only one duplicate for vertex 2 (not one per corner) + CHECK(get_num_uv_values(mesh) == 8); // 7 original + 1 duplicate + + // All corners in chart B referencing the pinch point should use the same new index. + auto indices = get_uv_indices(mesh); + // tri2 corner0 and tri3 corner0 both reference the duplicated vertex 2 + CHECK(indices[6] == indices[9]); + // And they should differ from chart A's references to vertex 2 + CHECK(indices[2] != indices[6]); + } + + SECTION("Interleaved chart ordering") + { + // Facets from two charts are interleaved: A, B, A, B. + // UV vertex 0 is shared across both charts. + // This tests that switching back to chart A after seeing chart B still works correctly. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); // shared + 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({2, 0, 0}); + mesh.add_vertex({1.5, 1, 0}); + mesh.add_vertex({-2, 0, 0}); + mesh.add_vertex({-1.5, 1, 0}); + + // Chart A: tri(0,1,2) and tri(0,5,6) — no shared edge, so 2 separate charts + // unless we force them with a chart_id attribute. + mesh.add_triangle(0, 1, 2); // f0: chart A + mesh.add_triangle(0, 3, 4); // f1: chart B + mesh.add_triangle(0, 5, 6); // f2: chart A + mesh.add_triangle(0, 7, 8); // f3: chart B + + add_indexed_uv( + mesh, + {0, 0, 1, 0, 0.5, 1, -1, 0, -0.5, 1, 2, 0, 1.5, 1, -2, 0, -1.5, 1}, + {0, 1, 2, 0, 3, 4, 0, 5, 6, 0, 7, 8}); + + // Force chart assignment: A=0, B=1, A=0, B=1 + mesh.template create_attribute( + "@chart_id", + AttributeElement::Facet, + AttributeUsage::Scalar, + 1, + std::vector{0, 1, 0, 1}); + + 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 + + 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]); + } +} + +TEST_CASE("disconnect_uv_charts: empty mesh", "[surface][utilities]") +{ + SECTION("No vertices, no facets") + { + SurfaceMesh mesh; + add_indexed_uv(mesh, {}, {}); + + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 0); + CHECK(get_num_uv_values(mesh) == 0); + } + + SECTION("Vertices but no facets") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + + // Indexed UV with 3 values but no index entries (no facets -> no corners). + // After separation, unreferenced UV values are compacted away. + add_indexed_uv(mesh, {0, 0, 1, 0, 0, 1}, {}); + + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 0); + CHECK(get_num_uv_values(mesh) == 0); + } +} + +TEST_CASE("disconnect_uv_charts: different UV scalar type", "[surface][utilities]") +{ + using UVScalar = float; + + SECTION("Single chart with float UVs - no 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.f, 0.f, 1.f, 0.f, 0.f, 1.f, 1.f, 1.f}, + {0, 1, 2, 1, 3, 2}); + + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 0); + CHECK(get_num_uv_values(mesh) == 4); + } + + SECTION("Two charts sharing a UV vertex (pinch point) with float UVs") + { + 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.f, 0.f, 1.f, 0.f, 0.5f, 0.5f, 0.f, 1.f, 1.f, 1.f}, + {0, 1, 2, 2, 3, 4}); + + auto num_duped = disconnect_uv_charts(mesh); + CHECK(num_duped == 1); + CHECK(get_num_uv_values(mesh) == 6); + + auto indices = get_uv_indices(mesh); + std::set tri0_uvs(indices.begin(), indices.begin() + 3); + std::set tri1_uvs(indices.begin() + 3, indices.begin() + 6); + std::vector intersection; + std::set_intersection( + tri0_uvs.begin(), + tri0_uvs.end(), + tri1_uvs.begin(), + tri1_uvs.end(), + std::back_inserter(intersection)); + CHECK(intersection.empty()); + } + + SECTION("UV values preserved after duplication with float UVs") + { + 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); + + const UVScalar pinch_u = 0.5f, pinch_v = 0.5f; + add_indexed_uv( + mesh, + {0.f, 0.f, 1.f, 0.f, pinch_u, pinch_v, 0.f, 1.f, 1.f, 1.f}, + {0, 1, 2, 2, 3, 4}); + + disconnect_uv_charts(mesh); + + auto& attr = mesh.template get_indexed_attribute(mesh.get_attribute_id("uv")); + auto values = matrix_view(attr.values()); + auto indices = get_uv_indices(mesh); + + Index uv_idx_tri0 = indices[2]; + Index uv_idx_tri1 = indices[3]; + CHECK(uv_idx_tri0 != uv_idx_tri1); + CHECK(values(uv_idx_tri0, 0) == Catch::Approx(pinch_u)); + CHECK(values(uv_idx_tri0, 1) == Catch::Approx(pinch_v)); + CHECK(values(uv_idx_tri1, 0) == Catch::Approx(pinch_u)); + CHECK(values(uv_idx_tri1, 1) == Catch::Approx(pinch_v)); + } +} diff --git a/modules/core/tests/test_foreach_attribute.cpp b/modules/core/tests/test_foreach_attribute.cpp index 933cacdb..f5785663 100644 --- a/modules/core/tests/test_foreach_attribute.cpp +++ b/modules/core/tests/test_foreach_attribute.cpp @@ -24,6 +24,7 @@ #include #include #include +#include // clang-format on #include @@ -72,10 +73,10 @@ void test_foreach_attribute() std::atomic_int cnt = 0; for (auto elem : attribute_elements) { - std::string name = fmt::format("attr_{}", cnt++); + std::string name = lagrange::format("attr_{}", cnt++); mesh.template create_attribute(name, elem); for (size_t i = 0; i < 50; ++i) { - mesh.duplicate_attribute(name, fmt::format("attr_{}", cnt++)); + mesh.duplicate_attribute(name, lagrange::format("attr_{}", cnt++)); } } @@ -312,9 +313,11 @@ void test_foreach_cow() for (int i = 0; i < N; ++i) { mesh.template create_attribute( - fmt::format("attr_{}_1", i), + lagrange::format("attr_{}_1", i), AttributeElement::Vertex); - mesh.duplicate_attribute(fmt::format("attr_{}_1", i), fmt::format("attr_{}_2", i)); + mesh.duplicate_attribute( + lagrange::format("attr_{}_1", i), + lagrange::format("attr_{}_2", i)); } std::map> ptr; @@ -337,8 +340,11 @@ void test_foreach_cow() const void* after1 = kv.second.second; if (starts_with(name, "attr_")) { auto tokens = string_split(std::string(name), '_'); - std::string other = - fmt::format("{}_{}_{}", tokens[0], tokens[1], tokens.back() == "1" ? "2" : "1"); + std::string other = lagrange::format( + "{}_{}_{}", + tokens[0], + tokens[1], + tokens.back() == "1" ? "2" : "1"); const void* before2 = ptr.at(other).first; const void* after2 = ptr.at(other).second; CAPTURE(name, before1, after1, before2, after2); @@ -362,9 +368,11 @@ void test_foreach_cow() for (int i = 0; i < N; ++i) { mesh.template create_attribute( - fmt::format("attr_{}_1", i), + lagrange::format("attr_{}_1", i), AttributeElement::Vertex); - mesh.duplicate_attribute(fmt::format("attr_{}_1", i), fmt::format("attr_{}_2", i)); + mesh.duplicate_attribute( + lagrange::format("attr_{}_1", i), + lagrange::format("attr_{}_2", i)); } std::map> ptr; @@ -387,8 +395,11 @@ void test_foreach_cow() const void* after1 = kv.second.second; if (starts_with(name, "attr_")) { auto tokens = string_split(std::string(name), '_'); - std::string other = - fmt::format("{}_{}_{}", tokens[0], tokens[1], tokens.back() == "1" ? "2" : "1"); + std::string other = lagrange::format( + "{}_{}_{}", + tokens[0], + tokens[1], + tokens.back() == "1" ? "2" : "1"); const void* before2 = ptr.at(other).first; const void* after2 = ptr.at(other).second; CAPTURE(name, before1, after1, before2, after2); @@ -423,9 +434,11 @@ void test_foreach_cow() }); for (int i = 0; i < N; ++i) { mesh.template create_attribute( - fmt::format("attr_{}_1", i), + lagrange::format("attr_{}_1", i), AttributeElement::Vertex); - mesh.duplicate_attribute(fmt::format("attr_{}_1", i), fmt::format("attr_{}_2", i)); + mesh.duplicate_attribute( + lagrange::format("attr_{}_1", i), + lagrange::format("attr_{}_2", i)); } seq_foreach_named_attribute_read<~AttributeElement::Indexed>( mesh, diff --git a/modules/core/tests/test_separate_by_facet_groups.cpp b/modules/core/tests/test_separate_by_facet_groups.cpp new file mode 100644 index 00000000..f9caeaec --- /dev/null +++ b/modules/core/tests/test_separate_by_facet_groups.cpp @@ -0,0 +1,697 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +using Scalar = float; +using Index = uint32_t; + +/// Validate that a submesh correctly represents a subset of the source mesh. +/// Checks positions, facet connectivity via source mappings, and attributes. +void validate_submesh( + const lagrange::SurfaceMesh& source, + const lagrange::SurfaceMesh& submesh, + std::string_view source_vertex_attr_name, + std::string_view source_facet_attr_name) +{ + if (submesh.get_num_facets() == 0 && submesh.get_num_vertices() == 0) return; + + REQUIRE(submesh.has_attribute(source_vertex_attr_name)); + REQUIRE(submesh.has_attribute(source_facet_attr_name)); + + auto vertex_mapping = lagrange::attribute_vector_view(submesh, source_vertex_attr_name); + auto facet_mapping = lagrange::attribute_vector_view(submesh, source_facet_attr_name); + + const Index num_vertices = submesh.get_num_vertices(); + const Index num_facets = submesh.get_num_facets(); + + // Validate vertex positions. + for (Index i = 0; i < num_vertices; i++) { + auto src_pos = source.get_position(vertex_mapping[i]); + auto out_pos = submesh.get_position(i); + REQUIRE(std::equal(src_pos.begin(), src_pos.end(), out_pos.begin())); + } + + // Validate facet connectivity. + for (Index i = 0; i < num_facets; i++) { + Index src_fi = facet_mapping[i]; + REQUIRE(submesh.get_facet_size(i) == source.get_facet_size(src_fi)); + auto src_f = source.get_facet_vertices(src_fi); + auto out_f = submesh.get_facet_vertices(i); + for (Index k = 0; k < submesh.get_facet_size(i); k++) { + REQUIRE(src_f[k] == vertex_mapping[out_f[k]]); + } + } +} + +/// Validate indexed attributes by comparing resolved per-corner values. +void validate_indexed_attributes( + const lagrange::SurfaceMesh& source, + const lagrange::SurfaceMesh& submesh, + std::string_view source_facet_attr_name) +{ + if (submesh.get_num_facets() == 0) return; + + auto facet_mapping = lagrange::attribute_vector_view(submesh, source_facet_attr_name); + + lagrange::seq_foreach_named_attribute_read( + source, + [&](std::string_view name, auto&& attr) { + if (source.attr_name_is_reserved(name)) return; + REQUIRE(submesh.has_attribute(name)); + + using AttributeType = std::decay_t; + using ValueType = typename AttributeType::ValueType; + + auto src_values = lagrange::matrix_view(attr.values()); + auto src_indices = lagrange::vector_view(attr.indices()); + const auto& target_attr = submesh.template get_indexed_attribute(name); + auto tgt_values = lagrange::matrix_view(target_attr.values()); + auto tgt_indices = lagrange::vector_view(target_attr.indices()); + + const Index num_facets = submesh.get_num_facets(); + for (Index fi = 0; fi < num_facets; fi++) { + Index src_fi = facet_mapping[fi]; + Index fsize = submesh.get_facet_size(fi); + for (Index lv = 0; lv < fsize; lv++) { + Index out_ci = submesh.get_facet_corner_begin(fi) + lv; + Index src_ci = source.get_facet_corner_begin(src_fi) + lv; + Index out_val = tgt_indices(out_ci); + Index src_val = src_indices(src_ci); + REQUIRE(tgt_values.row(out_val) == src_values.row(src_val)); + } + } + }); +} + +} // namespace + +TEST_CASE("separate_by_facet_groups: empty mesh", "[core][utilities][separate]") +{ + lagrange::SurfaceMesh mesh(3); + std::vector groups; + lagrange::span groups_span(groups.data(), groups.size()); + auto result = lagrange::separate_by_facet_groups(mesh, size_t{0}, groups_span, {}); + REQUIRE(result.empty()); +} + +TEST_CASE("separate_by_facet_groups: single group", "[core][utilities][separate]") +{ + auto mesh = lagrange::testing::create_test_cube(); + const Index num_facets = mesh.get_num_facets(); + + std::vector group_ids(num_facets, 0); + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@source_vertex"; + options.source_facet_attr_name = "@source_facet"; + options.map_attributes = true; + + auto result = lagrange::separate_by_facet_groups( + mesh, + size_t{1}, + lagrange::span(group_ids), + options); + REQUIRE(result.size() == 1); + REQUIRE(result[0].get_num_vertices() == mesh.get_num_vertices()); + REQUIRE(result[0].get_num_facets() == mesh.get_num_facets()); + + validate_submesh( + mesh, + result[0], + options.source_vertex_attr_name, + options.source_facet_attr_name); + validate_indexed_attributes(mesh, result[0], options.source_facet_attr_name); +} + +TEST_CASE("separate_by_facet_groups: two groups", "[core][utilities][separate]") +{ + auto mesh = lagrange::testing::create_test_cube(); + const Index num_facets = mesh.get_num_facets(); + + // Split into two groups: first half and second half. + std::vector group_ids(num_facets); + for (Index i = 0; i < num_facets; i++) { + group_ids[i] = (i < num_facets / 2) ? 0 : 1; + } + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@source_vertex"; + options.source_facet_attr_name = "@source_facet"; + + auto result = lagrange::separate_by_facet_groups( + mesh, + size_t{2}, + lagrange::span(group_ids), + options); + REQUIRE(result.size() == 2); + REQUIRE(result[0].get_num_facets() == num_facets / 2); + REQUIRE(result[1].get_num_facets() == num_facets - num_facets / 2); + + validate_submesh( + mesh, + result[0], + options.source_vertex_attr_name, + options.source_facet_attr_name); + validate_submesh( + mesh, + result[1], + options.source_vertex_attr_name, + options.source_facet_attr_name); +} + +TEST_CASE("separate_by_facet_groups: many small groups", "[core][utilities][separate]") +{ + // Create N disconnected triangles by combining many cubes. + constexpr size_t N = 50; + auto cube = lagrange::testing::create_test_cube(); + auto mesh = lagrange::combine_meshes( + N, + [&](size_t) -> const lagrange::SurfaceMesh& { return cube; }); + + const Index num_facets = mesh.get_num_facets(); + const Index facets_per_cube = cube.get_num_facets(); + + // Assign each cube copy its own group. + std::vector group_ids(num_facets); + for (Index i = 0; i < num_facets; i++) { + group_ids[i] = i / facets_per_cube; + } + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@source_vertex"; + options.source_facet_attr_name = "@source_facet"; + + auto result = lagrange::separate_by_facet_groups( + mesh, + N, + lagrange::span(group_ids), + options); + REQUIRE(result.size() == N); + + for (size_t g = 0; g < N; g++) { + REQUIRE(result[g].get_num_facets() == facets_per_cube); + REQUIRE(result[g].get_num_vertices() == cube.get_num_vertices()); + validate_submesh( + mesh, + result[g], + options.source_vertex_attr_name, + options.source_facet_attr_name); + } +} + +TEST_CASE("separate_by_facet_groups: shared vertices across groups", "[core][utilities][separate]") +{ + // Build a simple mesh: two triangles sharing an edge (vertices 0,1 shared). + // Triangle 0: (0,1,2), Triangle 1: (0,1,3) + lagrange::SurfaceMesh mesh(3); + mesh.add_vertices(4); + auto V = lagrange::vertex_ref(mesh); + V.row(0) << 0, 0, 0; + V.row(1) << 1, 0, 0; + V.row(2) << 0, 1, 0; + V.row(3) << 1, 1, 0; + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 1, 3); + + std::vector group_ids = {0, 1}; + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@source_vertex"; + options.source_facet_attr_name = "@source_facet"; + + auto result = lagrange::separate_by_facet_groups( + mesh, + size_t{2}, + lagrange::span(group_ids), + options); + REQUIRE(result.size() == 2); + + // Each triangle should have 3 vertices (shared vertices duplicated). + REQUIRE(result[0].get_num_vertices() == 3); + REQUIRE(result[0].get_num_facets() == 1); + REQUIRE(result[1].get_num_vertices() == 3); + REQUIRE(result[1].get_num_facets() == 1); + + validate_submesh( + mesh, + result[0], + options.source_vertex_attr_name, + options.source_facet_attr_name); + validate_submesh( + mesh, + result[1], + options.source_vertex_attr_name, + options.source_facet_attr_name); +} + +TEST_CASE("separate_by_facet_groups: attribute mapping", "[core][utilities][separate]") +{ + auto mesh = lagrange::testing::create_test_cube(); + lagrange::compute_facet_normal(mesh); + lagrange::compute_vertex_normal(mesh); + + const Index num_facets = mesh.get_num_facets(); + std::vector group_ids(num_facets); + for (Index i = 0; i < num_facets; i++) { + group_ids[i] = (i < num_facets / 2) ? 0 : 1; + } + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@source_vertex"; + options.source_facet_attr_name = "@source_facet"; + options.map_attributes = true; + + auto result = lagrange::separate_by_facet_groups( + mesh, + size_t{2}, + lagrange::span(group_ids), + options); + REQUIRE(result.size() == 2); + + for (auto& submesh : result) { + validate_submesh( + mesh, + submesh, + options.source_vertex_attr_name, + options.source_facet_attr_name); + validate_indexed_attributes(mesh, submesh, options.source_facet_attr_name); + } +} + +TEST_CASE("separate_by_facet_groups: source mapping attributes", "[core][utilities][separate]") +{ + auto mesh = lagrange::testing::create_test_cube(); + const Index num_facets = mesh.get_num_facets(); + + std::vector group_ids(num_facets, 0); + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@sv"; + options.source_facet_attr_name = "@sf"; + + auto result = lagrange::separate_by_facet_groups( + mesh, + size_t{1}, + lagrange::span(group_ids), + options); + REQUIRE(result.size() == 1); + REQUIRE(result[0].has_attribute("@sv")); + REQUIRE(result[0].has_attribute("@sf")); + + auto sv = lagrange::attribute_vector_view(result[0], "@sv"); + auto sf = lagrange::attribute_vector_view(result[0], "@sf"); + + // All vertices and facets should map to valid source indices. + for (Index i = 0; i < result[0].get_num_vertices(); i++) { + REQUIRE(sv[i] < mesh.get_num_vertices()); + } + for (Index i = 0; i < result[0].get_num_facets(); i++) { + REQUIRE(sf[i] < mesh.get_num_facets()); + } +} + +TEST_CASE("separate_by_facet_groups: hybrid mesh", "[core][utilities][separate]") +{ + // Build a hybrid mesh with triangles and quads. + lagrange::SurfaceMesh mesh(3); + mesh.add_vertices(6); + auto V = lagrange::vertex_ref(mesh); + V.row(0) << 0, 0, 0; + V.row(1) << 1, 0, 0; + V.row(2) << 1, 1, 0; + V.row(3) << 0, 1, 0; + V.row(4) << 2, 0, 0; + V.row(5) << 2, 1, 0; + + // Quad: (0,1,2,3), Triangle: (1,4,5) + mesh.add_quad(0, 1, 2, 3); + mesh.add_triangle(1, 4, 5); + + std::vector group_ids = {0, 1}; + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@source_vertex"; + options.source_facet_attr_name = "@source_facet"; + + auto result = lagrange::separate_by_facet_groups( + mesh, + size_t{2}, + lagrange::span(group_ids), + options); + REQUIRE(result.size() == 2); + REQUIRE(result[0].get_num_facets() == 1); + REQUIRE(result[0].get_num_vertices() == 4); + REQUIRE(result[0].get_facet_size(0) == 4); // quad + REQUIRE(result[1].get_num_facets() == 1); + REQUIRE(result[1].get_num_vertices() == 3); + REQUIRE(result[1].get_facet_size(0) == 3); // triangle + + validate_submesh( + mesh, + result[0], + options.source_vertex_attr_name, + options.source_facet_attr_name); + validate_submesh( + mesh, + result[1], + options.source_vertex_attr_name, + options.source_facet_attr_name); +} + +TEST_CASE("separate_by_facet_groups: function_ref overload", "[core][utilities][separate]") +{ + auto mesh = lagrange::testing::create_test_cube(); + const Index num_facets = mesh.get_num_facets(); + + // Compare span-based and function_ref-based overloads. + std::vector group_ids(num_facets); + for (Index i = 0; i < num_facets; i++) { + group_ids[i] = (i < num_facets / 2) ? 0 : 1; + } + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@source_vertex"; + options.source_facet_attr_name = "@source_facet"; + + auto result_span = lagrange::separate_by_facet_groups( + mesh, + size_t{2}, + lagrange::span(group_ids), + options); + + auto result_func = lagrange::separate_by_facet_groups( + mesh, + size_t{2}, + lagrange::function_ref( + [&](Index fi) -> Index { return (fi < num_facets / 2) ? 0 : 1; }), + options); + + REQUIRE(result_span.size() == result_func.size()); + for (size_t g = 0; g < result_span.size(); g++) { + REQUIRE(result_span[g].get_num_vertices() == result_func[g].get_num_vertices()); + REQUIRE(result_span[g].get_num_facets() == result_func[g].get_num_facets()); + } +} + +TEST_CASE("separate_by_facet_groups: reference equivalence", "[core][utilities][separate]") +{ + auto mesh = lagrange::testing::create_test_cube(); + lagrange::compute_facet_normal(mesh); + lagrange::compute_vertex_normal(mesh); + + const Index num_facets = mesh.get_num_facets(); + std::vector group_ids(num_facets); + for (Index i = 0; i < num_facets; i++) { + group_ids[i] = i % 3; + } + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@source_vertex"; + options.source_facet_attr_name = "@source_facet"; + options.map_attributes = true; + + auto result = lagrange::separate_by_facet_groups( + mesh, + size_t{3}, + lagrange::span(group_ids), + options); + + // Build reference results by manually calling extract_submesh per group. + // Sort facets into groups. + std::vector> facets_per_group(3); + for (Index i = 0; i < num_facets; i++) { + facets_per_group[group_ids[i]].push_back(i); + } + + lagrange::SubmeshOptions sub_options; + sub_options.source_vertex_attr_name = options.source_vertex_attr_name; + sub_options.source_facet_attr_name = options.source_facet_attr_name; + sub_options.map_attributes = true; + + for (size_t g = 0; g < 3; g++) { + auto ref = lagrange::extract_submesh( + mesh, + lagrange::span(facets_per_group[g]), + sub_options); + REQUIRE(result[g].get_num_vertices() == ref.get_num_vertices()); + REQUIRE(result[g].get_num_facets() == ref.get_num_facets()); + + // Compare positions. + auto ref_v = lagrange::vertex_view(ref); + auto out_v = lagrange::vertex_view(result[g]); + REQUIRE(ref_v == out_v); + + // Compare facet connectivity. + for (Index fi = 0; fi < ref.get_num_facets(); fi++) { + auto ref_f = ref.get_facet_vertices(fi); + auto out_f = result[g].get_facet_vertices(fi); + REQUIRE(std::equal(ref_f.begin(), ref_f.end(), out_f.begin())); + } + + // Compare source mapping attributes. + auto ref_sv = lagrange::attribute_vector_view(ref, "@source_vertex"); + auto out_sv = lagrange::attribute_vector_view(result[g], "@source_vertex"); + REQUIRE(std::equal(ref_sv.begin(), ref_sv.end(), out_sv.begin())); + + auto ref_sf = lagrange::attribute_vector_view(ref, "@source_facet"); + auto out_sf = lagrange::attribute_vector_view(result[g], "@source_facet"); + REQUIRE(std::equal(ref_sf.begin(), ref_sf.end(), out_sf.begin())); + + // Compare indexed attributes by resolved per-corner values. + validate_indexed_attributes(mesh, result[g], "@source_facet"); + } +} + +TEST_CASE("separate_by_facet_groups: groups with zero facets", "[core][utilities][separate]") +{ + auto mesh = lagrange::testing::create_test_cube(); + const Index num_facets = mesh.get_num_facets(); + + // All facets in group 0, but claim there are 5 groups. + std::vector group_ids(num_facets, 0); + + auto result = lagrange::separate_by_facet_groups( + mesh, + size_t{5}, + lagrange::span(group_ids), + {}); + REQUIRE(result.size() == 5); + REQUIRE(result[0].get_num_facets() == num_facets); + for (size_t g = 1; g < 5; g++) { + REQUIRE(result[g].get_num_facets() == 0); + REQUIRE(result[g].get_num_vertices() == 0); + } +} + +TEST_CASE( + "separate_by_facet_groups: single-facet groups on hybrid input", + "[core][utilities][separate]") +{ + // Build a hybrid mesh: mix of tris and quads, one facet per group. + lagrange::SurfaceMesh mesh(3); + mesh.add_vertices(7); + auto V = lagrange::vertex_ref(mesh); + V.row(0) << 0, 0, 0; + V.row(1) << 1, 0, 0; + V.row(2) << 0.5f, 1, 0; + V.row(3) << 2, 0, 0; + V.row(4) << 3, 0, 0; + V.row(5) << 3, 1, 0; + V.row(6) << 2, 1, 0; + + mesh.add_triangle(0, 1, 2); + mesh.add_quad(3, 4, 5, 6); + + std::vector group_ids = {0, 1}; + + lagrange::SeparateByFacetGroupsOptions options; + options.source_vertex_attr_name = "@sv"; + options.source_facet_attr_name = "@sf"; + + auto result = lagrange::separate_by_facet_groups( + mesh, + size_t{2}, + lagrange::span(group_ids), + options); + REQUIRE(result.size() == 2); + REQUIRE(result[0].get_num_facets() == 1); + REQUIRE(result[0].get_facet_size(0) == 3); + REQUIRE(result[1].get_num_facets() == 1); + REQUIRE(result[1].get_facet_size(0) == 4); + + validate_submesh(mesh, result[0], "@sv", "@sf"); + validate_submesh(mesh, result[1], "@sv", "@sf"); +} + +TEST_CASE( + "separate_by_facet_groups: span overload (auto num_groups)", + "[core][utilities][separate]") +{ + auto mesh = lagrange::testing::create_test_cube(); + const Index num_facets = mesh.get_num_facets(); + + std::vector group_ids(num_facets); + for (Index i = 0; i < num_facets; i++) { + group_ids[i] = i % 4; + } + + // Use the overload that deduces num_groups from max element. + auto result = + lagrange::separate_by_facet_groups(mesh, lagrange::span(group_ids), {}); + REQUIRE(result.size() == 4); + + Index total_facets = 0; + for (auto& m : result) { + total_facets += m.get_num_facets(); + } + REQUIRE(total_facets == num_facets); +} + +TEST_CASE("separate_by_facet_groups benchmark", "[core][utilities][separate][!benchmark]") +{ + using BScalar = double; + using BIndex = uint32_t; + + SECTION("many tiny groups") + { + auto cube = lagrange::testing::create_test_cube(); + lagrange::split_long_edges(cube); + constexpr size_t N = 1000; + auto mesh = lagrange::combine_meshes( + N, + [&](size_t) -> const lagrange::SurfaceMesh& { return cube; }); + lagrange::logger().info( + "Mesh has {} vertices and {} facets", + mesh.get_num_vertices(), + mesh.get_num_facets()); + + const BIndex num_facets = mesh.get_num_facets(); + const BIndex facets_per_cube = cube.get_num_facets(); + std::vector group_ids(num_facets); + for (BIndex i = 0; i < num_facets; i++) { + group_ids[i] = i / facets_per_cube; + } + + BENCHMARK("1000 tiny groups") + { + return lagrange::separate_by_facet_groups( + mesh, + N, + lagrange::span(group_ids), + {}); + }; + } + + SECTION("few large groups") + { + auto cube = lagrange::testing::create_test_cube(); + auto sphere = lagrange::testing::create_test_sphere(); + lagrange::split_long_edges(cube); + lagrange::split_long_edges(sphere); + cube.delete_attribute("@normal"); + auto mesh = lagrange::combine_meshes({&cube, &sphere}); + lagrange::logger().info( + "Mesh has {} vertices and {} facets", + mesh.get_num_vertices(), + mesh.get_num_facets()); + + const BIndex num_facets = mesh.get_num_facets(); + std::vector group_ids(num_facets); + for (BIndex i = 0; i < num_facets; i++) { + group_ids[i] = (i < cube.get_num_facets()) ? 0 : 1; + } + + BENCHMARK("2 large groups") + { + return lagrange::separate_by_facet_groups( + mesh, + size_t{2}, + lagrange::span(group_ids), + {}); + }; + } + + SECTION("single group") + { + auto cube = lagrange::testing::create_test_cube(); + lagrange::split_long_edges(cube); + const BIndex num_facets = cube.get_num_facets(); + std::vector group_ids(num_facets, 0); + lagrange::logger().info( + "Mesh has {} vertices and {} facets", + cube.get_num_vertices(), + cube.get_num_facets()); + + BENCHMARK("single group") + { + return lagrange::separate_by_facet_groups( + cube, + size_t{1}, + lagrange::span(group_ids), + {}); + }; + } + + SECTION("many tiny groups with attributes") + { + auto cube = lagrange::testing::create_test_cube(); + lagrange::split_long_edges(cube); + lagrange::compute_facet_normal(cube); + lagrange::compute_vertex_normal(cube); + + constexpr size_t N = 1000; + auto mesh = lagrange::combine_meshes( + N, + [&](size_t) -> const lagrange::SurfaceMesh& { return cube; }); + lagrange::logger().info( + "Mesh has {} vertices and {} facets", + mesh.get_num_vertices(), + mesh.get_num_facets()); + mesh.delete_attribute("@edge_length"); + mesh.clear_edges(); + + const BIndex num_facets = mesh.get_num_facets(); + const BIndex facets_per_cube = cube.get_num_facets(); + std::vector group_ids(num_facets); + for (BIndex i = 0; i < num_facets; i++) { + group_ids[i] = i / facets_per_cube; + } + + lagrange::SeparateByFacetGroupsOptions options; + options.map_attributes = true; + + BENCHMARK("1000 tiny groups with attrs") + { + return lagrange::separate_by_facet_groups( + mesh, + N, + lagrange::span(group_ids), + options); + }; + } +} diff --git a/modules/core/tests/test_surface_mesh.cpp b/modules/core/tests/test_surface_mesh.cpp index be1add75..bd494ba4 100644 --- a/modules/core/tests/test_surface_mesh.cpp +++ b/modules/core/tests/test_surface_mesh.cpp @@ -27,6 +27,7 @@ #include #include #include +#include // clang-format on #include @@ -1034,13 +1035,13 @@ void test_normal_attribute() for (size_t num_channels = kmin; num_channels <= kmax; ++num_channels) { if (num_channels == dim || num_channels == dim + 1) { REQUIRE_NOTHROW(mesh.template create_attribute( - fmt::format("normals_{}", num_channels), + lagrange::format("normals_{}", num_channels), AttributeElement::Vertex, AttributeUsage::Normal, num_channels)); } else { LA_REQUIRE_THROWS(mesh.template create_attribute( - fmt::format("normals_{}", num_channels), + lagrange::format("normals_{}", num_channels), AttributeElement::Vertex, AttributeUsage::Normal, num_channels)); @@ -2284,7 +2285,7 @@ void test_element_index_type() }; int cnt = 0; - auto get_name = [&cnt]() { return fmt::format("id_{}", cnt++); }; + auto get_name = [&cnt]() { return lagrange::format("id_{}", cnt++); }; for (auto usage : usages) { if constexpr (std::is_same_v) { REQUIRE_NOTHROW(mesh.template create_attribute(get_name(), elem, usage)); @@ -3070,6 +3071,30 @@ void test_foreach_facet_around_facet() } } +template +void test_edge_data() +{ + lagrange::SurfaceMesh mesh; + mesh.add_vertices(4); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + + REQUIRE_NOTHROW(mesh.clear_edges()); + + mesh.initialize_edges(); + REQUIRE(mesh.has_edges()); + REQUIRE_NOTHROW(mesh.initialize_edges()); + + mesh.template create_attribute("edge_id", lagrange::AttributeElement::Edge); + REQUIRE(mesh.has_attribute("edge_id")); + + mesh.clear_edges(); + REQUIRE_NOTHROW(mesh.clear_edges()); + + REQUIRE(mesh.has_attribute("edge_id")); + REQUIRE(mesh.template get_attribute("edge_id").get_num_elements() == 0); +} + } // namespace TEST_CASE("SurfaceMesh Construction", "[mesh]") @@ -3293,3 +3318,9 @@ TEST_CASE("SurfaceMesh: foreach_facet_around_facet", "[mesh]") test_foreach_facet_around_facet(); LA_SURFACE_MESH_X(test_foreach_facet_around_facet, 0) } + +TEST_CASE("SurfaceMesh: edge data", "[mesh]") +{ +#define LA_X_test_edge_data(_, Scalar, Index) test_edge_data(); + LA_SURFACE_MESH_X(test_edge_data, 0) +} diff --git a/modules/core/tests/test_triangle_triangle_intersection.cpp b/modules/core/tests/test_triangle_triangle_intersection.cpp new file mode 100644 index 00000000..8bea73d2 --- /dev/null +++ b/modules/core/tests/test_triangle_triangle_intersection.cpp @@ -0,0 +1,872 @@ +/* + * 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 + +TEST_CASE("triangle_triangle_intersection", "[utils][triangle_intersection]") +{ + using namespace lagrange; + + SECTION("Non-intersecting triangles - separated") + { + // Triangle 1 in XY plane at z=0 + double t1_v0[3] = {0.0, 0.0, 0.0}; + double t1_v1[3] = {1.0, 0.0, 0.0}; + double t1_v2[3] = {0.0, 1.0, 0.0}; + + // Triangle 2 in XY plane at z=1 (parallel, separated) + double t2_v0[3] = {0.0, 0.0, 1.0}; + double t2_v1[3] = {1.0, 0.0, 1.0}; + double t2_v2[3] = {0.0, 1.0, 1.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + REQUIRE_FALSE(triangle_triangle_intersection(s1_v0, s1_v1, s1_v2, s2_v0, s2_v1, s2_v2)); + } + + SECTION("Non-intersecting triangles - same plane, no overlap") + { + // Triangle 1 + double t1_v0[3] = {0.0, 0.0, 0.0}; + double t1_v1[3] = {1.0, 0.0, 0.0}; + double t1_v2[3] = {0.0, 1.0, 0.0}; + + // Triangle 2 in same plane but far away + double t2_v0[3] = {5.0, 5.0, 0.0}; + double t2_v1[3] = {6.0, 5.0, 0.0}; + double t2_v2[3] = {5.0, 6.0, 0.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + REQUIRE_FALSE(triangle_triangle_intersection(s1_v0, s1_v1, s1_v2, s2_v0, s2_v1, s2_v2)); + } + + SECTION("Intersecting triangles - crossing") + { + // Triangle 1 in XY plane + double t1_v0[3] = {-1.0, 0.0, 0.0}; + double t1_v1[3] = {1.0, 0.0, 0.0}; + double t1_v2[3] = {0.0, 1.0, 0.0}; + + // Triangle 2 crossing through triangle 1 + double t2_v0[3] = {0.0, 0.5, -1.0}; + double t2_v1[3] = {0.0, 0.5, 1.0}; + double t2_v2[3] = {0.0, -0.5, 0.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + REQUIRE(triangle_triangle_intersection(s1_v0, s1_v1, s1_v2, s2_v0, s2_v1, s2_v2)); + } + + SECTION("Intersecting triangles - coplanar overlap") + { + // Triangle 1 + double t1_v0[3] = {0.0, 0.0, 0.0}; + double t1_v1[3] = {2.0, 0.0, 0.0}; + double t1_v2[3] = {0.0, 2.0, 0.0}; + + // Triangle 2 overlapping in same plane + double t2_v0[3] = {0.5, 0.5, 0.0}; + double t2_v1[3] = {1.5, 0.5, 0.0}; + double t2_v2[3] = {0.5, 1.5, 0.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + REQUIRE(triangle_triangle_intersection(s1_v0, s1_v1, s1_v2, s2_v0, s2_v1, s2_v2)); + } + + SECTION("Touching triangles - shared vertex") + { + // Triangle 1 + double t1_v0[3] = {0.0, 0.0, 0.0}; + double t1_v1[3] = {1.0, 0.0, 0.0}; + double t1_v2[3] = {0.0, 1.0, 0.0}; + + // Triangle 2 sharing a vertex but on different planes + double t2_v0[3] = {0.0, 0.0, 0.0}; // Shared vertex + double t2_v1[3] = {1.0, 0.0, 1.0}; + double t2_v2[3] = {0.0, 1.0, 1.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + // Touching at a single shared vertex is a boundary-only contact. + REQUIRE_FALSE(triangle_triangle_intersection(s1_v0, s1_v1, s1_v2, s2_v0, s2_v1, s2_v2)); + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Span interface - float") + { + using Scalar = float; + + Scalar t1_v0_data[3] = {0.0f, 0.0f, 0.0f}; + Scalar t1_v1_data[3] = {1.0f, 0.0f, 0.0f}; + Scalar t1_v2_data[3] = {0.0f, 1.0f, 0.0f}; + + Scalar t2_v0_data[3] = {0.0f, 0.5f, -1.0f}; + Scalar t2_v1_data[3] = {0.0f, 0.5f, 1.0f}; + Scalar t2_v2_data[3] = {0.0f, -0.5f, 0.0f}; + + span t1_v0(t1_v0_data, 3); + span t1_v1(t1_v1_data, 3); + span t1_v2(t1_v2_data, 3); + span t2_v0(t2_v0_data, 3); + span t2_v1(t2_v1_data, 3); + span t2_v2(t2_v2_data, 3); + + REQUIRE(triangle_triangle_intersection(t1_v0, t1_v1, t1_v2, t2_v0, t2_v1, t2_v2)); + } + + SECTION("Edge case - degenerate triangle") + { + // Triangle 1 - normal triangle + double t1_v0[3] = {0.0, 0.0, 0.0}; + double t1_v1[3] = {1.0, 0.0, 0.0}; + double t1_v2[3] = {0.0, 1.0, 0.0}; + + // Triangle 2 - degenerate (all vertices collinear) + double t2_v0[3] = {0.5, 0.5, 0.0}; + double t2_v1[3] = {0.6, 0.6, 0.0}; + double t2_v2[3] = {0.7, 0.7, 0.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + // Degenerate triangles should be handled gracefully + auto result = triangle_triangle_intersection(s1_v0, s1_v1, s1_v2, s2_v0, s2_v1, s2_v2); + // Accept any result for degenerate case + (void)result; + } + + SECTION("Coplanar - bowtie configuration (sharing single vertex)") + { + // ._____. + // \ / + // \ / + // * + // / \. + // / \. + // /_____\. + + // Triangle 1 + double t1_v0[3] = {0.0, 0.0, 0.0}; // Shared vertex + double t1_v1[3] = {1.0, 1.0, 0.0}; + double t1_v2[3] = {-1.0, 1.0, 0.0}; + + // Triangle 2 + double t2_v0[3] = {0.0, 0.0, 0.0}; // Shared vertex + double t2_v1[3] = {-1.0, -1.0, 0.0}; + double t2_v2[3] = {1.0, -1.0, 0.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + // Boundary OFF: No intersection (only touching at shared vertex) + REQUIRE_FALSE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection (touching at vertex counts) + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Coplanar - nested triangles (sharing single vertex)") + { + // Large triangle + double t1_v0[3] = {0.0, 0.0, 0.0}; // Shared vertex + double t1_v1[3] = {3.0, 0.0, 0.0}; + double t1_v2[3] = {0.0, 3.0, 0.0}; + + // Small triangle (nested inside, sharing one vertex) + // Positioned so its interior overlaps with large triangle's interior + double t2_v0[3] = {0.0, 0.0, 0.0}; // Shared vertex + double t2_v1[3] = {1.5, 0.5, 0.0}; // Inside large triangle + double t2_v2[3] = {0.5, 1.5, 0.0}; // Inside large triangle + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + // Boundary OFF: Intersection (small triangle's interior overlaps large triangle's interior) + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection (same, plus the shared vertex counts) + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Coplanar - nested triangles (sharing single sub-edge)") + { + // Large triangle + double t1_v0[3] = {0.0, 0.0, 0.0}; + double t1_v1[3] = {3.0, 0.0, 0.0}; + double t1_v2[3] = {0.0, 3.0, 0.0}; + + // Small triangle (nested inside, sharing a sub-edge) + double t2_v0[3] = {1.0, 0.0, 0.0}; + double t2_v1[3] = {2.0, 0.0, 0.0}; + double t2_v2[3] = {1.5, 1.5, 0.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + // Boundary OFF: Intersection (small triangle's interior overlaps large triangle's interior) + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection (same, plus the shared sub-edge) + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Coplanar - sharing edge, no interior intersection (valid fold)") + { + // Two coplanar triangles sharing an edge, forming a valid "fold" or "butterfly" + // Their interiors do NOT overlap + // + // (0.5,1,0) + // /\. + // / \. + // / \. + // (0,0,0)---(1,0,0) <- shared edge + // \ / + // \ / + // \/ + // (0.5,-1,0) + + // Triangle 1 (upper) + double t1_v0[3] = {0.0, 0.0, 0.0}; // Shared edge vertex 1 + double t1_v1[3] = {1.0, 0.0, 0.0}; // Shared edge vertex 2 + double t1_v2[3] = {0.5, 1.0, 0.0}; // Unique vertex + + // Triangle 2 (lower) + double t2_v0[3] = {0.0, 0.0, 0.0}; // Shared edge vertex 1 + double t2_v1[3] = {1.0, 0.0, 0.0}; // Shared edge vertex 2 + double t2_v2[3] = {0.5, -1.0, 0.0}; // Unique vertex + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + // Boundary OFF: No intersection (only share edge, interiors don't overlap) + REQUIRE_FALSE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection (shared edge counts as contact) + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Coplanar - sharing edge, WITH interior intersection (invalid geometry)") + { + // Two coplanar triangles sharing an edge, with interiors overlapping + // This represents INVALID geometry where adjacent faces improperly overlap + // + // Triangle 1: (0,0,0)-(1,0,0)-(0.5,1,0) + // Triangle 2: (0,0,0)-(1,0,0)-(0.5,0.5,0) + // + // Triangle 2's apex (0.5,0.5,0) is inside Triangle 1 + + // Triangle 1: standard triangle + double t1_v0[3] = {0.0, 0.0, 0.0}; // Shared edge vertex 1 + double t1_v1[3] = {1.0, 0.0, 0.0}; // Shared edge vertex 2 + double t1_v2[3] = {0.5, 1.0, 0.0}; // Apex + + // Triangle 2: shares edge (0,0,0)-(1,0,0), apex inside Triangle 1 + double t2_v0[3] = {0.0, 0.0, 0.0}; // Shared edge vertex 1 + double t2_v1[3] = {1.0, 0.0, 0.0}; // Shared edge vertex 2 + double t2_v2[3] = {0.5, 0.5, 0.0}; // Apex inside Triangle 1's interior + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + // Boundary OFF: Intersection (Triangle 2's apex is in Triangle 1's interior) + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection (both interior overlap and shared edge) + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Touching triangles - edge tip meets edge interior (non-coplanar)") + { + // T1's edge (1,1,-1)→(1,-1,1) crosses T2's plane (z=0) at (1,0,0), + // which is the midpoint of T2's edge (0,0,0)→(2,0,0). + // This is a boundary-only contact (edge piercing a triangle edge interior, + // not the triangle interior), so it should NOT count as an intersection + // in strict mode. + // + // T1_v0 (1, 1,-1) + // | + // (1,0,0) [*] <- hits interior of T2 edge (0,0,0)-(2,0,0) + // | + // T1_v1 (1,-1, 1) + + double t1_v0[3] = {1.0, 1.0, -1.0}; + double t1_v1[3] = {1.0, -1.0, 1.0}; + double t1_v2[3] = {0.0, 1.0, -2.0}; + + double t2_v0[3] = {0.0, 0.0, 0.0}; + double t2_v1[3] = {2.0, 0.0, 0.0}; + double t2_v2[3] = {0.0, 2.0, 0.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + // Boundary OFF: No intersection (contact is only on T2's edge, not its interior) + REQUIRE_FALSE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection (edge-touching-edge counts as contact) + REQUIRE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Degenerate triangle - collinear vertices") + { + // A degenerate triangle where all three vertices are collinear (zero area) + // Such triangles should not have interior intersection with any valid triangle + + // Degenerate triangle: all vertices on a line segment + double degenerate_v0[3] = {0.0, 0.0, 0.0}; + double degenerate_v1[3] = {1.0, 0.0, 0.0}; + double degenerate_v2[3] = {0.5, 0.0, 0.0}; // On the line segment [v0, v1] + + // Valid triangle that intersects the line segment + double valid_v0[3] = {0.5, -1.0, 0.0}; + double valid_v1[3] = {0.5, 1.0, 0.0}; + double valid_v2[3] = {1.5, 0.0, 0.0}; + + span s_degen_v0(degenerate_v0, 3); + span s_degen_v1(degenerate_v1, 3); + span s_degen_v2(degenerate_v2, 3); + span s_valid_v0(valid_v0, 3); + span s_valid_v1(valid_v1, 3); + span s_valid_v2(valid_v2, 3); + + // Boundary OFF: No interior intersection (degenerate has zero area, no interior) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection at segment (0.5, 0) to (1, 0) + REQUIRE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Degenerate triangle - edge intersection") + { + // Degenerate triangle: all vertices on a line segment + double degenerate_v0[3] = {0.0, 0.0, 0.0}; + double degenerate_v1[3] = {1.0, 0.0, 0.0}; + double degenerate_v2[3] = {0.9, 0.0, 0.0}; // On the line segment [v0, v1] + + // Valid triangle that intersects the line segment + double valid_v0[3] = {0.5, -1.0, 0.0}; + double valid_v1[3] = {0.5, 1.0, 0.0}; + double valid_v2[3] = {0.0, 0.0, 1.0}; + + span s_degen_v0(degenerate_v0, 3); + span s_degen_v1(degenerate_v1, 3); + span s_degen_v2(degenerate_v2, 3); + span s_valid_v0(valid_v0, 3); + span s_valid_v1(valid_v1, 3); + span s_valid_v2(valid_v2, 3); + + // Boundary OFF: No interior intersection (degenerate has zero area, no interior) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection at point (0.5, 0, 0) + REQUIRE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Degenerate triangle - all vertices at same point") + { + // A completely degenerate triangle: all vertices at the same point + + // Degenerate triangle: single point + double degenerate_v0[3] = {0.5, 0.5, 0.0}; + double degenerate_v1[3] = {0.5, 0.5, 0.0}; + double degenerate_v2[3] = {0.5, 0.5, 0.0}; + + // Valid triangle containing the degenerate point + double valid_v0[3] = {0.0, 0.0, 0.0}; + double valid_v1[3] = {1.0, 0.0, 0.0}; + double valid_v2[3] = {0.5, 1.0, 0.0}; + + span s_degen_v0(degenerate_v0, 3); + span s_degen_v1(degenerate_v1, 3); + span s_degen_v2(degenerate_v2, 3); + span s_valid_v0(valid_v0, 3); + span s_valid_v1(valid_v1, 3); + span s_valid_v2(valid_v2, 3); + + // Boundary OFF: No interior intersection (degenerate has zero area) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection at the single point (0.5, 0.5, 0) + REQUIRE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Degenerate triangle - all vertices at edge midpoint") + { + // A completely degenerate triangle: all vertices at the same point + + // Degenerate triangle: single point + double degenerate_v0[3] = {0.5, 0.0, 0.0}; + double degenerate_v1[3] = {0.5, 0.0, 0.0}; + double degenerate_v2[3] = {0.5, 0.0, 0.0}; + + // Valid triangle containing the degenerate point + double valid_v0[3] = {0.0, 0.0, 0.0}; + double valid_v1[3] = {1.0, 0.0, 0.0}; + double valid_v2[3] = {0.5, 1.0, 0.0}; + + span s_degen_v0(degenerate_v0, 3); + span s_degen_v1(degenerate_v1, 3); + span s_degen_v2(degenerate_v2, 3); + span s_valid_v0(valid_v0, 3); + span s_valid_v1(valid_v1, 3); + span s_valid_v2(valid_v2, 3); + + // Boundary OFF: No interior intersection (degenerate has zero area) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection at the single point (0.5, 0.0, 0) + REQUIRE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Two degenerate triangle - both segments") + { + // Degenerate triangle: horizontal line segment + double degenerate_v0[3] = {-1.0, 0.1, 0.0}; + double degenerate_v1[3] = {0.0, 0.1, 0.0}; + double degenerate_v2[3] = {1.0, 0.1, 0.0}; + + // Degenerate triangle: vertical line segment + double valid_v0[3] = {0.1, -1.0, 0.0}; + double valid_v1[3] = {0.1, 0.0, 0.0}; + double valid_v2[3] = {0.1, 1.0, 0.0}; + + span s_degen_v0(degenerate_v0, 3); + span s_degen_v1(degenerate_v1, 3); + span s_degen_v2(degenerate_v2, 3); + span s_valid_v0(valid_v0, 3); + span s_valid_v1(valid_v1, 3); + span s_valid_v2(valid_v2, 3); + + // Boundary OFF: No interior intersection (both triangle have zero area) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: Intersection at the single point (0.1, 0.1, 0) + REQUIRE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Two degenerate triangle - both segments but not touching") + { + // Degenerate triangle: horizontal line segment + double degenerate_v0[3] = {-1.0, 1.1, 0.0}; + double degenerate_v1[3] = {0.0, 1.1, 0.0}; + double degenerate_v2[3] = {1.0, 1.1, 0.0}; + + // Degenerate triangle: vertical line segment + double valid_v0[3] = {0.1, -1.0, 0.0}; + double valid_v1[3] = {0.1, 0.0, 0.0}; + double valid_v2[3] = {0.1, 1.0, 0.0}; + + span s_degen_v0(degenerate_v0, 3); + span s_degen_v1(degenerate_v1, 3); + span s_degen_v2(degenerate_v2, 3); + span s_valid_v0(valid_v0, 3); + span s_valid_v1(valid_v1, 3); + span s_valid_v2(valid_v2, 3); + + // Boundary OFF: No interior intersection (both triangle have zero area) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: No intersection (degenerate segments do not touch) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Two degenerate triangle - non coplanar") + { + // Degenerate triangle: horizontal line segment in plane z=0 + double degenerate_v0[3] = {-1.0, 0.0, 0.0}; + double degenerate_v1[3] = {0.0, 0.0, 0.0}; + double degenerate_v2[3] = {1.0, 0.0, 0.0}; + + // Degenerate triangle: vertical line segment in plane z=1 + double valid_v0[3] = {0.0, -1.0, 1.0}; + double valid_v1[3] = {0.0, 0.0, 1.0}; + double valid_v2[3] = {0.0, 1.0, 1.0}; + + span s_degen_v0(degenerate_v0, 3); + span s_degen_v1(degenerate_v1, 3); + span s_degen_v2(degenerate_v2, 3); + span s_valid_v0(valid_v0, 3); + span s_valid_v1(valid_v1, 3); + span s_valid_v2(valid_v2, 3); + + // Boundary OFF: No interior intersection (both triangle have zero area) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: No intersection (degenerate segments do not touch) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Two degenerate triangle - different points") + { + // Degenerate triangle: (0, 0, 0) + double degenerate_v0[3] = {0.0, 0.0, 0.0}; + double degenerate_v1[3] = {0.0, 0.0, 0.0}; + double degenerate_v2[3] = {0.0, 0.0, 0.0}; + + // Degenerate triangle: (0, 0, 1) + double valid_v0[3] = {0.0, 0.0, 1.0}; + double valid_v1[3] = {0.0, 0.0, 1.0}; + double valid_v2[3] = {0.0, 0.0, 1.0}; + + span s_degen_v0(degenerate_v0, 3); + span s_degen_v1(degenerate_v1, 3); + span s_degen_v2(degenerate_v2, 3); + span s_valid_v0(valid_v0, 3); + span s_valid_v1(valid_v1, 3); + span s_valid_v2(valid_v2, 3); + + // Boundary OFF: No interior intersection (both triangle have zero area) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: No intersection (degenerate segments do not touch) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Two degenerate triangle - same points") + { + // Degenerate triangle: (0, 0, 0) + double degenerate_v0[3] = {0.0, 0.0, 0.0}; + double degenerate_v1[3] = {0.0, 0.0, 0.0}; + double degenerate_v2[3] = {0.0, 0.0, 0.0}; + + // Degenerate triangle: (0, 0, 1) + double valid_v0[3] = {0.0, 0.0, 0.0}; + double valid_v1[3] = {0.0, 0.0, 0.0}; + double valid_v2[3] = {0.0, 0.0, 0.0}; + + span s_degen_v0(degenerate_v0, 3); + span s_degen_v1(degenerate_v1, 3); + span s_degen_v2(degenerate_v2, 3); + span s_valid_v0(valid_v0, 3); + span s_valid_v1(valid_v1, 3); + span s_valid_v2(valid_v2, 3); + + // Boundary OFF: No interior intersection (both triangle have zero area) + REQUIRE_FALSE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::No)); + + // Boundary ON: intersection at the single point (0, 0, 0) + REQUIRE(triangle_triangle_intersection( + s_degen_v0, + s_degen_v1, + s_degen_v2, + s_valid_v0, + s_valid_v1, + s_valid_v2, + IncludeBoundaryIntersection::Yes)); + } + + SECTION("Degenerate triangle along z, non-degenerate triangle separated in x") + { + // T2 is a degenerate triangle collinear along the z-axis at x=y=0. + // T1 is a non-degenerate triangle at z=0.5 but x in [1,2], so the only axis + // separating them in 3D is x. A normal-based projection picks the x-axis as the + // discarded one (T2's normal is zero so axis defaults to 0), causing a false + // positive. The fix uses a bbox-based axis pick over all 5 relevant points. + double t1_v0[3] = {1.0, 0.0, 0.5}; + double t1_v1[3] = {2.0, 0.0, 0.5}; + double t1_v2[3] = {1.0, 1.0, 0.5}; + double t2_v0[3] = {0.0, 0.0, 0.0}; + double t2_v1[3] = {0.0, 0.0, 1.0}; + double t2_v2[3] = {0.0, 0.0, 2.0}; + + span s1_v0(t1_v0, 3); + span s1_v1(t1_v1, 3); + span s1_v2(t1_v2, 3); + span s2_v0(t2_v0, 3); + span s2_v1(t2_v1, 3); + span s2_v2(t2_v2, 3); + + REQUIRE_FALSE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::No)); + + REQUIRE_FALSE(triangle_triangle_intersection( + s1_v0, + s1_v1, + s1_v2, + s2_v0, + s2_v1, + s2_v2, + IncludeBoundaryIntersection::Yes)); + } +} diff --git a/modules/core/tests/test_unify_index_buffer.cpp b/modules/core/tests/test_unify_index_buffer.cpp index 98d71b15..7ac7ba9c 100644 --- a/modules/core/tests/test_unify_index_buffer.cpp +++ b/modules/core/tests/test_unify_index_buffer.cpp @@ -521,4 +521,26 @@ TEST_CASE("unify_index_buffer", "[attribute][next][unify]") mesh2.initialize_edges(); check_for_consistency(mesh, mesh2); } + + SECTION("Isolated vertices with selected indexed attribute") + { + // Regression test: unify_index_buffer used to under-allocate the output + // vertex attribute when mapping a selected indexed attribute on a mesh + // with isolated vertices. + lagrange::SurfaceMesh mesh = generate_rectangle(); + mesh.add_vertex({-1, -1, -1}); // isolated vertex + REQUIRE(mesh.get_num_vertices() == 7); + + std::vector values = {0, 1, 2}; + std::vector indices = {0, 0, 0, 1, 1, 1, 2, 2, 2, 2}; + auto attr_id = add_indexed_attribute(mesh, "facet_id", values, indices); + + // Pass the attribute id so it gets mapped to a vertex attribute. + auto mesh2 = unify_index_buffer(mesh, {attr_id}); + REQUIRE(mesh2.get_num_vertices() == 11); + REQUIRE(mesh2.has_attribute("facet_id")); + REQUIRE_FALSE(mesh2.is_attribute_indexed("facet_id")); + mesh2.initialize_edges(); + check_for_consistency(mesh, mesh2); + } } diff --git a/modules/core/tests/test_uv_mesh.cpp b/modules/core/tests/test_uv_mesh.cpp index 01f38b8d..d5e2749c 100644 --- a/modules/core/tests/test_uv_mesh.cpp +++ b/modules/core/tests/test_uv_mesh.cpp @@ -226,6 +226,132 @@ TEST_CASE("uv_mesh: corner attribute hybrid mesh", "[core][uv_mesh]") } } +TEST_CASE("uv_mesh: indexed attribute with different UV scalar type", "[core][uv_mesh]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + using UVScalar = float; + + 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(2, 1, 3); + + std::vector uv_values{0, 0, 1, 0, 0, 1, 1, 1}; + std::vector uv_indices{0, 1, 2, 2, 1, 3}; + mesh.template create_attribute( + "uv", + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + uv_values, + uv_indices); + + SECTION("uv_mesh_view") + { + auto uv = uv_mesh_view(mesh); + REQUIRE(uv.get_num_vertices() == 4); + REQUIRE(uv.get_num_facets() == 2); + REQUIRE(uv.get_num_corners() == 6); + } + + SECTION("uv_mesh_ref") + { + auto uv = uv_mesh_ref(mesh); + REQUIRE(uv.get_num_vertices() == 4); + REQUIRE(uv.get_num_facets() == 2); + REQUIRE(uv.get_num_corners() == 6); + } +} + +TEST_CASE("uv_mesh: vertex attribute with different UV scalar type", "[core][uv_mesh]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + using UVScalar = float; + + 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(2, 1, 3); + + std::vector uv_values{0, 0, 1, 0, 0, 1, 1, 1}; + mesh.template create_attribute( + "uv", + AttributeElement::Vertex, + AttributeUsage::UV, + 2, + uv_values); + + SECTION("uv_mesh_view") + { + auto uv = uv_mesh_view(mesh); + REQUIRE(uv.get_num_vertices() == 4); + REQUIRE(uv.get_num_facets() == 2); + REQUIRE(uv.get_num_corners() == 6); + } + + SECTION("uv_mesh_ref") + { + auto uv = uv_mesh_ref(mesh); + REQUIRE(uv.get_num_vertices() == 4); + REQUIRE(uv.get_num_facets() == 2); + REQUIRE(uv.get_num_corners() == 6); + } +} + +TEST_CASE("uv_mesh: corner attribute with different UV scalar type", "[core][uv_mesh]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + using UVScalar = float; + + 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(2, 1, 3); + + std::vector uv_values{0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1}; + mesh.template create_attribute( + "uv", + AttributeElement::Corner, + AttributeUsage::UV, + 2, + uv_values); + + UVMeshOptions options; + options.uv_attribute_name = "uv"; + options.element_types = UVMeshOptions::ElementTypes::All; + + SECTION("uv_mesh_view") + { + auto uv = uv_mesh_view(mesh, options); + REQUIRE(uv.get_num_vertices() == 6); + REQUIRE(uv.get_num_facets() == 2); + REQUIRE(uv.get_num_corners() == 6); + } + + SECTION("uv_mesh_ref") + { + auto uv = uv_mesh_ref(mesh, options); + REQUIRE(uv.get_num_vertices() == 6); + REQUIRE(uv.get_num_facets() == 2); + REQUIRE(uv.get_num_corners() == 6); + } +} + TEST_CASE( "uv_mesh: auto-detect corner attribute with UVMeshOptions::ElementTypes::All", "[core][uv_mesh]") diff --git a/modules/filtering/CMakeLists.txt b/modules/filtering/CMakeLists.txt index 88632289..d9578d11 100644 --- a/modules/filtering/CMakeLists.txt +++ b/modules/filtering/CMakeLists.txt @@ -39,3 +39,7 @@ endif() if(LAGRANGE_MODULE_PYTHON) add_subdirectory(python) endif() + +if(LAGRANGE_BINDINGS_JS) + add_subdirectory(js) +endif() diff --git a/modules/filtering/js/CMakeLists.txt b/modules/filtering/js/CMakeLists.txt new file mode 100644 index 00000000..e1b3832b --- /dev/null +++ b/modules/filtering/js/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_js_binding(filtering FilteringModule) diff --git a/modules/filtering/js/src/filtering.cpp b/modules/filtering/js/src/filtering.cpp new file mode 100644 index 00000000..4981d1a4 --- /dev/null +++ b/modules/filtering/js/src/filtering.cpp @@ -0,0 +1,72 @@ +/* + * 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 "bind_types.h" + +#include +#include + +#include +#include + +#include + +namespace { + +using namespace lagrange; +using namespace lagrange::js::bind; +using val = emscripten::val; + +} // namespace + +EMSCRIPTEN_BINDINGS(lagrange_filtering) +{ + using namespace emscripten; + + function( + "meshSmoothing", + +[](MeshType& mesh, val opts) { + filtering::SmoothingOptions o; + if (!opts.isUndefined()) { + auto method = opts["method"]; + if (!method.isUndefined()) { + auto s = method.as(); + if (s == "vertexSmoothing") { + o.filter_method = + filtering::SmoothingOptions::FilterMethod::VertexSmoothing; + } else { + o.filter_method = + filtering::SmoothingOptions::FilterMethod::NormalSmoothing; + } + } + apply_opt(opts, "curvatureWeight", o.curvature_weight); + apply_opt(opts, "normalSmoothingWeight", o.normal_smoothing_weight); + apply_opt(opts, "gradientWeight", o.gradient_weight); + apply_opt(opts, "gradientModulationScale", o.gradient_modulation_scale); + apply_opt(opts, "normalProjectionWeight", o.normal_projection_weight); + } + filtering::mesh_smoothing(mesh, o); + }); + + function( + "scalarAttributeSmoothing", + +[](MeshType& mesh, const std::string& attribute_name, val opts) { + filtering::AttributeSmoothingOptions o; + if (!opts.isUndefined()) { + apply_opt(opts, "curvatureWeight", o.curvature_weight); + apply_opt(opts, "normalSmoothingWeight", o.normal_smoothing_weight); + apply_opt(opts, "gradientWeight", o.gradient_weight); + apply_opt(opts, "gradientModulationScale", o.gradient_modulation_scale); + } + filtering::scalar_attribute_smoothing(mesh, attribute_name, o); + }); +} diff --git a/modules/filtering/js/test/filtering.test.ts b/modules/filtering/js/test/filtering.test.ts new file mode 100644 index 00000000..6dab3b1a --- /dev/null +++ b/modules/filtering/js/test/filtering.test.ts @@ -0,0 +1,39 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +describe("filtering", () => { + test("meshSmoothing with default options", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const vertsBefore = mesh.getNumVertices(); + lagrange.filtering.meshSmoothing(mesh); + // Smoothing modifies positions but preserves topology + expect(mesh.getNumVertices()).toBe(vertsBefore); + expect(mesh.getNumFacets()).toBeGreaterThan(0); + mesh.delete(); + }); + + test("meshSmoothing with vertex smoothing method", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + lagrange.filtering.meshSmoothing(mesh, { method: "vertexSmoothing" }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + mesh.delete(); + }); + + test("meshSmoothing with custom weights", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + lagrange.filtering.meshSmoothing(mesh, { + curvatureWeight: 0.05, + gradientWeight: 2, + gradientModulationScale: 0.5, + }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + mesh.delete(); + }); +}); diff --git a/modules/filtering/js/ts/filtering.ts b/modules/filtering/js/ts/filtering.ts new file mode 100644 index 00000000..2836e3c2 --- /dev/null +++ b/modules/filtering/js/ts/filtering.ts @@ -0,0 +1,67 @@ +/** + * Anisotropic smoothing of mesh geometry and per-vertex scalar fields. + * Uses a normal/vertex diffusion formulation that preserves sharp features + * better than naive Laplacian smoothing. + * + * Omitted option fields fall back to library defaults. + */ + +import type { SurfaceMesh } from "./core.js"; + +/** + * - `"normalSmoothing"`: diffuse facet normals, then fit geometry to them. + * Preserves sharp features better. + * - `"vertexSmoothing"`: diffuse vertex positions directly. Faster, more aggressive. + */ +export type SmoothingMethod = "normalSmoothing" | "vertexSmoothing"; + +export interface MeshSmoothingOptions { + /** Smoothing strategy. Default: `"normalSmoothing"`. */ + method?: SmoothingMethod; + /** + * Curvature-based feature weight in `[0, 1]`. Higher values protect + * high-curvature regions (edges, corners) from being flattened. + * Default: `0.02`. + */ + curvatureWeight?: number; + /** Diffusion time-step for normals; larger = more smoothing per call. Default: `0.1`. */ + normalSmoothingWeight?: number; + /** Trade-off between matching smoothed normals and keeping positions. Default: `1`. */ + gradientWeight?: number; + /** Gradient scaling: `<1` smooths further, `>1` sharpens. Default: `0`. */ + gradientModulationScale?: number; + /** How strongly geometry is pulled toward the target normal field. Default: `0.1`. */ + normalProjectionWeight?: number; +} + +export interface AttributeSmoothingOptions { + /** Curvature-based feature weight in `[0, 1]`; higher = protect features more. Default: `0.02`. */ + curvatureWeight?: number; + /** Diffusion time-step for normals used to build the anisotropic metric. Default: `0.1`. */ + normalSmoothingWeight?: number; + /** Trade-off between matching gradients and keeping values. Default: `1`. */ + gradientWeight?: number; + /** Gradient scaling; `<1` smooths more, `>1` sharpens. Default: `0`. */ + gradientModulationScale?: number; +} + +/** + * Smoothing / denoising. Accessible as `lagrange.filtering`. + */ +export interface FilteringModule { + /** + * Feature-preserving mesh smoothing. Moves vertex positions so the surface + * becomes smoother while preserving corners and creases. In-place. + */ + meshSmoothing(mesh: SurfaceMesh, opts?: MeshSmoothingOptions): void; + /** + * Smooth a named per-vertex scalar attribute across the surface. Useful for + * cleaning up painted weights, noisy curvature, etc. In-place. + */ + scalarAttributeSmoothing(mesh: SurfaceMesh, attributeName: string, opts?: AttributeSmoothingOptions): void; +} + +export const filteringModuleKeys = [ + "meshSmoothing", + "scalarAttributeSmoothing", +] as const satisfies readonly (keyof FilteringModule)[]; diff --git a/modules/fs/src/file_utils.cpp b/modules/fs/src/file_utils.cpp index 5b16f1ca..3ce44d76 100644 --- a/modules/fs/src/file_utils.cpp +++ b/modules/fs/src/file_utils.cpp @@ -25,6 +25,7 @@ #error Platform not supported #endif +#include #include #include diff --git a/modules/geodesic/include/lagrange/geodesic/GeodesicEngine.h b/modules/geodesic/include/lagrange/geodesic/GeodesicEngine.h index abf2d64b..6d2a85e2 100644 --- a/modules/geodesic/include/lagrange/geodesic/GeodesicEngine.h +++ b/modules/geodesic/include/lagrange/geodesic/GeodesicEngine.h @@ -14,7 +14,9 @@ #include #include +#include #include +#include namespace lagrange::geodesic { @@ -92,6 +94,48 @@ struct LA_GEODESIC_API PointToPointGeodesicOptions std::array target_facet_bc = {0.0f, 0.0f}; }; +/// +/// Options for point-to-point geodesic path computation. +/// +struct LA_GEODESIC_API PointToPointGeodesicPathOptions +{ + /// Facet containing the source point. + size_t source_facet_id = 0; + + /// Facet containing the target point. + size_t target_facet_id = 0; + + /// Barycentric coordinates of the source point within the source facet. Given a triangle (p1, + /// p2, p3), the barycentric coordinates (u, v) are such that the surface point is represented + /// by p = (1 - u - v) * p1 + u * p2 + v * p3. + std::array source_facet_bc = {0.0f, 0.0f}; + + /// Barycentric coordinates of the target point within the target facet. Given a triangle (p1, + /// p2, p3), the barycentric coordinates (u, v) are such that the surface point is represented + /// by p = (1 - u - v) * p1 + u * p2 + v * p3. + std::array target_facet_bc = {0.0f, 0.0f}; +}; + + +/// +/// Result of a point-to-point geodesic path computation. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +template +struct GeodesicPathResult +{ + /// Ordered list of 3D points along the geodesic path from source to target. + /// The first point is the source and the last point is the target. + std::vector> points; + + /// Facet index for each path segment. The i-th entry is the facet that the segment from + /// points[i] to points[i+1] lies in. The size of this vector is points.size() - 1, or 0 if the + /// path is empty. + std::vector facet_ids; +}; + /// /// Engine that is used to compute geodesic distances on a surface mesh. /// @@ -138,6 +182,22 @@ class GeodesicEngine /// virtual Scalar point_to_point_geodesic(const PointToPointGeodesicOptions& options); + /// + /// Computes the geodesic path between two points on the mesh. + /// + /// @param[in] options Input options for point-to-point path computation. + /// + /// @return A GeodesicPathResult containing the ordered path points and the facet index for + /// each path segment. + /// + /// @throws lagrange::Error if the engine does not support path extraction. + /// + /// @note Not all engines support path extraction. The default implementation throws + /// an exception. Currently, only GeodesicEngineMMP provides exact path extraction. + /// + virtual GeodesicPathResult point_to_point_geodesic_path( + const PointToPointGeodesicPathOptions& options); + protected: const Mesh& mesh() const { return m_mesh.get(); } Mesh& mesh() { return m_mesh.get(); } diff --git a/modules/geodesic/include/lagrange/geodesic/GeodesicEngineMMP.h b/modules/geodesic/include/lagrange/geodesic/GeodesicEngineMMP.h index 328b8272..ed46e017 100644 --- a/modules/geodesic/include/lagrange/geodesic/GeodesicEngineMMP.h +++ b/modules/geodesic/include/lagrange/geodesic/GeodesicEngineMMP.h @@ -62,6 +62,19 @@ class GeodesicEngineMMP : public GeodesicEngine SingleSourceGeodesicResult single_source_geodesic( const SingleSourceGeodesicOptions& options) override; + /// + /// Compute the exact geodesic path between two points using the MMP algorithm. + /// + /// This function computes the shortest path on the surface between two points. + /// + /// @param options The options for the path computation. + /// + /// @return A GeodesicPathResult containing the ordered path points and segment facet + /// indices. Returns an empty result if no path exists. + /// + GeodesicPathResult point_to_point_geodesic_path( + const PointToPointGeodesicPathOptions& options) override; + protected: struct Impl; lagrange::value_ptr m_impl; diff --git a/modules/geodesic/python/src/geodesic.cpp b/modules/geodesic/python/src/geodesic.cpp index d7a7efa6..05fd9af8 100644 --- a/modules/geodesic/python/src/geodesic.cpp +++ b/modules/geodesic/python/src/geodesic.cpp @@ -18,6 +18,7 @@ #include #include +#include #include namespace nb = nanobind; @@ -62,6 +63,38 @@ void populate_geodesic_module(nb::module_& m) :returns: The geodesic distance between the two points.)"); }; + auto def_point_to_point_geodesic_path = [](auto& cls) { + using EngineType = typename std::decay_t::Type; + cls.def( + "point_to_point_geodesic_path", + [](EngineType& self, + size_t source_facet_id, + size_t target_facet_id, + std::array source_facet_bc, + std::array target_facet_bc) { + geodesic::PointToPointGeodesicPathOptions options; + options.source_facet_id = source_facet_id; + options.target_facet_id = target_facet_id; + options.source_facet_bc = source_facet_bc; + options.target_facet_bc = target_facet_bc; + auto result = self.point_to_point_geodesic_path(options); + return std::make_tuple(std::move(result.points), std::move(result.facet_ids)); + }, + "source_facet_id"_a, + "target_facet_id"_a, + "source_facet_bc"_a, + "target_facet_bc"_a, + R"(Compute the geodesic path between two points on the mesh. + +:param source_facet_id: Facet containing the source point. +:param target_facet_id: Facet containing the target point. +:param source_facet_bc: Barycentric coordinates of the source point within the source facet. Given a triangle (p1, p2, p3), the barycentric coordinates (u, v) are such that the surface point is represented by p = (1 - u - v) * p1 + u * p2 + v * p3. +:param target_facet_bc: Barycentric coordinates of the target point within the target facet. Given a triangle (p1, p2, p3), the barycentric coordinates (u, v) are such that the surface point is represented by p = (1 - u - v) * p1 + u * p2 + v * p3. + +:returns: A tuple of (points, facet_ids) where points is a list of [x, y, z] coordinates along the geodesic path (first is source, last is target), and facet_ids is a list of facet indices for each path segment. +:raises RuntimeError: If the engine does not support path extraction.)"); + }; + // DGPC engine nb::class_> cls_dgpc(m, "GeodesicEngineDGPC"); cls_dgpc.def(nb::init&>()) @@ -167,6 +200,7 @@ void populate_geodesic_module(nb::module_& m) :returns: The attribute ID of the computed geodesic distance attributes.)"); def_point_to_point_geodesic(cls_mmp); + def_point_to_point_geodesic_path(cls_mmp); } } // namespace lagrange::python diff --git a/modules/geodesic/python/tests/conftest.py b/modules/geodesic/python/tests/conftest.py new file mode 100644 index 00000000..c6306c8b --- /dev/null +++ b/modules/geodesic/python/tests/conftest.py @@ -0,0 +1,35 @@ +# Copyright 2025 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +""" +Pytest fixtures for geodesic module tests. + +Note: The `single_triangle` fixture is provided by modules/conftest.py and +is automatically available to all tests in this module. +""" + +import lagrange.primitive + +import pytest + + +@pytest.fixture +def sphere_mesh(): + """ + Generate a sphere mesh for geodesic testing. + + Returns a sphere with radius 10.0, 32 longitude sections, and 16 latitude sections. + This resolution provides a good balance between accuracy and performance for testing + geodesic distance calculations. + """ + mesh = lagrange.primitive.generate_sphere( + radius=10.0, num_longitude_sections=32, num_latitude_sections=16, triangulate=True + ) + return mesh diff --git a/modules/geodesic/python/tests/test_geodesic.py b/modules/geodesic/python/tests/test_geodesic.py index 22798ffa..2f434a19 100644 --- a/modules/geodesic/python/tests/test_geodesic.py +++ b/modules/geodesic/python/tests/test_geodesic.py @@ -11,98 +11,187 @@ # import lagrange -import pytest import numpy as np -@pytest.fixture -def mesh(): - mesh = lagrange.SurfaceMesh() - mesh.add_vertices(np.eye(3)) - mesh.add_triangle(0, 1, 2) - assert mesh.num_vertices == 3 - assert mesh.num_facets == 1 - - return mesh - - class TestDGPCEngine: - def test_single_source(self, mesh): - assert not mesh.has_attribute("@geodesic_distance") - assert not mesh.has_attribute("@polar_angle") - engine = lagrange.geodesic.GeodesicEngineDGPC(mesh) + def test_single_source(self, single_triangle): + assert not single_triangle.has_attribute("@geodesic_distance") + assert not single_triangle.has_attribute("@polar_angle") + engine = lagrange.geodesic.GeodesicEngineDGPC(single_triangle) dist_id, angle_id = engine.single_source_geodesic( source_facet_id=0, source_facet_bc=[0.3, 0.3], ) - assert mesh.has_attribute("@geodesic_distance") - assert mesh.has_attribute("@polar_angle") - assert mesh.get_attribute_id("@geodesic_distance") == dist_id - assert mesh.get_attribute_id("@polar_angle") == angle_id - - def test_point_to_point(self, mesh): - assert not mesh.has_attribute("@geodesic_distance") - assert not mesh.has_attribute("@polar_angle") - engine = lagrange.geodesic.GeodesicEngineDGPC(mesh) + assert single_triangle.has_attribute("@geodesic_distance") + assert single_triangle.has_attribute("@polar_angle") + assert single_triangle.get_attribute_id("@geodesic_distance") == dist_id + assert single_triangle.get_attribute_id("@polar_angle") == angle_id + + def test_point_to_point(self, single_triangle): + assert not single_triangle.has_attribute("@geodesic_distance") + assert not single_triangle.has_attribute("@polar_angle") + engine = lagrange.geodesic.GeodesicEngineDGPC(single_triangle) distance = engine.point_to_point_geodesic( source_facet_id=0, source_facet_bc=[0.3, 0.3], target_facet_id=0, target_facet_bc=[0.6, 0.2], ) - assert mesh.has_attribute("@geodesic_distance") + assert single_triangle.has_attribute("@geodesic_distance") assert distance >= 0.0 class TestHeatEngine: - def test_single_source(self, mesh): - assert not mesh.has_attribute("@geodesic_distance") - assert not mesh.has_attribute("@polar_angle") - engine = lagrange.geodesic.GeodesicEngineHeat(mesh) + def test_single_source(self, single_triangle): + assert not single_triangle.has_attribute("@geodesic_distance") + assert not single_triangle.has_attribute("@polar_angle") + engine = lagrange.geodesic.GeodesicEngineHeat(single_triangle) dist_id = engine.single_source_geodesic( source_facet_id=0, source_facet_bc=[0.3, 0.3], ) - assert mesh.has_attribute("@geodesic_distance") - assert not mesh.has_attribute("@polar_angle") - assert mesh.get_attribute_id("@geodesic_distance") == dist_id - - def test_point_to_point(self, mesh): - assert not mesh.has_attribute("@geodesic_distance") - assert not mesh.has_attribute("@polar_angle") - engine = lagrange.geodesic.GeodesicEngineHeat(mesh) + assert single_triangle.has_attribute("@geodesic_distance") + assert not single_triangle.has_attribute("@polar_angle") + assert single_triangle.get_attribute_id("@geodesic_distance") == dist_id + + def test_point_to_point(self, single_triangle): + assert not single_triangle.has_attribute("@geodesic_distance") + assert not single_triangle.has_attribute("@polar_angle") + engine = lagrange.geodesic.GeodesicEngineHeat(single_triangle) distance = engine.point_to_point_geodesic( source_facet_id=0, source_facet_bc=[0.3, 0.3], target_facet_id=0, target_facet_bc=[0.6, 0.2], ) - assert mesh.has_attribute("@geodesic_distance") + assert single_triangle.has_attribute("@geodesic_distance") assert distance >= 0.0 class TestMMPEngine: - def test_single_source(self, mesh): - assert not mesh.has_attribute("@geodesic_distance") - assert not mesh.has_attribute("@polar_angle") - engine = lagrange.geodesic.GeodesicEngineMMP(mesh) + def test_single_source(self, single_triangle): + assert not single_triangle.has_attribute("@geodesic_distance") + assert not single_triangle.has_attribute("@polar_angle") + engine = lagrange.geodesic.GeodesicEngineMMP(single_triangle) dist_id = engine.single_source_geodesic( source_facet_id=0, source_facet_bc=[0.3, 0.3], ) - assert mesh.has_attribute("@geodesic_distance") - assert not mesh.has_attribute("@polar_angle") - assert mesh.get_attribute_id("@geodesic_distance") == dist_id - - def test_point_to_point(self, mesh): - assert not mesh.has_attribute("@geodesic_distance") - assert not mesh.has_attribute("@polar_angle") - engine = lagrange.geodesic.GeodesicEngineMMP(mesh) + assert single_triangle.has_attribute("@geodesic_distance") + assert not single_triangle.has_attribute("@polar_angle") + assert single_triangle.get_attribute_id("@geodesic_distance") == dist_id + + def test_point_to_point(self, single_triangle): + assert not single_triangle.has_attribute("@geodesic_distance") + assert not single_triangle.has_attribute("@polar_angle") + engine = lagrange.geodesic.GeodesicEngineMMP(single_triangle) distance = engine.point_to_point_geodesic( source_facet_id=0, source_facet_bc=[0.3, 0.3], target_facet_id=0, target_facet_bc=[0.6, 0.2], ) - assert mesh.has_attribute("@geodesic_distance") + assert single_triangle.has_attribute("@geodesic_distance") assert distance >= 0.0 + + def test_point_to_point_path(self, single_triangle): + engine = lagrange.geodesic.GeodesicEngineMMP(single_triangle) + points, facet_ids = engine.point_to_point_geodesic_path( + source_facet_id=0, + source_facet_bc=[0.3, 0.3], + target_facet_id=0, + target_facet_bc=[0.6, 0.2], + ) + assert len(points) >= 2 # At least source and target + assert all(len(p) == 3 for p in points) # Each point is 3D + assert len(facet_ids) == len(points) - 1 # One facet per segment + + +class TestSphereGeodesic: + """ + Test geodesic distances on a sphere. + + For a sphere of radius r, the relationship between Euclidean distance d_E + and geodesic distance d_G is: + + d_G = 2 * r * arcsin(d_E / (2 * r)) + + This test verifies this mathematical relationship. + """ + + def test_geodesic_distance_on_sphere(self, sphere_mesh): + """ + Test that geodesic distance on a sphere matches the theoretical formula. + + The geodesic distance should satisfy: + d_geodesic = 2 * r * arcsin(d_euclidean / (2 * r)) + + And the bounds: + d_euclidean <= d_geodesic <= pi * r + """ + radius = 10.0 + + # Create MMP engine + engine = lagrange.geodesic.GeodesicEngineMMP(sphere_mesh) + + # Test with several pairs of points + test_cases = [ + # (source_facet, target_facet) - different locations on sphere + (0, 10), # Nearby points + (0, 50), # Medium distance + (0, 100), # Larger distance + ] + + for source_facet, target_facet in test_cases: + # Compute geodesic distance + d_geodesic = engine.point_to_point_geodesic( + source_facet_id=source_facet, + target_facet_id=target_facet, + source_facet_bc=[0.33, 0.33], + target_facet_bc=[0.33, 0.33], + ) + + # Compute Euclidean distance between the same points + vertices = sphere_mesh.vertices + facets = sphere_mesh.facets + + # Get barycentric interpolated positions + source_triangle = facets[source_facet] + source_pos = ( + (1 - 0.33 - 0.33) * vertices[source_triangle[0]] + + 0.33 * vertices[source_triangle[1]] + + 0.33 * vertices[source_triangle[2]] + ) + + target_triangle = facets[target_facet] + target_pos = ( + (1 - 0.33 - 0.33) * vertices[target_triangle[0]] + + 0.33 * vertices[target_triangle[1]] + + 0.33 * vertices[target_triangle[2]] + ) + + d_euclidean = np.linalg.norm(target_pos - source_pos) + + # Theoretical geodesic distance on sphere + # d_G = 2 * r * arcsin(d_E / (2 * r)) + d_theoretical = 2 * radius * np.arcsin(d_euclidean / (2 * radius)) + + # Verify bounds: d_euclidean <= d_geodesic <= pi * r + assert d_euclidean <= d_geodesic, ( + f"Geodesic shorter than Euclidean: {d_geodesic} < {d_euclidean}" + ) + assert d_geodesic <= np.pi * radius, ( + f"Geodesic longer than max: {d_geodesic} > {np.pi * radius}" + ) + + # Verify the geodesic distance matches theory + # Allow some tolerance due to mesh discretization (~5%) + # Note: The theoretical formula assumes points on an exact sphere, but we're working + # with a triangulated mesh where barycentric coordinates lie on planar facets. + # The tolerance accounts for this discretization error. + tolerance = 0.05 * d_theoretical + assert np.abs(d_geodesic - d_theoretical) < tolerance, ( + f"Geodesic distance {d_geodesic:.4f} doesn't match theoretical {d_theoretical:.4f} " + f"(Euclidean: {d_euclidean:.4f}, error: {np.abs(d_geodesic - d_theoretical):.4f})" + ) diff --git a/modules/geodesic/src/GeodesicEngine.cpp b/modules/geodesic/src/GeodesicEngine.cpp index a4c27aa6..c59602b3 100644 --- a/modules/geodesic/src/GeodesicEngine.cpp +++ b/modules/geodesic/src/GeodesicEngine.cpp @@ -12,6 +12,7 @@ #include #include +#include #include namespace lagrange::geodesic { @@ -49,6 +50,17 @@ Scalar GeodesicEngine::point_to_point_geodesic( geo_dists(facets(options.target_facet_id, 2)) * options.target_facet_bc[1]; } +template +GeodesicPathResult GeodesicEngine::point_to_point_geodesic_path( + const PointToPointGeodesicPathOptions& /*options*/) +{ + // Default implementation throws an exception + // Derived classes should override this method to provide actual path computation + throw Error( + "Geodesic path extraction is not supported by this engine. " + "Use GeodesicEngineMMP for exact path computation."); +} + #define LA_X_GeodesicEngine(_, Scalar, Index) \ template class LA_GEODESIC_API GeodesicEngine; LA_SURFACE_MESH_X(GeodesicEngine, 0) diff --git a/modules/geodesic/src/GeodesicEngineMMP.cpp b/modules/geodesic/src/GeodesicEngineMMP.cpp index 7c268c5c..83790513 100644 --- a/modules/geodesic/src/GeodesicEngineMMP.cpp +++ b/modules/geodesic/src/GeodesicEngineMMP.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "geometry_central_utils.h" @@ -26,6 +27,7 @@ #include #include +#include namespace lagrange::geodesic { @@ -91,6 +93,67 @@ SingleSourceGeodesicResult GeodesicEngineMMP::single_source_geode return {geodesic_distance_id, invalid_attribute_id()}; } + +template +GeodesicPathResult GeodesicEngineMMP::point_to_point_geodesic_path( + const PointToPointGeodesicPathOptions& options) +{ + // Create source and target surface points + gcSurfacePoint source_point( + m_impl->m_gc_mesh->face(options.source_facet_id), + geometrycentral::Vector3{ + 1.0 - options.source_facet_bc[0] - options.source_facet_bc[1], + options.source_facet_bc[0], + options.source_facet_bc[1]}); + + gcSurfacePoint target_point( + m_impl->m_gc_mesh->face(options.target_facet_id), + geometrycentral::Vector3{ + 1.0 - options.target_facet_bc[0] - options.target_facet_bc[1], + options.target_facet_bc[0], + options.target_facet_bc[1]}); + + // Propagate from source + m_impl->m_solver->propagate(source_point); + + // Trace back path from target to source + std::vector gc_path = m_impl->m_solver->traceBack(target_point); + + GeodesicPathResult result; + + if (gc_path.empty()) { + return result; + } + + // Convert geometry-central path to result vectors + // Note: geometry-central returns path from target to source, so we reverse it + const size_t n = gc_path.size(); + result.points.resize(n); + result.facet_ids.resize(n - 1); + + for (size_t i = 0; i < n; ++i) { + const auto& sp = gc_path[n - 1 - i]; + geometrycentral::Vector3 pos = sp.interpolate(m_impl->m_gc_geom->inputVertexPositions); + result.points[i] = { + static_cast(pos.x), + static_cast(pos.y), + static_cast(pos.z)}; + } + + // Determine the facet for each path segment using the shared face between consecutive points + for (size_t i = 0; i + 1 < n; ++i) { + const auto& sp_a = gc_path[n - 1 - i]; + const auto& sp_b = gc_path[n - 2 - i]; + auto f = geometrycentral::surface::sharedFace(sp_a, sp_b); + la_runtime_assert( + f != geometrycentral::surface::Face(), + "No shared face found for path segment"); + result.facet_ids[i] = static_cast(f.getIndex()); + } + + return result; +} + #define LA_X_GeodesicEngineMMP(_, Scalar, Index) \ template class LA_GEODESIC_API GeodesicEngineMMP; LA_SURFACE_MESH_X(GeodesicEngineMMP, 0) diff --git a/modules/geodesic/tests/CMakeLists.txt b/modules/geodesic/tests/CMakeLists.txt index 5a095531..b5d6de2e 100644 --- a/modules/geodesic/tests/CMakeLists.txt +++ b/modules/geodesic/tests/CMakeLists.txt @@ -10,3 +10,6 @@ # governing permissions and limitations under the License. # lagrange_add_test() + +lagrange_include_modules(primitive) +target_link_libraries(test_lagrange_geodesic PRIVATE lagrange::primitive) diff --git a/modules/geodesic/tests/test_geodesic_path.cpp b/modules/geodesic/tests/test_geodesic_path.cpp new file mode 100644 index 00000000..00474bed --- /dev/null +++ b/modules/geodesic/tests/test_geodesic_path.cpp @@ -0,0 +1,244 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include +#include +#include +#include + +#include + +#include +#include + +namespace { + +using Scalar = float; +using Index = uint32_t; + +} // namespace + +TEST_CASE("geodesic_path_mmp", "[geodesic][path]") +{ + auto mesh = lagrange::testing::load_surface_mesh("open/core/ball.obj"); + + SECTION("Path exists") + { + // Create MMP engine + auto engine = lagrange::geodesic::make_mmp_engine(mesh); + + // Compute path between two different points + lagrange::geodesic::PointToPointGeodesicPathOptions options; + options.source_facet_id = 0; + options.target_facet_id = 10; + options.source_facet_bc = {0.0, 0.0}; + options.target_facet_bc = {0.0, 0.0}; + + auto result = engine.point_to_point_geodesic_path(options); + + // Path should not be empty + REQUIRE(!result.points.empty()); + + // Should have at least 2 points (source and target) + REQUIRE(result.points.size() >= 2); + + // facet_ids should have one fewer entry than points + REQUIRE(result.facet_ids.size() == result.points.size() - 1); + } + + SECTION("Path from point to itself") + { + auto engine = lagrange::geodesic::make_mmp_engine(mesh); + + lagrange::geodesic::PointToPointGeodesicPathOptions options; + options.source_facet_id = 0; + options.target_facet_id = 0; + options.source_facet_bc = {0.0, 0.0}; + options.target_facet_bc = {0.0, 0.0}; + + auto result = engine.point_to_point_geodesic_path(options); + + // Path should still exist but may be trivial (just source/target point) + REQUIRE(!result.points.empty()); + } + + SECTION("Path endpoints match source/target") + { + auto engine = lagrange::geodesic::make_mmp_engine(mesh); + + // Define source and target points + lagrange::geodesic::PointToPointGeodesicPathOptions options; + options.source_facet_id = 5; + options.target_facet_id = 25; + options.source_facet_bc = {0.3, 0.3}; + options.target_facet_bc = {0.2, 0.4}; + + auto result = engine.point_to_point_geodesic_path(options); + REQUIRE(!result.points.empty()); + + // Compute expected source and target positions + auto mesh_vertices = lagrange::vertex_view(mesh); + auto mesh_facets = lagrange::facet_view(mesh); + + auto source_facet = mesh_facets.row(options.source_facet_id); + Eigen::Matrix expected_source = + (1.0f - options.source_facet_bc[0] - options.source_facet_bc[1]) * + mesh_vertices.row(source_facet[0]).transpose() + + options.source_facet_bc[0] * mesh_vertices.row(source_facet[1]).transpose() + + options.source_facet_bc[1] * mesh_vertices.row(source_facet[2]).transpose(); + + auto target_facet = mesh_facets.row(options.target_facet_id); + Eigen::Matrix expected_target = + (1.0f - options.target_facet_bc[0] - options.target_facet_bc[1]) * + mesh_vertices.row(target_facet[0]).transpose() + + options.target_facet_bc[0] * mesh_vertices.row(target_facet[1]).transpose() + + options.target_facet_bc[1] * mesh_vertices.row(target_facet[2]).transpose(); + + // Check path endpoints + const auto& src = result.points.front(); + const auto& tgt = result.points.back(); + Eigen::Matrix actual_source(src[0], src[1], src[2]); + Eigen::Matrix actual_target(tgt[0], tgt[1], tgt[2]); + + Scalar source_error = (actual_source - expected_source).norm(); + Scalar target_error = (actual_target - expected_target).norm(); + + REQUIRE_THAT(source_error, Catch::Matchers::WithinAbs(0.0f, 1e-4f)); + REQUIRE_THAT(target_error, Catch::Matchers::WithinAbs(0.0f, 1e-4f)); + + // Verify facet_ids are valid + for (auto fid : result.facet_ids) { + REQUIRE(fid < mesh.get_num_facets()); + } + } + + SECTION("Path length on sphere (north to south pole)") + { + // Generate a sphere mesh using the primitive module + lagrange::primitive::SphereOptions sphere_options; + sphere_options.radius = 10.0f; + sphere_options.num_longitude_sections = 32; + sphere_options.num_latitude_sections = 16; + sphere_options.triangulate = true; + + auto sphere = lagrange::primitive::generate_sphere(sphere_options); + + auto engine = lagrange::geodesic::make_mmp_engine(sphere); + auto vertices = lagrange::vertex_view(sphere); + auto facets = lagrange::facet_view(sphere); + + // Get the radius of the sphere + Scalar radius = static_cast(sphere_options.radius); + + // Find north pole (vertex with maximum z coordinate) + Index north_vertex = 0; + Scalar max_z = std::numeric_limits::lowest(); + for (Index v = 0; v < sphere.get_num_vertices(); ++v) { + Scalar z = vertices(v, 2); + if (z > max_z) { + max_z = z; + north_vertex = v; + } + } + + // Find south pole (vertex with minimum z coordinate) + Index south_vertex = 0; + Scalar min_z = std::numeric_limits::max(); + for (Index v = 0; v < sphere.get_num_vertices(); ++v) { + Scalar z = vertices(v, 2); + if (z < min_z) { + min_z = z; + south_vertex = v; + } + } + + // Find facets containing the north and south pole vertices + Index north_facet = lagrange::invalid(); + Index south_facet = lagrange::invalid(); + Index north_lc = 0; + Index south_lc = 0; + + for (Index f = 0; f < sphere.get_num_facets(); ++f) { + auto facet = facets.row(f); + if (north_facet == lagrange::invalid()) { + if (facet[0] == north_vertex) { + north_facet = f; + north_lc = 0; + } else if (facet[1] == north_vertex) { + north_facet = f; + north_lc = 1; + } else if (facet[2] == north_vertex) { + north_facet = f; + north_lc = 2; + } + } + if (south_facet == lagrange::invalid()) { + if (facet[0] == south_vertex) { + south_facet = f; + south_lc = 0; + } else if (facet[1] == south_vertex) { + south_facet = f; + south_lc = 1; + } else if (facet[2] == south_vertex) { + south_facet = f; + south_lc = 2; + } + } + + if (north_facet != lagrange::invalid() && + south_facet != lagrange::invalid()) { + break; + } + } + + REQUIRE(north_facet != lagrange::invalid()); + REQUIRE(south_facet != lagrange::invalid()); + + // Compute geodesic path from north to south pole + lagrange::geodesic::PointToPointGeodesicPathOptions options; + options.source_facet_id = north_facet; + options.target_facet_id = south_facet; + options.source_facet_bc = {0.0, 0.0}; + options.target_facet_bc = {0.0, 0.0}; + if (north_lc != 0) { + options.source_facet_bc[north_lc - 1] = 1; + } + if (south_lc != 0) { + options.target_facet_bc[south_lc - 1] = 1; + } + + auto result = engine.point_to_point_geodesic_path(options); + + REQUIRE(result.points.size() >= 2); + REQUIRE(result.facet_ids.size() == result.points.size() - 1); + + // Compute total path length + Scalar total_length = Scalar(0); + for (size_t i = 0; i + 1 < result.points.size(); ++i) { + Scalar dx = result.points[i + 1][0] - result.points[i][0]; + Scalar dy = result.points[i + 1][1] - result.points[i][1]; + Scalar dz = result.points[i + 1][2] - result.points[i][2]; + total_length += std::sqrt(dx * dx + dy * dy + dz * dz); + } + + // For a sphere, the geodesic distance between north and south poles + // (antipodal points) is exactly pi * radius (half the great circle) + // The 1% tolerance accounts for mesh discretization. + Scalar expected_length = static_cast(M_PI) * radius; + + REQUIRE_THAT(total_length, Catch::Matchers::WithinRel(expected_length, 0.01f)); + + // Verify all facet_ids are valid + for (auto fid : result.facet_ids) { + REQUIRE(fid < sphere.get_num_facets()); + } + } +} diff --git a/modules/image_io/include/lagrange/image_io/save_image_svg.h b/modules/image_io/include/lagrange/image_io/save_image_svg.h index 2edde2aa..bf482466 100644 --- a/modules/image_io/include/lagrange/image_io/save_image_svg.h +++ b/modules/image_io/include/lagrange/image_io/save_image_svg.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -71,7 +72,7 @@ void save_image_svg( height = float(bbox_max[1] - bbox_min[1]) * settings.scaling_factor; } - fout << fmt::format( + fout << format( " #include #include +#include #include @@ -37,7 +38,7 @@ bool save_image( "save_image error: invalid input (fn, data, width, height, precision, " "channel): {}, {}, {}, {}, {}, {}", input_path.string(), - fmt::ptr(data), + ptr(data), width, height, static_cast(precision), diff --git a/modules/io/CMakeLists.txt b/modules/io/CMakeLists.txt index a3ba3a19..c05eef4a 100644 --- a/modules/io/CMakeLists.txt +++ b/modules/io/CMakeLists.txt @@ -60,3 +60,7 @@ endif() if(LAGRANGE_MODULE_PYTHON) add_subdirectory(python) endif() + +if(LAGRANGE_BINDINGS_JS) + add_subdirectory(js) +endif() diff --git a/modules/io/js/CMakeLists.txt b/modules/io/js/CMakeLists.txt new file mode 100644 index 00000000..72c70af6 --- /dev/null +++ b/modules/io/js/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_js_binding(io IOModule) diff --git a/modules/io/js/src/io.cpp b/modules/io/js/src/io.cpp new file mode 100644 index 00000000..bbdad148 --- /dev/null +++ b/modules/io/js/src/io.cpp @@ -0,0 +1,214 @@ +/* + * 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 "bind_types.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace lagrange::js::bind { +namespace { + +template +T load_from_buffer_impl( + const emscripten::val& js_array, + const std::string& error_tag, + Loader loader) +{ + const auto length = js_array["length"].as(); + std::vector buf(length); + emscripten::val memory_view = emscripten::val( + emscripten::typed_memory_view(length, reinterpret_cast(buf.data()))); + memory_view.call("set", js_array); + + std::stringstream ss; + ss.write(buf.data(), static_cast(length)); + ss.seekg(0); + try { + return loader(ss); + } catch (const std::exception& e) { + std::ostringstream diag; + diag << error_tag << e.what() << " [size=" << length << ", magic="; + const size_t n = std::min(length, size_t(8)); + for (size_t i = 0; i < n; ++i) { + if (i) diag << ' '; + diag << std::hex << std::setw(2) << std::setfill('0') + << (static_cast(buf[i]) & 0xff); + } + diag << ']'; + emscripten::val::global("console").call("error", diag.str()); + throw; + } +} + +emscripten::val to_uint8array(const std::ostringstream& ss) +{ + const auto& buf = ss.str(); + return emscripten::val::global("Uint8Array") + .new_( + emscripten::typed_memory_view( + buf.size(), + reinterpret_cast(buf.data()))); +} + +void parse_load_opts(const emscripten::val& opts, io::LoadOptions& o) +{ + if (opts.isUndefined()) return; + apply_opt(opts, "triangulate", o.triangulate); + apply_opt(opts, "loadNormals", o.load_normals); + apply_opt(opts, "loadTangents", o.load_tangents); + apply_opt(opts, "loadUvs", o.load_uvs); + apply_opt(opts, "loadWeights", o.load_weights); + apply_opt(opts, "loadMaterials", o.load_materials); + apply_opt(opts, "loadVertexColors", o.load_vertex_colors); + apply_opt(opts, "loadObjectIds", o.load_object_ids); + apply_opt(opts, "loadImages", o.load_images); + apply_opt(opts, "stitchVertices", o.stitch_vertices); + apply_opt(opts, "quiet", o.quiet); +} + +// Overlays any fields present in `opts` onto `o`. Fields absent in `opts` keep their caller-set +// value (or the C++ struct default). +void parse_save_opts(const emscripten::val& opts, io::SaveOptions& o) +{ + if (opts.isUndefined()) return; + + auto enc = opts["encoding"]; + if (!enc.isUndefined()) { + o.encoding = + enc.as() == "ascii" ? io::FileEncoding::Ascii : io::FileEncoding::Binary; + } + + auto oa = opts["outputAttributes"]; + if (!oa.isUndefined()) { + o.output_attributes = oa.as() == "selectedOnly" + ? io::SaveOptions::OutputAttributes::SelectedOnly + : io::SaveOptions::OutputAttributes::All; + } + + auto sel = opts["selectedAttributes"]; + if (!sel.isUndefined()) { + unsigned len = sel["length"].as(); + o.selected_attributes.reserve(len); + for (unsigned i = 0; i < len; ++i) + o.selected_attributes.push_back(sel[i].as()); + } + + auto acp = opts["attributeConversionPolicy"]; + if (!acp.isUndefined()) { + o.attribute_conversion_policy = + acp.as() == "convertAsNeeded" + ? io::SaveOptions::AttributeConversionPolicy::ConvertAsNeeded + : io::SaveOptions::AttributeConversionPolicy::ExactMatchOnly; + } + apply_opt(opts, "embedImages", o.embed_images); + apply_opt(opts, "exportMaterials", o.export_materials); + apply_opt(opts, "quiet", o.quiet); +} + +MeshType load_mesh_from_buffer(const emscripten::val& js_array, const emscripten::val& opts) +{ + io::LoadOptions load_opts; + parse_load_opts(opts, load_opts); + return load_from_buffer_impl( + js_array, + "load_mesh error: ", + [&load_opts](std::istream& ss) { return io::load_mesh(ss, load_opts); }); +} + +emscripten::val +save_mesh_to_buffer(const MeshType& mesh, const std::string& format, const emscripten::val& opts) +{ + io::FileFormat file_format; + io::SaveOptions save_opts; + if (format == "obj") { + file_format = io::FileFormat::Obj; + save_opts.encoding = io::FileEncoding::Ascii; + } else if (format == "ply") { + file_format = io::FileFormat::Ply; + save_opts.encoding = io::FileEncoding::Binary; + } else if (format == "glb") { + file_format = io::FileFormat::Gltf; + save_opts.encoding = io::FileEncoding::Binary; + } else if (format == "gltf") { + file_format = io::FileFormat::Gltf; + save_opts.encoding = io::FileEncoding::Ascii; + } else if (format == "msh") { + file_format = io::FileFormat::Msh; + save_opts.encoding = io::FileEncoding::Binary; + } else { + throw std::invalid_argument("Unsupported mesh format: " + format); + } + parse_save_opts(opts, save_opts); + std::ostringstream ss; + io::save_mesh(ss, mesh, file_format, save_opts); + return to_uint8array(ss); +} + +SceneType load_scene_from_buffer(const emscripten::val& js_array, const emscripten::val& opts) +{ + io::LoadOptions load_opts; + parse_load_opts(opts, load_opts); + return load_from_buffer_impl( + js_array, + "load_scene error: ", + [&load_opts](std::istream& ss) { return io::load_scene(ss, load_opts); }); +} + +emscripten::val +save_scene_to_buffer(const SceneType& scene, const std::string& format, const emscripten::val& opts) +{ + io::FileFormat file_format; + io::SaveOptions save_opts; + if (format == "glb") { + file_format = io::FileFormat::Gltf; + save_opts.encoding = io::FileEncoding::Binary; + } else if (format == "gltf") { + file_format = io::FileFormat::Gltf; + save_opts.encoding = io::FileEncoding::Ascii; + } else if (format == "obj") { + file_format = io::FileFormat::Obj; + save_opts.encoding = io::FileEncoding::Ascii; + } else { + throw std::invalid_argument("Unsupported scene format: " + format); + } + parse_save_opts(opts, save_opts); + std::ostringstream ss; + io::save_scene(ss, scene, file_format, save_opts); + return to_uint8array(ss); +} + +} // namespace +} // namespace lagrange::js::bind + +EMSCRIPTEN_BINDINGS(lagrange_io) +{ + using namespace emscripten; + using namespace lagrange::js::bind; + + function("loadMeshFromBuffer", &load_mesh_from_buffer); + function("saveMeshToBuffer", &save_mesh_to_buffer); + function("loadSceneFromBuffer", &load_scene_from_buffer); + function("saveSceneToBuffer", &save_scene_to_buffer); +} diff --git a/modules/io/js/test/io.test.ts b/modules/io/js/test/io.test.ts new file mode 100644 index 00000000..895d3e87 --- /dev/null +++ b/modules/io/js/test/io.test.ts @@ -0,0 +1,97 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +describe("io mesh round-trip", () => { + const formats: Array<"obj" | "ply" | "glb" | "gltf"> = ["obj", "ply", "glb", "gltf"]; + + for (const format of formats) { + test(`save + load preserves mesh via ${format}`, () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const numVerts = mesh.getNumVertices(); + const numFacets = mesh.getNumFacets(); + + const bytes = lagrange.io.saveMeshToBuffer(mesh, format); + expect(bytes.length).toBeGreaterThan(0); + + const restored = lagrange.io.loadMeshFromBuffer(bytes, { quiet: true }); + expect(restored.getNumVertices()).toBe(numVerts); + // Some formats (e.g. obj) may re-triangulate or drop duplicates; + // facet count may differ. For sphere, all supported formats here + // preserve facet count. + expect(restored.getNumFacets()).toBe(numFacets); + + restored.delete(); + mesh.delete(); + }); + } + + + test("saveMeshToBuffer rejects unknown format", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + expect(() => lagrange.io.saveMeshToBuffer(mesh, "bogus" as any)).toThrow(); + mesh.delete(); + }); + + test("ascii encoding opt overrides default for ply", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const binary = lagrange.io.saveMeshToBuffer(mesh, "ply"); + const ascii = lagrange.io.saveMeshToBuffer(mesh, "ply", { encoding: "ascii" }); + + // ascii ply begins with "ply\nformat ascii" + const head = new TextDecoder().decode(ascii.slice(0, 32)); + expect(head).toContain("format ascii"); + // binary ply header declares binary_little_endian (or big) + const binHead = new TextDecoder().decode(binary.slice(0, 64)); + expect(binHead).toContain("format binary"); + + mesh.delete(); + }); +}); + +describe("io scene round-trip", () => { + // obj is save-only: stream-based io::load_scene supports Gltf/Fbx only, + // so loading an obj scene buffer throws. Saving is supported. + const roundTripFormats: Array<"glb" | "gltf"> = ["glb", "gltf"]; + + for (const format of roundTripFormats) { + test(`save + load preserves scene via ${format}`, () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const scene = lagrange.scene.meshToScene(mesh); + const numMeshes = scene.getNumMeshes(); + + const bytes = lagrange.io.saveSceneToBuffer(scene, format); + expect(bytes.length).toBeGreaterThan(0); + + const restored = lagrange.io.loadSceneFromBuffer(bytes, { quiet: true }); + expect(restored.getNumMeshes()).toBe(numMeshes); + + restored.delete(); + scene.delete(); + mesh.delete(); + }); + } + + test("save scene to obj produces bytes (load not supported)", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const scene = lagrange.scene.meshToScene(mesh); + const bytes = lagrange.io.saveSceneToBuffer(scene, "obj"); + expect(bytes.length).toBeGreaterThan(0); + scene.delete(); + mesh.delete(); + }); + + test("saveSceneToBuffer rejects unknown format", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const scene = lagrange.scene.meshToScene(mesh); + expect(() => lagrange.io.saveSceneToBuffer(scene, "bogus" as any)).toThrow(); + scene.delete(); + mesh.delete(); + }); +}); diff --git a/modules/io/js/ts/io.ts b/modules/io/js/ts/io.ts new file mode 100644 index 00000000..5128bb5a --- /dev/null +++ b/modules/io/js/ts/io.ts @@ -0,0 +1,90 @@ +/** + * Read and write mesh / scene files from in-memory byte buffers — no + * filesystem access. To load from a URL, `fetch` it first and pass the + * resulting `Uint8Array`. + * + * Supported formats are auto-detected on load. + * + * Omitted option fields fall back to library defaults. + */ + +import type { SurfaceMesh } from "./core.js"; +import type { Scene } from "./scene.js"; + +/** Single-mesh output formats. */ +export type OutputMeshFormat = "obj" | "ply" | "glb" | "gltf"; + +/** Scene-graph output formats. */ +export type OutputSceneFormat = "glb" | "gltf" | "obj"; + +/** Knobs for `loadMeshFromBuffer` / `loadSceneFromBuffer`. */ +export interface LoadOptions { + /** Triangulate polygons on load. Default: `false`. */ + triangulate?: boolean; + /** Import vertex normals if present. Default: `true`. */ + loadNormals?: boolean; + /** Import tangents and bitangents if present. Default: `true`. */ + loadTangents?: boolean; + /** Import texture coordinates. Default: `true`. */ + loadUvs?: boolean; + /** Import skinning joints and weights. Default: `true`. */ + loadWeights?: boolean; + /** Import per-facet material ids. Default: `true`. */ + loadMaterials?: boolean; + /** Import per-vertex colors. Default: `true`. */ + loadVertexColors?: boolean; + /** Import per-facet object ids. Default: `true`. */ + loadObjectIds?: boolean; + /** Resolve and decode referenced image data. Default: `true`. */ + loadImages?: boolean; + /** Merge coincident boundary vertices while loading. Default: `false`. */ + stitchVertices?: boolean; + /** Suppress warnings printed to the console. Default: `false`. */ + quiet?: boolean; +} + +/** Knobs for `saveMeshToBuffer` / `saveSceneToBuffer`. */ +export interface SaveOptions { + /** + * Override the default encoding. If unset, `binary` is used for `ply`/`glb` + * and `ascii` for `obj`/`gltf`. + */ + encoding?: "binary" | "ascii"; + /** Which attributes to write. Default: `"all"`. */ + outputAttributes?: "all" | "selectedOnly"; + /** Attribute ids to write when {@link outputAttributes} is `"selectedOnly"`. */ + selectedAttributes?: number[]; + /** + * How to handle attributes whose type the format can't store natively. + * `"exactMatchOnly"` drops them; `"convertAsNeeded"` casts to a + * supported type. Default: `"exactMatchOnly"`. + */ + attributeConversionPolicy?: "exactMatchOnly" | "convertAsNeeded"; + /** Embed image data inside the file where the format supports it (e.g. `glb`). Default: `false`. */ + embedImages?: boolean; + /** Write materials and textures. Default: `true`. */ + exportMaterials?: boolean; + /** Suppress warnings printed to the console. Default: `false`. */ + quiet?: boolean; +} + +/** + * Loading / saving. Accessible as `lagrange.io`. + */ +export interface IOModule { + /** Parse a mesh from the bytes of an `obj`/`ply`/`glb`/`gltf` file. Format is auto-detected. */ + loadMeshFromBuffer(data: Uint8Array, opts?: LoadOptions): SurfaceMesh; + /** Serialize a mesh to the given file format. Returns the encoded bytes. */ + saveMeshToBuffer(mesh: SurfaceMesh, format: OutputMeshFormat, opts?: SaveOptions): Uint8Array; + /** Parse a full scene (multi-mesh, materials, nodes) from file bytes. Format is auto-detected. */ + loadSceneFromBuffer(data: Uint8Array, opts?: LoadOptions): Scene; + /** Serialize a scene to `glb`, `gltf`, or `obj`. Returns the encoded bytes. */ + saveSceneToBuffer(scene: Scene, format: OutputSceneFormat, opts?: SaveOptions): Uint8Array; +} + +export const ioModuleKeys = [ + "loadMeshFromBuffer", + "saveMeshToBuffer", + "loadSceneFromBuffer", + "saveSceneToBuffer", +] as const satisfies readonly (keyof IOModule)[]; diff --git a/modules/io/python/src/io.cpp b/modules/io/python/src/io.cpp index 913f21ee..8f8eb3bc 100644 --- a/modules/io/python/src/io.cpp +++ b/modules/io/python/src/io.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -354,7 +355,7 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl opts.encoding = io::FileEncoding::Binary; io::save_mesh_gltf(ss, mesh, opts); } else { - throw std::invalid_argument(fmt::format("Unsupported format: {}", format)); + throw std::invalid_argument(lagrange::format("Unsupported format: {}", format)); } // TODO: switch to ss.view() when C++20 is available. std::string data = ss.str(); @@ -573,7 +574,7 @@ The binary string should use one of the supported formats (i.e. `gltf`, `glb` an opts.encoding = io::FileEncoding::Binary; io::save_scene(ss, scene, lagrange::io::FileFormat::Gltf, opts); } else { - throw std::invalid_argument(fmt::format("Unsupported format: {}", format)); + throw std::invalid_argument(lagrange::format("Unsupported format: {}", format)); } // TODO: switch to ss.view() when C++20 is available. diff --git a/modules/io/src/load_fbx.cpp b/modules/io/src/load_fbx.cpp index 79f19a50..3677353b 100644 --- a/modules/io/src/load_fbx.cpp +++ b/modules/io/src/load_fbx.cpp @@ -26,7 +26,7 @@ #include #include #include -#include +#include #include #include #include @@ -114,8 +114,7 @@ MeshType convert_mesh_ufbx_to_lagrange(const ufbx_mesh* mesh, const LoadOptions& if (opt.load_uvs) { for (size_t i = 0; i < mesh->uv_sets.count; ++i) { const ufbx_uv_set& uv_set = mesh->uv_sets[i]; - std::string name = - lagrange::internal::get_unique_attribute_name(lmesh, uv_set.name.data); + std::string name = lagrange::get_unique_attribute_name(lmesh, uv_set.name.data); auto id = lmesh.template create_attribute( name, diff --git a/modules/io/src/load_gltf.cpp b/modules/io/src/load_gltf.cpp index fe6badf4..3e0e6674 100644 --- a/modules/io/src/load_gltf.cpp +++ b/modules/io/src/load_gltf.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -185,7 +186,7 @@ span load_buffer( size_t num_channels = get_num_channels(accessor.type); if (num_channels == invalid()) - throw Error(fmt::format("Unsupported accessor type {}", accessor.type)); + throw Error(format("Unsupported accessor type {}", accessor.type)); const size_t start = accessor.byteOffset + buffer_view.byteOffset; @@ -339,7 +340,12 @@ void accessor_to_attribute_internal( } else if (accessor.count == mesh.get_num_facets()) { element = AttributeElement::Facet; } else { - logger().error("Unknown mesh property {}!", name); + logger().error( + "Unknown mesh property {}! Has {} values, but mesh has {} vertices and {} facets", + name, + accessor.count, + mesh.get_num_vertices(), + mesh.get_num_facets()); return; } @@ -359,7 +365,12 @@ void accessor_to_attribute_internal( // Matrices are flattened as vectors for now. usage = AttributeUsage::Vector; break; - default: logger().error("Unknown mesh property {}!", name); return; + default: + logger().error( + "Unknown mesh property {}! Accessor type not supported: {}", + name, + accessor.type); + return; } } @@ -869,8 +880,24 @@ SceneType load_scene_gltf(const tinygltf::Model& model, const LoadOptions& optio const tinygltf::Mesh& mesh = model.meshes[node.mesh]; for (size_t i = 0; i < mesh.primitives.size(); ++i) { size_t mesh_idx = primitive_count[node.mesh] + i; - size_t material_idx = mesh.primitives[i].material; - lnode.meshes.push_back({mesh_idx, {material_idx}}); + int material_idx = mesh.primitives[i].material; + if (material_idx < -1 || material_idx >= static_cast(model.materials.size())) { + if (!options.quiet) { + logger().warn( + "Mesh {} primitive {} references material {} which is out of range. " + "Ignoring material.", + node.mesh, + i, + material_idx); + } + material_idx = -1; + } + if (material_idx == -1) { + lnode.meshes.push_back({mesh_idx, {}}); + } else { + size_t mat_idx = static_cast(material_idx); + lnode.meshes.push_back({mesh_idx, {mat_idx}}); + } } } if (!node.extensions.empty()) { diff --git a/modules/io/src/load_mesh_msh.cpp b/modules/io/src/load_mesh_msh.cpp index 2be490ae..5ac0b812 100644 --- a/modules/io/src/load_mesh_msh.cpp +++ b/modules/io/src/load_mesh_msh.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -143,7 +144,7 @@ void extract_attribute( for (auto special_usage : {AttributeUsage::Normal, AttributeUsage::UV, AttributeUsage::Color}) { if (starts_with( attr_name, - fmt::format( + format( "{}_{}", internal::to_string(element_type), internal::to_string(special_usage)))) { @@ -268,7 +269,7 @@ template MeshType load_mesh_msh(const fs::path& filename, const LoadOptions& options) { fs::ifstream fin(filename, std::ios::binary); - la_runtime_assert(fin.good(), fmt::format("Unable to open file {}", filename.string())); + la_runtime_assert(fin.good(), format("Unable to open file {}", filename.string())); return load_mesh_msh(fin, options); } diff --git a/modules/io/src/load_mesh_ply.cpp b/modules/io/src/load_mesh_ply.cpp index e6fcc72f..40567d00 100644 --- a/modules/io/src/load_mesh_ply.cpp +++ b/modules/io/src/load_mesh_ply.cpp @@ -30,6 +30,7 @@ #include #include #include +#include // clang-format on namespace lagrange::io { @@ -51,14 +52,14 @@ void extract_normal( SurfaceMesh& mesh) { std::string_view suffix = get_suffix(name); - auto nx = ply_element.getProperty(fmt::format("nx{}", suffix)); - auto ny = ply_element.getProperty(fmt::format("ny{}", suffix)); - auto nz = ply_element.getProperty(fmt::format("nz{}", suffix)); + auto nx = ply_element.getProperty(format("nx{}", suffix)); + auto ny = ply_element.getProperty(format("ny{}", suffix)); + auto nz = ply_element.getProperty(format("nz{}", suffix)); Index num_entries = static_cast(nx.size()); auto usage = AttributeUsage::Normal; std::string attr_name = - fmt::format("{}_{}{}", internal::to_string(element), internal::to_string(usage), suffix); + format("{}_{}{}", internal::to_string(element), internal::to_string(usage), suffix); logger().debug("Reading normal attribute {} -> {}", name, attr_name); @@ -79,14 +80,14 @@ void extract_vertex_uv( SurfaceMesh& mesh) { std::string_view suffix = get_suffix(name); - auto u = vertex_element.getProperty(fmt::format("s{}", suffix)); - auto v = vertex_element.getProperty(fmt::format("t{}", suffix)); + auto u = vertex_element.getProperty(format("s{}", suffix)); + auto v = vertex_element.getProperty(format("t{}", suffix)); Index num_vertices = static_cast(u.size()); auto element = AttributeElement::Vertex; auto usage = AttributeUsage::UV; std::string attr_name = - fmt::format("{}_{}{}", internal::to_string(element), internal::to_string(usage), suffix); + format("{}_{}{}", internal::to_string(element), internal::to_string(usage), suffix); logger().debug("Reading uv attribute {} -> {}", name, attr_name); @@ -105,15 +106,15 @@ void extract_color( SurfaceMesh& mesh) { std::string_view suffix = get_suffix(name); - auto red = ply_element.getProperty(fmt::format("red{}", suffix)); - auto green = ply_element.getProperty(fmt::format("green{}", suffix)); - auto blue = ply_element.getProperty(fmt::format("blue{}", suffix)); - bool has_alpha = ply_element.hasPropertyType(fmt::format("alpha{}", suffix)); + auto red = ply_element.getProperty(format("red{}", suffix)); + auto green = ply_element.getProperty(format("green{}", suffix)); + auto blue = ply_element.getProperty(format("blue{}", suffix)); + bool has_alpha = ply_element.hasPropertyType(format("alpha{}", suffix)); Index num_entries = static_cast(red.size()); auto usage = AttributeUsage::Color; std::string attr_name = - fmt::format("{}_{}{}", internal::to_string(element), internal::to_string(usage), suffix); + format("{}_{}{}", internal::to_string(element), internal::to_string(usage), suffix); Index num_channels = has_alpha ? 4 : 3; logger().debug("Reading color attribute {} -> {}", name, attr_name); @@ -128,7 +129,7 @@ void extract_color( } if (has_alpha) { - auto alpha = ply_element.getProperty(fmt::format("alpha{}", suffix)); + auto alpha = ply_element.getProperty(format("alpha{}", suffix)); for (Index i = 0; i < num_entries; ++i) { attr[i * num_channels + 3] = alpha[i]; } @@ -356,7 +357,7 @@ template MeshType load_mesh_ply(const fs::path& filename, const LoadOptions& options) { fs::ifstream fin(filename, std::ios::binary); - la_runtime_assert(fin.good(), fmt::format("Unable to open file {}", filename.string())); + la_runtime_assert(fin.good(), format("Unable to open file {}", filename.string())); return load_mesh_ply(fin, options); } diff --git a/modules/io/src/load_mesh_stl.cpp b/modules/io/src/load_mesh_stl.cpp index 4a469e4c..4747ce2e 100644 --- a/modules/io/src/load_mesh_stl.cpp +++ b/modules/io/src/load_mesh_stl.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include "stitch_mesh.h" @@ -153,7 +154,7 @@ template MeshType load_mesh_stl(const fs::path& filename, const LoadOptions& options) { fs::ifstream fin(filename, std::ios::binary); - la_runtime_assert(fin.good(), fmt::format("Unable to open file {}", filename.string())); + la_runtime_assert(fin.good(), format("Unable to open file {}", filename.string())); return load_mesh_stl(fin, options); } diff --git a/modules/io/src/load_obj.cpp b/modules/io/src/load_obj.cpp index 82b01810..88fb067e 100644 --- a/modules/io/src/load_obj.cpp +++ b/modules/io/src/load_obj.cpp @@ -45,6 +45,7 @@ #include #include #include +#include // clang-format on namespace lagrange::io { @@ -164,15 +165,33 @@ ObjReaderResult extract_mes // Reserve facet indices logger().trace("[load_mesh_obj] Reserve facet indices"); std::vector facet_sizes; - std::vector facet_counts; + std::vector num_facets_per_shape; + std::vector num_segments_per_shape; + std::vector num_polylines_per_shape; + bool has_any_lines = false; + // First pass: append all face facet sizes for (const auto& shape : shapes) { facet_sizes.insert( facet_sizes.end(), shape.mesh.num_face_vertices.begin(), shape.mesh.num_face_vertices.end()); - facet_counts.push_back(static_cast(shape.mesh.num_face_vertices.size())); + num_facets_per_shape.push_back(static_cast(shape.mesh.num_face_vertices.size())); + Index num_line_segments = 0; + for (auto nv : shape.lines.num_line_vertices) { + la_runtime_assert(nv >= 2, "Line element must have at least 2 vertices"); + num_line_segments += static_cast(nv - 1); + } + num_segments_per_shape.push_back(num_line_segments); + num_polylines_per_shape.push_back(static_cast(shape.lines.num_line_vertices.size())); + if (num_line_segments > 0) has_any_lines = true; result.names.push_back(shape.name); } + // Second pass: append all line segment facet sizes (after all faces) + for (const auto& shape : shapes) { + for (auto nv : shape.lines.num_line_vertices) { + facet_sizes.insert(facet_sizes.end(), nv - 1, 2); + } + } if (!facet_sizes.empty()) { mesh.add_hybrid(facet_sizes); } @@ -197,17 +216,40 @@ ObjReaderResult extract_mes id_attr = &mesh.template ref_attribute(id); } + // Initialize line id (0 for regular faces, 1-based for line element segments) + Attribute* line_id_attr = nullptr; + if (has_any_lines) { + auto id = mesh.template create_attribute( + AttributeName::line_id, + AttributeElement::Facet, + AttributeUsage::Scalar); + line_id_attr = &mesh.template ref_attribute(id); + } + span vtx_indices = mesh.ref_corner_to_vertex().ref_all(); span uv_indices = (uv_attr ? uv_attr->indices().ref_all() : span{}); span nrm_indices = (nrm_attr ? nrm_attr->indices().ref_all() : span{}); logger().trace("[load_mesh_obj] Copy facet indices"); - std::partial_sum(facet_counts.begin(), facet_counts.end(), facet_counts.begin()); + // Compute cumulative sums for facets, line segments, and polyline ids + std::partial_sum( + num_facets_per_shape.begin(), + num_facets_per_shape.end(), + num_facets_per_shape.begin()); + std::partial_sum( + num_segments_per_shape.begin(), + num_segments_per_shape.end(), + num_segments_per_shape.begin()); + std::partial_sum( + num_polylines_per_shape.begin(), + num_polylines_per_shape.end(), + num_polylines_per_shape.begin()); + const Index total_facets = num_facets_per_shape.empty() ? 0 : num_facets_per_shape.back(); std::atomic_size_t num_invalid_uv = 0; std::atomic_size_t num_invalid_nrm = 0; tbb::parallel_for(Index(0), Index(shapes.size()), [&](Index i) { const auto& shape = shapes[i]; - const Index first_facet = (i == 0 ? 0 : facet_counts[i - 1]); + const Index first_facet = (i == 0 ? 0 : num_facets_per_shape[i - 1]); const auto local_num_facets = safe_cast(shape.mesh.num_face_vertices.size()); // Copy material id @@ -233,7 +275,7 @@ ObjReaderResult extract_mes std::fill(span.begin(), span.end(), i); } - // Copy indices + // Copy face indices for (Index f = 0, local_corner = 0; f < local_num_facets; ++f) { const Index first_corner = mesh.get_facet_corner_begin(first_facet + f); const Index last_corner = mesh.get_facet_corner_end(first_facet + f); @@ -260,6 +302,61 @@ ObjReaderResult extract_mes } } + // Copy line element indices as 2-vertex facets + if (!shape.lines.indices.empty()) { + const Index first_line_facet = + total_facets + (i == 0 ? 0 : num_segments_per_shape[i - 1]); + const Index first_polyline_id = (i == 0 ? 0 : num_polylines_per_shape[i - 1]); + Index line_facet = 0; + Index line_idx = 0; + Index local_polyline = 0; + for (auto nv : shape.lines.num_line_vertices) { + const Index global_line_id = first_polyline_id + local_polyline + 1; // 1-based + for (int e = 0; e < nv - 1; ++e) { + const Index fid = first_line_facet + line_facet; + const Index c0 = mesh.get_facet_corner_begin(fid); + vtx_indices[c0] = + safe_cast(shape.lines.indices[line_idx + e].vertex_index); + vtx_indices[c0 + 1] = + safe_cast(shape.lines.indices[line_idx + e + 1].vertex_index); + if (!uv_indices.empty()) { + auto ti0 = shape.lines.indices[line_idx + e].texcoord_index; + auto ti1 = shape.lines.indices[line_idx + e + 1].texcoord_index; + uv_indices[c0] = ti0 < 0 ? invalid() : safe_cast(ti0); + uv_indices[c0 + 1] = ti1 < 0 ? invalid() : safe_cast(ti1); + if (ti0 < 0) ++num_invalid_uv; + if (ti1 < 0) ++num_invalid_uv; + } + if (!nrm_indices.empty()) { + auto ni0 = shape.lines.indices[line_idx + e].normal_index; + auto ni1 = shape.lines.indices[line_idx + e + 1].normal_index; + nrm_indices[c0] = ni0 < 0 ? invalid() : safe_cast(ni0); + nrm_indices[c0 + 1] = ni1 < 0 ? invalid() : safe_cast(ni1); + if (ni0 < 0) ++num_invalid_nrm; + if (ni1 < 0) ++num_invalid_nrm; + } + if (line_id_attr) { + line_id_attr->ref_all()[fid] = global_line_id; + } + ++line_facet; + } + line_idx += nv; + ++local_polyline; + } + + // Set material and object ids for line facets + const Index local_num_segments = + num_segments_per_shape[i] - (i == 0 ? 0 : num_segments_per_shape[i - 1]); + if (mat_attr) { + auto span = mat_attr->ref_middle(first_line_facet, local_num_segments); + std::fill(span.begin(), span.end(), SignedIndex(-1)); + } + if (id_attr) { + auto span = id_attr->ref_middle(first_line_facet, local_num_segments); + std::fill(span.begin(), span.end(), i); + } + } + // TODO: Support smoothing groups + subd tags }); @@ -497,7 +594,7 @@ MeshType load_mesh_obj(const fs::path& filename, const LoadOptions& options) { auto ret = internal::load_mesh_obj(filename, options); if (!ret.success) { - throw Error(fmt::format("Failed to load mesh from file: '{}'", filename.string())); + throw Error(format("Failed to load mesh from file: '{}'", filename.string())); } return std::move(ret.mesh); } diff --git a/modules/io/src/save_gltf.cpp b/modules/io/src/save_gltf.cpp index c8c48820..0051ff31 100644 --- a/modules/io/src/save_gltf.cpp +++ b/modules/io/src/save_gltf.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -647,7 +648,7 @@ tinygltf::Model lagrange_simple_scene_to_gltf_model( if (lmesh.get_num_vertices() == 0) continue; if (lscene.get_num_instances(i) == 0) continue; - const auto& tmesh = ensure_triangulated(fmt::format("#{}", i), lmesh, options); + const auto& tmesh = ensure_triangulated(format("#{}", i), lmesh, options); model.meshes.push_back(create_gltf_mesh(model, tmesh, options)); for (Index j = 0; j < lscene.get_num_instances(i); ++j) { @@ -744,16 +745,15 @@ tinygltf::Model lagrange_scene_to_gltf_model( llight.color_diffuse.y(), llight.color_diffuse.z()}; light.intensity = 1 / llight.attenuation_constant; - auto light_label = llight.name.empty() - ? fmt::format("light[{}]", light_idx) - : fmt::format("'{}' (index {})", llight.name, light_idx); + auto light_label = llight.name.empty() ? format("light[{}]", light_idx) + : format("'{}' (index {})", llight.name, light_idx); switch (llight.type) { case scene::Light::Type::Directional: light.type = "directional"; break; case scene::Light::Type::Point: light.type = "point"; break; case scene::Light::Type::Spot: la_runtime_assert( llight.angle_inner_cone.has_value() && llight.angle_outer_cone.has_value(), - fmt::format("Spot light {} must have inner and outer cone angles.", light_label)); + format("Spot light {} must have inner and outer cone angles.", light_label)); light.type = "spot"; light.spot.innerConeAngle = llight.angle_inner_cone.value(); light.spot.outerConeAngle = llight.angle_outer_cone.value(); @@ -962,15 +962,17 @@ tinygltf::Model lagrange_scene_to_gltf_model( if (!lnode.meshes.empty()) { // we treat multiple meshes in one lagrange node as one gltf mesh with multiple - // primitives. they must reference exactly one material. + // primitives. they must reference at most one material. tinygltf::Mesh mesh; for (const auto& mesh_instance : lnode.meshes) { const auto& lmesh = lscene.meshes[mesh_instance.mesh]; const auto& tmesh = ensure_triangulated(node.name, lmesh, options); tinygltf::Primitive prim = create_gltf_primitive(model, tmesh, options); if (options.export_materials) { - la_runtime_assert(mesh_instance.materials.size() == 1); - prim.material = lagrange::safe_cast(mesh_instance.materials.front()); + la_runtime_assert(mesh_instance.materials.size() <= 1); + if (mesh_instance.materials.size() == 1) { + prim.material = lagrange::safe_cast(mesh_instance.materials.front()); + } } mesh.primitives.push_back(prim); } diff --git a/modules/io/src/save_mesh_msh.cpp b/modules/io/src/save_mesh_msh.cpp index db48a66e..c20fb56a 100644 --- a/modules/io/src/save_mesh_msh.cpp +++ b/modules/io/src/save_mesh_msh.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -52,21 +53,21 @@ get_attribute_name(const SurfaceMesh& mesh, AttributeId id, Attri switch (usage) { case AttributeUsage::UV: - name = fmt::format( + name = format( "{}_{}_{}", internal::to_string(element), internal::to_string(usage), counts.uv_count++); break; case AttributeUsage::Normal: - name = fmt::format( + name = format( "{}_{}_{}", internal::to_string(element), internal::to_string(usage), counts.normal_count++); break; case AttributeUsage::Color: - name = fmt::format( + name = format( "{}_{}_{}", internal::to_string(element), internal::to_string(usage), @@ -450,7 +451,7 @@ void save_mesh_msh( if (!fout) { throw std::runtime_error( - fmt::format("Failed to open MSH file for writing: {}", filename.string())); + format("Failed to open MSH file for writing: {}", filename.string())); } save_mesh_msh(fout, mesh, options); diff --git a/modules/io/src/save_mesh_ply.cpp b/modules/io/src/save_mesh_ply.cpp index 48a8f6d8..c0e92a27 100644 --- a/modules/io/src/save_mesh_ply.cpp +++ b/modules/io/src/save_mesh_ply.cpp @@ -27,6 +27,7 @@ #include #include #include +#include // clang-format on #include @@ -90,22 +91,22 @@ void register_normal(happly::Element& element, std::string_view name, T&& attr, using ValueType = typename AttributeType::ValueType; logger().debug("Writing normal attribute '{}'", name); auto nrm = matrix_view(attr); - std::string suffix = count == 0 ? "" : fmt::format("_{}", count); + std::string suffix = count == 0 ? "" : format("_{}", count); if constexpr (is_valid_ply_type()) { auto nx = to_vector(nrm.col(0)); auto ny = to_vector(nrm.col(1)); auto nz = to_vector(nrm.col(2)); - element.addProperty(fmt::format("nx{}", suffix), nx); - element.addProperty(fmt::format("ny{}", suffix), ny); - element.addProperty(fmt::format("nz{}", suffix), nz); + element.addProperty(format("nx{}", suffix), nx); + element.addProperty(format("ny{}", suffix), ny); + element.addProperty(format("nz{}", suffix), nz); } else { using ValueType2 = std::conditional_t, int32_t, uint32_t>; auto nx = to_vector(nrm.col(0)); auto ny = to_vector(nrm.col(1)); auto nz = to_vector(nrm.col(2)); - element.addProperty(fmt::format("nx{}", suffix), nx); - element.addProperty(fmt::format("ny{}", suffix), ny); - element.addProperty(fmt::format("nz{}", suffix), nz); + element.addProperty(format("nx{}", suffix), nx); + element.addProperty(format("ny{}", suffix), ny); + element.addProperty(format("nz{}", suffix), nz); } ++count; } @@ -117,18 +118,18 @@ void register_uv(happly::Element& element, std::string_view name, T&& attr, size using ValueType = typename AttributeType::ValueType; logger().debug("Writing uv attribute '{}'", name); auto uv = matrix_view(attr); - std::string suffix = count == 0 ? "" : fmt::format("_{}", count); + std::string suffix = count == 0 ? "" : format("_{}", count); if constexpr (is_valid_ply_type()) { auto s = to_vector(uv.col(0)); auto t = to_vector(uv.col(1)); - element.addProperty(fmt::format("s{}", suffix), s); - element.addProperty(fmt::format("t{}", suffix), t); + element.addProperty(format("s{}", suffix), s); + element.addProperty(format("t{}", suffix), t); } else { using ValueType2 = std::conditional_t, int32_t, uint32_t>; auto s = to_vector(uv.col(0)); auto t = to_vector(uv.col(1)); - element.addProperty(fmt::format("s{}", suffix), s); - element.addProperty(fmt::format("t{}", suffix), t); + element.addProperty(format("s{}", suffix), s); + element.addProperty(format("t{}", suffix), t); } ++count; } @@ -141,7 +142,7 @@ void register_color(happly::Element& element, std::string_view name, T&& attr, s auto num_channels = attr.get_num_channels(); if (num_channels != 3 && num_channels != 4) return; auto num_elements = attr.get_num_elements(); - std::string suffix = count == 0 ? "" : fmt::format("_{}", count); + std::string suffix = count == 0 ? "" : format("_{}", count); logger().debug("Writing color attribute '{}'", name); @@ -160,20 +161,20 @@ void register_color(happly::Element& element, std::string_view name, T&& attr, s if constexpr (is_valid_ply_type()) { std::vector buf; - add_channel(fmt::format("red{}", suffix), 0, buf); - add_channel(fmt::format("green{}", suffix), 1, buf); - add_channel(fmt::format("blue{}", suffix), 2, buf); + add_channel(format("red{}", suffix), 0, buf); + add_channel(format("green{}", suffix), 1, buf); + add_channel(format("blue{}", suffix), 2, buf); if (num_channels == 4) { - add_channel(fmt::format("alpha{}", suffix), 3, buf); + add_channel(format("alpha{}", suffix), 3, buf); } } else { using ValueType2 = std::conditional_t, int32_t, uint32_t>; std::vector buf; - add_channel(fmt::format("red{}", suffix), 0, buf); - add_channel(fmt::format("green{}", suffix), 1, buf); - add_channel(fmt::format("blue{}", suffix), 2, buf); + add_channel(format("red{}", suffix), 0, buf); + add_channel(format("green{}", suffix), 1, buf); + add_channel(format("blue{}", suffix), 2, buf); if (num_channels == 4) { - add_channel(fmt::format("alpha{}", suffix), 3, buf); + add_channel(format("alpha{}", suffix), 3, buf); } } @@ -390,7 +391,7 @@ void save_mesh_ply( if (!fout) { throw std::runtime_error( - fmt::format("Failed to open PLY file for writing: {}", filename.string())); + format("Failed to open PLY file for writing: {}", filename.string())); } save_mesh_ply(fout, mesh, options); diff --git a/modules/io/src/save_obj.cpp b/modules/io/src/save_obj.cpp index ffdb1999..e0f7c376 100644 --- a/modules/io/src/save_obj.cpp +++ b/modules/io/src/save_obj.cpp @@ -16,26 +16,24 @@ #include #include +#include #include #include +#include #include #include #include #include #include #include +#include +#include #include #include #include #include -// clang-format off -#include -#include -#include -// clang-format on - namespace lagrange { namespace io { @@ -48,7 +46,7 @@ namespace { template void write_obj_header(std::ostream& output_stream, Index num_vertices, Index num_facets) { - fmt::print( + print( output_stream, R"(#### # @@ -132,7 +130,7 @@ AttributeWriteResult write_mesh_attributes( result.uv_values_written = static_cast(values->get_num_elements()); for (Index vt = 0; vt < values->get_num_elements(); ++vt) { auto p = values->get_row(vt); - fmt::print(output_stream, "vt {} {}\n", p[0], p[1]); + print(output_stream, "vt {} {}\n", p[0], p[1]); } } @@ -184,7 +182,7 @@ AttributeWriteResult write_mesh_attributes( result.normal_values_written = static_cast(values->get_num_elements()); for (Index vn = 0; vn < values->get_num_elements(); ++vn) { auto p = values->get_row(vn); - fmt::print(output_stream, "vn {} {} {}\n", p[0], p[1], p[2]); + print(output_stream, "vn {} {} {}\n", p[0], p[1], p[2]); } } }); @@ -212,11 +210,11 @@ void write_mesh_vertices( if constexpr (Dim == 2) { Eigen::Matrix p{pos_span[0], pos_span[1]}; p = transform * p; - fmt::print(output_stream, "v {} {}\n", p[0], p[1]); + print(output_stream, "v {} {} 0\n", p[0], p[1]); } else if constexpr (Dim == 3) { Eigen::Matrix p{pos_span[0], pos_span[1], pos_span[2]}; p = transform * p; - fmt::print(output_stream, "v {} {} {}\n", p[0], p[1], p[2]); + print(output_stream, "v {} {} {}\n", p[0], p[1], p[2]); } } } @@ -232,12 +230,31 @@ void write_mesh_facets( { const Index num_facets = mesh.get_num_facets(); + // Check for line_id attribute (must be a facet-scalar attribute of type Index) + span line_ids; + if (mesh.has_attribute(AttributeName::line_id)) { + auto lid = mesh.get_attribute_id(AttributeName::line_id); + const auto& base = mesh.get_attribute_base(lid); + if (base.get_element_type() == AttributeElement::Facet && base.get_num_channels() == 1 && + base.get_value_type() == make_attribute_value_type()) { + const auto& line_id_attr = mesh.template get_attribute(lid); + line_ids = line_id_attr.get_all(); + } else { + logger().warn( + "Ignoring attribute '{}': expected facet scalar of type Index", + AttributeName::line_id); + } + } + + // Write regular faces (line_id == 0 or no line_id attribute) for (Index f = 0; f < num_facets; ++f) { + if (!line_ids.empty() && line_ids[f] != 0) continue; + const Index first_corner = mesh.get_facet_corner_begin(f); const auto vtx_indices = mesh.get_facet_vertices(f); la_runtime_assert( vtx_indices.size() >= 3, - fmt::format("Mesh facet {} should have >= 3 vertices", f)); + format("Mesh facet {} should have >= 3 vertices", f)); output_stream << "f"; for (Index lv = 0; lv < vtx_indices.size(); ++lv) { // vertex_index/texture_index/normal_index (OBJ indices are 1-based) @@ -251,17 +268,88 @@ void write_mesh_facets( 1 + normal_offset; if (attr_result.uv_indices.empty() && attr_result.normal_indices.empty()) { - fmt::print(output_stream, " {}", v); + print(output_stream, " {}", v); } else if (!attr_result.uv_indices.empty() && attr_result.normal_indices.empty()) { - fmt::print(output_stream, " {}/{}", v, vt); + print(output_stream, " {}/{}", v, vt); } else if (!attr_result.uv_indices.empty() && !attr_result.normal_indices.empty()) { - fmt::print(output_stream, " {}/{}/{}", v, vt, vn); + print(output_stream, " {}/{}/{}", v, vt, vn); } else if (attr_result.uv_indices.empty() && !attr_result.normal_indices.empty()) { - fmt::print(output_stream, " {}//{}", v, vn); + print(output_stream, " {}//{}", v, vn); } } output_stream << "\n"; } + + // Write line elements grouped by line_id + if (!line_ids.empty()) { + const bool has_uv = !attr_result.uv_indices.empty(); + + // Find max line_id to size the vector + Index max_line_id = 0; + for (Index f = 0; f < num_facets; ++f) { + if (line_ids[f] > max_line_id) max_line_id = line_ids[f]; + } + la_runtime_assert( + max_line_id <= num_facets + 1, + "line_id is not a dense mapping and has unexpected large values"); + + // Collect directed edges and UV indices per line_id (1-based, so index 0 is unused) + std::vector> edges_per_line(max_line_id + 1); + std::vector> uv_per_line(has_uv ? max_line_id + 1 : 0); + for (Index f = 0; f < num_facets; ++f) { + if (line_ids[f] != 0) { + auto seg = mesh.get_facet_vertices(f); + if (seg.size() != 2) { + logger().warn( + "OBJ saver: facet {} with nonzero line_id {} has {} vertices; " + "expected 2. Skipping this facet.", + f, + line_ids[f], + seg.size()); + continue; + } + Index lid = line_ids[f]; + edges_per_line[lid].push_back(seg[0]); + edges_per_line[lid].push_back(seg[1]); + if (has_uv) { + Index c0 = mesh.get_facet_corner_begin(f); + uv_per_line[lid].push_back(attr_result.uv_indices[c0]); + uv_per_line[lid].push_back(attr_result.uv_indices[c0 + 1]); + } + } + } + + // Write a single line vertex token (v or v/vt) + auto write_line_vertex = [&](Index vi, Index uvi) { + if (has_uv) { + print(output_stream, " {}/{}", vi + 1 + vertex_offset, uvi + 1 + uv_offset); + } else { + print(output_stream, " {}", vi + 1 + vertex_offset); + } + }; + + // Chain edges and write polylines. Use edge-index output so we can + // reconstruct both vertex and UV sequences from the original edge arrays. + ChainEdgesOptions chain_options; + chain_options.output_edge_index = true; + + for (Index lid = 1; lid <= max_line_id; ++lid) { + auto& edges = edges_per_line[lid]; + if (edges.empty()) continue; + auto& uvs = has_uv ? uv_per_line[lid] : edges; + auto result = chain_directed_edges({edges.data(), edges.size()}, chain_options); + for (auto& chain : {&result.chains, &result.loops}) { + for (auto& edge_indices : *chain) { + output_stream << "l"; + write_line_vertex(edges[2 * edge_indices[0]], uvs[2 * edge_indices[0]]); + for (Index ei : edge_indices) { + write_line_vertex(edges[2 * ei + 1], uvs[2 * ei + 1]); + } + output_stream << "\n"; + } + } + } + } } template @@ -286,7 +374,7 @@ void write_texture_to_mtl( if (!image.image.data.empty()) { // Image data is available, save it to a file if (image.uri.empty()) { - image_filename = fmt::format("texture_{}.png", texture_info.index); + image_filename = format("texture_{}.png", texture_info.index); } else { image_filename = image.uri; } @@ -300,7 +388,7 @@ void write_texture_to_mtl( static_cast(image.image.num_channels)); // Write the texture map directive - fmt::print(mtl_stream, "{} {}\n", map_directive, image_filename.string()); + print(mtl_stream, "{} {}\n", map_directive, image_filename.string()); } else if (!image.uri.empty()) { // No image data but URI exists, copy the file from URI fs::path source_path = image.uri; @@ -318,7 +406,7 @@ void write_texture_to_mtl( fs::copy_file(source_path, dest_path, fs::copy_options::overwrite_existing); // Write the texture map directive - fmt::print(mtl_stream, "{} {}\n", map_directive, image_filename.string()); + print(mtl_stream, "{} {}\n", map_directive, image_filename.string()); } else if (!quiet) { // Allow saving scenes with invalid texture paths logger().warn( @@ -328,7 +416,7 @@ void write_texture_to_mtl( } else { // Neither image data nor URI exists throw std::runtime_error( - fmt::format("Texture {} has no image data and no URI", texture_info.index)); + format("Texture {} has no image data and no URI", texture_info.index)); } } @@ -341,28 +429,28 @@ void write_mtl_file( fs::ofstream mtl_stream(mtl_filename); if (!mtl_stream) { throw std::runtime_error( - fmt::format("Failed to open MTL file for writing: {}", mtl_filename.string())); + format("Failed to open MTL file for writing: {}", mtl_filename.string())); } const fs::path base_dir = mtl_filename.parent_path(); - fmt::print(mtl_stream, "# MTL File Generated by Lagrange\n"); - fmt::print(mtl_stream, "# Materials: {}\n\n", scene.materials.size()); + print(mtl_stream, "# MTL File Generated by Lagrange\n"); + print(mtl_stream, "# Materials: {}\n\n", scene.materials.size()); for (size_t mat_idx = 0; mat_idx < scene.materials.size(); ++mat_idx) { const auto& material = scene.materials[mat_idx]; // Create a unique material name std::string mat_name = - material.name.empty() ? fmt::format("material_{}", mat_idx) : material.name; + material.name.empty() ? format("material_{}", mat_idx) : material.name; - fmt::print(mtl_stream, "newmtl {}\n", mat_name); + print(mtl_stream, "newmtl {}\n", mat_name); // Note: PBR to Phong material conversion is not fully implemented // The following values provide basic material properties for compatibility // Use base color as diffuse color - fmt::print( + print( mtl_stream, "Kd {} {} {}\n", material.base_color_value[0], @@ -370,7 +458,7 @@ void write_mtl_file( material.base_color_value[2]); // Use base color with reduced intensity for ambient - fmt::print( + print( mtl_stream, "Ka {} {} {}\n", material.base_color_value[0] * 0.1f, @@ -378,16 +466,16 @@ void write_mtl_file( material.base_color_value[2] * 0.1f); // Use low specular for non-metallic appearance - fmt::print(mtl_stream, "Ks 0.04 0.04 0.04\n"); + print(mtl_stream, "Ks 0.04 0.04 0.04\n"); // Set moderate shininess - fmt::print(mtl_stream, "Ns 32\n"); + print(mtl_stream, "Ns 32\n"); // Transparency (alpha) - fmt::print(mtl_stream, "d {}\n", material.base_color_value[3]); + print(mtl_stream, "d {}\n", material.base_color_value[3]); // Standard illumination model - fmt::print(mtl_stream, "illum 2\n"); + print(mtl_stream, "illum 2\n"); // Handle base color texture if (material.base_color_texture.index != scene::invalid_element) { @@ -411,7 +499,7 @@ void write_mtl_file( quiet); } - fmt::print(mtl_stream, "\n"); + print(mtl_stream, "\n"); } } @@ -458,7 +546,7 @@ void save_scene_obj_impl( if (should_export_materials) { fs::path mtl_filename = obj_filename; mtl_filename.replace_extension(".mtl"); - fmt::print(output_stream, "mtllib {}\n\n", mtl_filename.filename().string()); + print(output_stream, "mtllib {}\n\n", mtl_filename.filename().string()); // Write the MTL file write_mtl_file(mtl_filename, scene, options.quiet); @@ -495,10 +583,9 @@ void save_scene_obj_impl( } std::string obj_name = - node.name.empty() - ? fmt::format("node_{}_mesh_{}", node_id, mesh_instance.mesh) - : fmt::format("{}_{}", node.name, mesh_instance.mesh); - fmt::print(output_stream, "o {}\n", obj_name); + node.name.empty() ? format("node_{}_mesh_{}", node_id, mesh_instance.mesh) + : format("{}_{}", node.name, mesh_instance.mesh); + print(output_stream, "o {}\n", obj_name); // Set material if available if (should_export_materials && !mesh_instance.materials.empty()) { @@ -506,9 +593,9 @@ void save_scene_obj_impl( if (mat_idx < scene.materials.size()) { const auto& material = scene.materials[mat_idx]; std::string mat_name = material.name.empty() - ? fmt::format("material_{}", mat_idx) + ? format("material_{}", mat_idx) : material.name; - fmt::print(output_stream, "usemtl {}\n", mat_name); + print(output_stream, "usemtl {}\n", mat_name); } } @@ -573,7 +660,7 @@ void save_mesh_obj( write_obj_header(output_stream, num_vertices, num_facets); // Add object name for the mesh - fmt::print(output_stream, "o mesh\n"); + print(output_stream, "o mesh\n"); // Write positions if (dim == 2) { @@ -581,16 +668,14 @@ void save_mesh_obj( } else if (dim == 3) { write_mesh_vertices(output_stream, mesh); } else { - throw std::runtime_error(fmt::format("Unsupported mesh dimension: {}", dim)); + throw std::runtime_error(format("Unsupported mesh dimension: {}", dim)); } // Write normals and texcoords auto attr_result = write_mesh_attributes(output_stream, mesh, options); - // Write facets + // Write facets (and line elements if line_id attribute is present) write_mesh_facets(output_stream, mesh, attr_result); - - // TODO: Write edges } template @@ -605,7 +690,7 @@ void save_mesh_obj( fs::ofstream output_stream(filename); if (!output_stream) { throw std::runtime_error( - fmt::format("Failed to open OBJ file for writing: {}", filename.string())); + format("Failed to open OBJ file for writing: {}", filename.string())); } save_mesh_obj(output_stream, mesh, options); } @@ -649,7 +734,7 @@ void save_simple_scene_obj( write_obj_header(output_stream, total_vertices, total_facets); // Write comment about the scene structure - fmt::print( + print( output_stream, "# Simple scene with {} meshes and {} total instances\n", lscene.get_num_meshes(), @@ -672,7 +757,7 @@ void save_simple_scene_obj( // Process each instance of this mesh lscene.foreach_instances_for_mesh(mesh_idx, [&](const auto& instance) { - fmt::print(output_stream, "o mesh_{}_instance_{}\n", mesh_idx, instance_idx); + print(output_stream, "o mesh_{}_instance_{}\n", mesh_idx, instance_idx); // Write transformed vertices const Index num_vertices = mesh.get_num_vertices(); @@ -716,7 +801,7 @@ void save_simple_scene_obj( fs::ofstream output_stream(filename); if (!output_stream) { throw std::runtime_error( - fmt::format("Failed to open OBJ file for writing: {}", filename.string())); + format("Failed to open OBJ file for writing: {}", filename.string())); } save_simple_scene_obj(output_stream, lscene, options); } @@ -757,7 +842,7 @@ void save_scene_obj( fs::ofstream output_stream(filename); if (!output_stream) { throw std::runtime_error( - fmt::format("Failed to open OBJ file for writing: {}", filename.string())); + format("Failed to open OBJ file for writing: {}", filename.string())); } save_scene_obj_impl(output_stream, filename, scene, options); } diff --git a/modules/io/src/stitch_mesh.h b/modules/io/src/stitch_mesh.h index 3a6a598b..6c9dc22e 100644 --- a/modules/io/src/stitch_mesh.h +++ b/modules/io/src/stitch_mesh.h @@ -15,6 +15,7 @@ #include #include #include +#include namespace lagrange::io { @@ -30,6 +31,14 @@ void stitch_mesh(SurfaceMesh& mesh) RemoveDuplicateVerticesOptions rm_opts; rm_opts.boundary_only = true; remove_duplicate_vertices(mesh, rm_opts); + + // Weld indexed attributes + WeldOptions weld_opts; + weld_opts.epsilon_abs = 0; + weld_opts.epsilon_rel = 0; + for (auto id : find_matching_attributes(mesh, AttributeElement::Indexed)) { + weld_indexed_attribute(mesh, id, weld_opts); + } } } // namespace lagrange::io diff --git a/modules/io/tests/test_io.cpp b/modules/io/tests/test_io.cpp index 170cffa7..596ed4d7 100644 --- a/modules/io/tests/test_io.cpp +++ b/modules/io/tests/test_io.cpp @@ -21,10 +21,10 @@ // clang-format off #include -#include #include #include #include +#include // clang-format on #include @@ -45,7 +45,7 @@ void test_load_save() std::uniform_real_distribution dist(0, 1); for (int i = 0; i < 8; ++i) { - fs::path filename = fmt::format("semi{}.obj", i + 1); + fs::path filename = lagrange::format("semi{}.obj", i + 1); auto mesh = lagrange::testing::load_surface_mesh("open/core/tilings" / filename); logger().info( "Loaded tiling with {} vertices and {} facets", @@ -62,7 +62,7 @@ void test_load_save() std::array, 3> test_cases = { {{"hexagon", 6}, {"square", 4}, {"triangle", 3}}}; for (auto kv : test_cases) { - fs::path filename = fmt::format("{}.obj", kv.first); + fs::path filename = lagrange::format("{}.obj", kv.first); auto mesh = lagrange::testing::load_surface_mesh("open/core/tilings" / filename); logger().info( "Loaded tiling with {} vertices and {} facets", @@ -90,7 +90,7 @@ void test_benchmark_tiles() int n = 0; for (int i = 0; i < 8; ++i) { - fs::path filename = fmt::format("semi{}.obj", i + 1); + fs::path filename = lagrange::format("semi{}.obj", i + 1); auto mesh = lagrange::testing::load_surface_mesh("open/core/tilings" / filename); n += safe_cast(mesh.get_num_vertices()); REQUIRE(mesh.is_hybrid()); @@ -101,7 +101,7 @@ void test_benchmark_tiles() std::array, 3> test_cases = { {{"hexagon", 6}, {"square", 4}, {"triangle", 3}}}; for (auto kv : test_cases) { - fs::path filename = fmt::format("{}.obj", kv.first); + fs::path filename = lagrange::format("{}.obj", kv.first); auto mesh = lagrange::testing::load_surface_mesh("open/core/tilings" / filename); n += safe_cast(mesh.get_num_vertices()); REQUIRE(mesh.is_regular()); diff --git a/modules/io/tests/test_load_scene.cpp b/modules/io/tests/test_load_scene.cpp index 849af1f0..c24fa297 100644 --- a/modules/io/tests/test_load_scene.cpp +++ b/modules/io/tests/test_load_scene.cpp @@ -20,11 +20,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include @@ -339,7 +341,11 @@ TEST_CASE("scene_extension_user", "[scene]" LA_CORP_FLAG) MyConverter converter; io::LoadOptions load_opt; + // GCC 13.1-13.3 -Warray-bounds false positive with -fsanitize=undefined + // https://gcc.gnu.org/bugzilla/show_bug.cgi?id=109727 + LA_IGNORE_ARRAY_BOUNDS_BEGIN load_opt.extension_converters = {&converter}; + LA_IGNORE_ARRAY_BOUNDS_END auto scene = io::load_scene_gltf( testing::get_data_path("corp/io/neural_assets/High_Heel.gltf"), load_opt); @@ -363,3 +369,31 @@ TEST_CASE("load_gltf_non_triangle", "[io][gltf]" LA_CORP_FLAG) REQUIRE(scene.meshes.at(1).get_num_vertices() == 198); REQUIRE(scene.meshes.at(1).get_num_facets() == 130); } + +TEST_CASE("scene without material", "[io][gltf]") +{ + lagrange::SurfaceMesh32f mesh; + mesh.add_vertices(3); + mesh.add_triangle(0, 1, 2); + + // save_mesh() uses a SimpleScene without material + auto tmp_mesh_path = testing::get_test_output_path("test_load_scene/mesh_no_material.gltf"); + io::save_mesh_gltf(tmp_mesh_path, mesh); + + auto scene_from_mesh = io::load_scene_gltf(tmp_mesh_path); + REQUIRE(scene_from_mesh.nodes.size() == 1); + REQUIRE(scene_from_mesh.meshes.size() == 1); + REQUIRE(scene_from_mesh.nodes[0].meshes.size() == 1); + REQUIRE(scene_from_mesh.nodes[0].meshes[0].materials.empty()); + + // We also need to test saving a full scene without material via save_scene(), and then load it + // again + auto tmp_scene_path = testing::get_test_output_path("test_load_scene/scene_no_material.gltf"); + io::save_scene_gltf(tmp_scene_path, scene_from_mesh); + + auto scene_from_scene = io::load_scene_gltf(tmp_scene_path); + REQUIRE(scene_from_scene.nodes.size() == 1); + REQUIRE(scene_from_scene.meshes.size() == 1); + REQUIRE(scene_from_scene.nodes[0].meshes.size() == 1); + REQUIRE(scene_from_scene.nodes[0].meshes[0].materials.empty()); +} diff --git a/modules/io/tests/test_obj.cpp b/modules/io/tests/test_obj.cpp index 1dabb546..0f778a68 100644 --- a/modules/io/tests/test_obj.cpp +++ b/modules/io/tests/test_obj.cpp @@ -17,9 +17,15 @@ #include #include #include +#include + +#include +#include #include +#include + TEST_CASE("Grenade_H", "[mesh][io]" LA_CORP_FLAG) { using namespace lagrange; @@ -376,10 +382,10 @@ TEST_CASE("io/obj 2d mesh", "[io][obj]") std::string output = data.str(); REQUIRE(!output.empty()); - // Check that we have 2D vertices (only x and y coordinates) - REQUIRE(output.find("v 0 0") != std::string::npos); - REQUIRE(output.find("v 1 0") != std::string::npos); - REQUIRE(output.find("v 0.5 1") != std::string::npos); + // Check that 2D vertices are written with an explicit z=0 coordinate + REQUIRE(output.find("v 0 0 0\n") != std::string::npos); + REQUIRE(output.find("v 1 0 0\n") != std::string::npos); + REQUIRE(output.find("v 0.5 1 0\n") != std::string::npos); // Check that we have UV coordinates REQUIRE(output.find("vt ") != std::string::npos); @@ -408,3 +414,560 @@ TEST_CASE("io/obj 2d mesh", "[io][obj]") REQUIRE(vertices(2, 1) == Catch::Approx(1.0)); REQUIRE(vertices(2, 2) == Catch::Approx(0.0)); } + +TEST_CASE("io/obj stitch_vertices welds indexed attributes", "[io][obj]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + // OBJ with two triangles sharing an edge, but with duplicated UV values along the seam. + // Vertices 2 and 4 are at the same position (1 0 0); vertices 3 and 5 are at the same + // position (0 1 0). + // UV coords 2 and 4 are identical (1 0); UV coords 3 and 5 are identical (0 1). + // After stitch_vertices, vertices should be merged AND the duplicate UV values should be + // welded. + const std::string obj_data = R"(v 0 0 0 +v 1 0 0 +v 0 1 0 +v 1 0 0 +v 0 1 0 +v 1 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +vt 1 0 +vt 0 1 +vt 1 1 +f 1/1 2/2 3/3 +f 4/4 6/6 5/5 +)"; + + io::LoadOptions load_options; + load_options.stitch_vertices = true; + + std::istringstream ss(obj_data); + auto mesh = io::load_mesh_obj>(ss, load_options); + + // After stitching, duplicate vertices should be merged: 6 -> 4 + CHECK(mesh.get_num_vertices() == 4); + CHECK(mesh.get_num_facets() == 2); + + // The UV attribute should have duplicate values welded: 6 -> 4 + REQUIRE(mesh.has_attribute("texcoord")); + auto& uv_attr = mesh.get_indexed_attribute("texcoord"); + CHECK(uv_attr.values().get_num_elements() == 4); +} + +TEST_CASE("io/obj line elements", "[io][obj]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + using MeshType = SurfaceMesh; + + // OBJ with 2 triangles, 1 polyline (3 vertices = 2 segments), and 1 edge (2 vertices) + std::string obj_data = R"( +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 2 0 0 +v 3 0 0 +v 4 0 0 +v 5 0 0 +v 6 0 0 +f 1 2 3 +f 1 3 4 +l 5 6 7 +l 8 9 +)"; + + SECTION("load line elements") + { + std::istringstream input(obj_data); + auto mesh = io::load_mesh_obj(input); + testing::check_mesh(mesh); + + // 2 face facets + 2 line segments (polyline 5-6-7) + 1 line segment (edge 8-9) = 5 + REQUIRE(mesh.get_num_vertices() == 9); + REQUIRE(mesh.get_num_facets() == 5); + + // Check face facets have 3 vertices + REQUIRE(mesh.get_facet_size(0) == 3); + REQUIRE(mesh.get_facet_size(1) == 3); + // Check line segments have 2 vertices + REQUIRE(mesh.get_facet_size(2) == 2); + REQUIRE(mesh.get_facet_size(3) == 2); + REQUIRE(mesh.get_facet_size(4) == 2); + + // Check line_id attribute exists + REQUIRE(mesh.has_attribute(AttributeName::line_id)); + auto lid = mesh.get_attribute_id(AttributeName::line_id); + const auto& line_id_attr = mesh.get_attribute(lid); + auto line_ids = line_id_attr.get_all(); + + // Face facets have line_id == 0 + REQUIRE(line_ids[0] == 0); + REQUIRE(line_ids[1] == 0); + // Polyline "l 5 6 7" produces 2 segments, both with line_id == 1 + REQUIRE(line_ids[2] == 1); + REQUIRE(line_ids[3] == 1); + // Edge "l 8 9" produces 1 segment with line_id == 2 + REQUIRE(line_ids[4] == 2); + + // Verify line segment vertex connectivity + // Segment from polyline: 5-6 (0-indexed: 4-5) + auto seg0 = mesh.get_facet_vertices(2); + REQUIRE(seg0[0] == 4); // vertex 5 (0-indexed) + REQUIRE(seg0[1] == 5); // vertex 6 (0-indexed) + // Segment from polyline: 6-7 (0-indexed: 5-6) + auto seg1 = mesh.get_facet_vertices(3); + REQUIRE(seg1[0] == 5); + REQUIRE(seg1[1] == 6); + // Segment from edge: 8-9 (0-indexed: 7-8) + auto seg2 = mesh.get_facet_vertices(4); + REQUIRE(seg2[0] == 7); + REQUIRE(seg2[1] == 8); + } + + SECTION("roundtrip preserves polylines") + { + std::istringstream input(obj_data); + auto mesh = io::load_mesh_obj(input); + + // Save to OBJ + std::stringstream output; + io::SaveOptions save_options; + io::save_mesh_obj(output, mesh, save_options); + std::string saved = output.str(); + + // Output should contain face lines + REQUIRE(saved.find("f ") != std::string::npos); + // Output should contain line directives + REQUIRE(saved.find("l ") != std::string::npos); + + // The polyline "l 5 6 7" should be saved as a single "l" command with 3 vertices + // (not split into two separate "l" commands) + // Find all "l " lines + std::vector l_lines; + std::istringstream line_reader(saved); + std::string line; + while (std::getline(line_reader, line)) { + if (line.size() >= 2 && line[0] == 'l' && line[1] == ' ') { + l_lines.push_back(line); + } + } + REQUIRE(l_lines.size() == 2); // one polyline + one edge + + // Count tokens in each line directive + auto count_tokens = [](const std::string& s) { + std::istringstream iss(s); + std::string token; + int count = 0; + while (iss >> token) count++; + return count; + }; + // Expect one polyline with 3 vertices and one edge with 2 vertices + std::vector counts; + for (auto& l : l_lines) counts.push_back(count_tokens(l)); + std::sort(counts.begin(), counts.end()); + REQUIRE(counts == std::vector{3, 4}); + + // Reload and verify same topology + std::istringstream reload_input(saved); + auto mesh2 = io::load_mesh_obj(reload_input); + testing::check_mesh(mesh2); + REQUIRE(mesh2.get_num_vertices() == mesh.get_num_vertices()); + REQUIRE(mesh2.get_num_facets() == mesh.get_num_facets()); + REQUIRE(mesh2.has_attribute(AttributeName::line_id)); + } + + SECTION("faces only produces no line_id attribute") + { + std::string faces_only = R"( +v 0 0 0 +v 1 0 0 +v 1 1 0 +f 1 2 3 +)"; + std::istringstream input(faces_only); + auto mesh = io::load_mesh_obj(input); + REQUIRE(mesh.get_num_facets() == 1); + REQUIRE_FALSE(mesh.has_attribute(AttributeName::line_id)); + } + + SECTION("multiple shapes with lines") + { + // Two groups, each with faces and lines. Tests that facet ordering and + // ref_middle offsets are correct when line segments follow all faces. + std::string multi_shape = R"( +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 2 0 0 +v 3 0 0 +v 4 0 0 +v 5 0 0 +g group1 +f 1 2 3 +l 5 6 +g group2 +f 1 3 4 +l 7 8 +)"; + std::istringstream input(multi_shape); + io::LoadOptions load_options; + load_options.load_object_ids = true; + auto mesh = io::load_mesh_obj(input, load_options); + testing::check_mesh(mesh); + + // 2 face facets + 2 line segments = 4 total facets + REQUIRE(mesh.get_num_facets() == 4); + // Faces come first (size 3), then line segments (size 2) + REQUIRE(mesh.get_facet_size(0) == 3); + REQUIRE(mesh.get_facet_size(1) == 3); + REQUIRE(mesh.get_facet_size(2) == 2); + REQUIRE(mesh.get_facet_size(3) == 2); + + // Check line_id attribute + REQUIRE(mesh.has_attribute(AttributeName::line_id)); + auto lid = mesh.get_attribute_id(AttributeName::line_id); + const auto& line_id_attr = mesh.get_attribute(lid); + auto line_ids = line_id_attr.get_all(); + REQUIRE(line_ids[0] == 0); // face from group1 + REQUIRE(line_ids[1] == 0); // face from group2 + REQUIRE(line_ids[2] == 1); // line from group1 + REQUIRE(line_ids[3] == 2); // line from group2 + + // Check object_id attribute: face and line from same group share same id + REQUIRE(mesh.has_attribute(AttributeName::object_id)); + auto oid = mesh.get_attribute_id(AttributeName::object_id); + const auto& object_id_attr = mesh.get_attribute(oid); + auto object_ids = object_id_attr.get_all(); + REQUIRE(object_ids[0] == 0); // face from group1 + REQUIRE(object_ids[1] == 1); // face from group2 + REQUIRE(object_ids[2] == 0); // line from group1 + REQUIRE(object_ids[3] == 1); // line from group2 + + // Verify vertex connectivity of line segments + auto seg0 = mesh.get_facet_vertices(2); + REQUIRE(seg0[0] == 4); // v5 0-indexed + REQUIRE(seg0[1] == 5); // v6 0-indexed + auto seg1 = mesh.get_facet_vertices(3); + REQUIRE(seg1[0] == 6); // v7 0-indexed + REQUIRE(seg1[1] == 7); // v8 0-indexed + + // Roundtrip should preserve topology + std::stringstream output; + io::save_mesh_obj(output, mesh); + std::istringstream reload_input(output.str()); + auto mesh2 = io::load_mesh_obj(reload_input); + testing::check_mesh(mesh2); + REQUIRE(mesh2.get_num_facets() == mesh.get_num_facets()); + REQUIRE(mesh2.get_num_vertices() == mesh.get_num_vertices()); + } + + SECTION("closed loop polyline roundtrip") + { + // A triangle (closed loop) represented as line segments. + // Tests close_loop_with_identical_vertices in the saver. + std::string loop_data = R"( +v 0 0 0 +v 1 0 0 +v 0.5 1 0 +l 1 2 3 1 +)"; + std::istringstream input(loop_data); + auto mesh = io::load_mesh_obj(input); + testing::check_mesh(mesh); + + // 3 line segments from the closed polyline "l 1 2 3 1" + REQUIRE(mesh.get_num_facets() == 3); + for (Index f = 0; f < 3; ++f) { + REQUIRE(mesh.get_facet_size(f) == 2); + } + + // Save and reload + std::stringstream output; + io::save_mesh_obj(output, mesh); + std::string saved = output.str(); + + // Should produce a single "l" directive with 4 tokens (closing vertex repeated) + std::vector l_lines; + std::istringstream line_reader(saved); + std::string line; + while (std::getline(line_reader, line)) { + if (line.size() >= 2 && line[0] == 'l' && line[1] == ' ') { + l_lines.push_back(line); + } + } + REQUIRE(l_lines.size() == 1); + + // Parse the vertex indices from the "l" line + std::istringstream iss(l_lines[0]); + std::string tok; + iss >> tok; // skip "l" + std::vector verts; + while (iss >> tok) verts.push_back(std::stoi(tok)); + // A closed loop should have 4 indices with first == last + REQUIRE(verts.size() == 4); + REQUIRE(verts.front() == verts.back()); + + // Reload and verify same facet count + std::istringstream reload_input(saved); + auto mesh2 = io::load_mesh_obj(reload_input); + testing::check_mesh(mesh2); + REQUIRE(mesh2.get_num_facets() == mesh.get_num_facets()); + } + + SECTION("deterministic output order") + { + // Multiple polylines should always be emitted in ascending line_id order. + std::istringstream input(obj_data); + auto mesh = io::load_mesh_obj(input); + + // Save twice and compare + std::stringstream out1, out2; + io::save_mesh_obj(out1, mesh); + io::save_mesh_obj(out2, mesh); + REQUIRE(out1.str() == out2.str()); + + // Verify "l" directives appear in ascending vertex order + std::vector l_lines; + std::istringstream reader(out1.str()); + std::string line; + while (std::getline(reader, line)) { + if (line.size() >= 2 && line[0] == 'l' && line[1] == ' ') { + l_lines.push_back(line); + } + } + REQUIRE(l_lines.size() == 2); + // line_id 1 (polyline 5-6-7) should come before line_id 2 (edge 8-9) + // Parse first vertex of each line + auto first_vertex = [](const std::string& s) { + std::istringstream iss(s); + std::string tok; + iss >> tok; // skip "l" + iss >> tok; + return std::stoi(tok); + }; + REQUIRE(first_vertex(l_lines[0]) < first_vertex(l_lines[1])); + } + + SECTION("saver ignores wrong line_id attribute type") + { + // Create a mesh with a line_id attribute of the wrong type (Scalar instead of Index). + // The saver should ignore it and emit all facets as faces. + MeshType 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); + + // Create a line_id attribute with float type (wrong) + mesh.template create_attribute( + AttributeName::line_id, + AttributeElement::Facet, + AttributeUsage::Scalar); + + std::stringstream output; + REQUIRE_NOTHROW(io::save_mesh_obj(output, mesh)); + std::string saved = output.str(); + + // Should contain a face but no line directives + REQUIRE(saved.find("f ") != std::string::npos); + REQUIRE(saved.find("l ") == std::string::npos); + } + + SECTION("saver skips non-segment facets with nonzero line_id") + { + // Create a mesh with a triangle that has nonzero line_id. + // The saver should warn and skip it for line output. + MeshType mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({2, 0, 0}); + mesh.add_vertex({3, 0, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 1, 2); // second triangle, will get nonzero line_id + + auto lid = mesh.template create_attribute( + AttributeName::line_id, + AttributeElement::Facet, + AttributeUsage::Scalar); + auto& line_id_attr = mesh.template ref_attribute(lid); + line_id_attr.ref_all()[0] = 0; // regular face + line_id_attr.ref_all()[1] = 1; // erroneously tagged as line + + std::stringstream output; + REQUIRE_NOTHROW(io::save_mesh_obj(output, mesh)); + std::string saved = output.str(); + + // Only the first face should appear as "f", and no "l" directives + // (the triangle with line_id=1 is skipped for face AND line output) + int f_count = 0; + std::istringstream reader(saved); + std::string line; + while (std::getline(reader, line)) { + if (line.size() >= 2 && line[0] == 'f' && line[1] == ' ') ++f_count; + } + REQUIRE(f_count == 1); + REQUIRE(saved.find("l ") == std::string::npos); + } + + SECTION("line elements with texcoords") + { + // OBJ with texcoords on both faces and line elements. + // Tests that UV indices are preserved for line segments during loading + // and that v/vt format is used during saving. + std::string obj_with_uv = R"( +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 2 0 0 +v 3 0 0 +v 4 0 0 +vt 0 0 +vt 1 0 +vt 1 1 +vt 0.5 0 +vt 0.5 1 +vt 0.75 0.5 +f 1/1 2/2 3/3 +l 4/4 5/5 6/6 +)"; + std::istringstream input(obj_with_uv); + auto mesh = io::load_mesh_obj(input); + testing::check_mesh(mesh); + + // 1 face + 2 line segments (polyline 4-5-6) = 3 facets + REQUIRE(mesh.get_num_facets() == 3); + REQUIRE(mesh.get_facet_size(0) == 3); + REQUIRE(mesh.get_facet_size(1) == 2); + REQUIRE(mesh.get_facet_size(2) == 2); + + // Check that UV attribute exists + REQUIRE(mesh.has_attribute(AttributeName::texcoord)); + + // Verify line segment UV indices are valid (0-indexed: 3, 4, 5) + auto uv_id = mesh.get_attribute_id(AttributeName::texcoord); + const auto& uv_attr = mesh.get_indexed_attribute(uv_id); + auto uv_indices = uv_attr.indices().get_all(); + // First line segment: corners of facet 1 + Index c0 = mesh.get_facet_corner_begin(1); + REQUIRE(uv_indices[c0] == 3); + REQUIRE(uv_indices[c0 + 1] == 4); + // Second line segment: corners of facet 2 + Index c1 = mesh.get_facet_corner_begin(2); + REQUIRE(uv_indices[c1] == 4); + REQUIRE(uv_indices[c1 + 1] == 5); + + // Save and verify v/vt syntax is used for line elements + std::stringstream output; + io::SaveOptions save_options; + save_options.output_attributes = io::SaveOptions::OutputAttributes::All; + save_options.attribute_conversion_policy = + io::SaveOptions::AttributeConversionPolicy::ConvertAsNeeded; + io::save_mesh_obj(output, mesh, save_options); + std::string saved = output.str(); + + // Find the "l" line and verify it uses v/vt format + std::vector l_lines; + std::istringstream reader(saved); + std::string line; + while (std::getline(reader, line)) { + if (line.size() >= 2 && line[0] == 'l' && line[1] == ' ') { + l_lines.push_back(line); + } + } + REQUIRE(l_lines.size() == 1); + // Should contain '/' for texcoord indices + REQUIRE(l_lines[0].find('/') != std::string::npos); + + // Roundtrip: reload and verify UVs are preserved + std::istringstream reload_input(saved); + auto mesh2 = io::load_mesh_obj(reload_input); + testing::check_mesh(mesh2); + REQUIRE(mesh2.get_num_facets() == mesh.get_num_facets()); + REQUIRE(mesh2.has_attribute(AttributeName::texcoord)); + REQUIRE(mesh2.has_attribute(AttributeName::line_id)); + + // Verify UV values match on the line segment corners + auto uv_id2 = mesh2.get_attribute_id(AttributeName::texcoord); + const auto& uv_attr2 = mesh2.get_indexed_attribute(uv_id2); + auto uv_values2 = uv_attr2.values().get_all(); + auto uv_indices2 = uv_attr2.indices().get_all(); + // First line segment of reloaded mesh + Index rc0 = mesh2.get_facet_corner_begin(1); + Index rc1 = mesh2.get_facet_corner_begin(2); + // UV values at these indices should match original (0.5,0), (0.5,1), (0.75,0.5) + auto check_uv = [&](Index idx, Scalar u, Scalar v) { + REQUIRE(uv_values2[2 * idx] == Catch::Approx(u)); + REQUIRE(uv_values2[2 * idx + 1] == Catch::Approx(v)); + }; + check_uv(uv_indices2[rc0], 0.5, 0.0); + check_uv(uv_indices2[rc0 + 1], 0.5, 1.0); + check_uv(uv_indices2[rc1], 0.5, 1.0); + check_uv(uv_indices2[rc1 + 1], 0.75, 0.5); + } + + SECTION("faces with UVs but lines without UVs") + { + // Mixed case: faces have texcoords but line elements don't. + // Line elements will still use v/vt format (pointing to infinity UV values + // created by set_invalid_indexed_values). This is acceptable. + std::string mixed_uv = R"( +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 2 0 0 +v 3 0 0 +vt 0 0 +vt 1 0 +vt 1 1 +f 1/1 2/2 3/3 +l 4 5 +)"; + std::istringstream input(mixed_uv); + auto mesh = io::load_mesh_obj(input); + testing::check_mesh(mesh); + + // 1 face + 1 line segment = 2 facets + REQUIRE(mesh.get_num_facets() == 2); + REQUIRE(mesh.has_attribute(AttributeName::line_id)); + + // Save to OBJ + std::stringstream output; + io::SaveOptions save_options; + save_options.output_attributes = io::SaveOptions::OutputAttributes::All; + save_options.attribute_conversion_policy = + io::SaveOptions::AttributeConversionPolicy::ConvertAsNeeded; + io::save_mesh_obj(output, mesh, save_options); + std::string saved = output.str(); + + // Find face and line directives + std::vector f_lines, l_lines; + std::istringstream reader(saved); + std::string line; + while (std::getline(reader, line)) { + if (line.size() >= 2 && line[0] == 'f' && line[1] == ' ') f_lines.push_back(line); + if (line.size() >= 2 && line[0] == 'l' && line[1] == ' ') l_lines.push_back(line); + } + REQUIRE(f_lines.size() == 1); + REQUIRE(l_lines.size() == 1); + + // Both face and line use v/vt format since mesh has UVs globally + REQUIRE(f_lines[0].find('/') != std::string::npos); + REQUIRE(l_lines[0].find('/') != std::string::npos); + + // Roundtrip should preserve topology + std::istringstream reload_input(saved); + auto mesh2 = io::load_mesh_obj(reload_input); + testing::check_mesh(mesh2); + REQUIRE(mesh2.get_num_facets() == mesh.get_num_facets()); + REQUIRE(mesh2.has_attribute(AttributeName::line_id)); + } +} diff --git a/modules/packing/src/repack_uv_charts.cpp b/modules/packing/src/repack_uv_charts.cpp index f7714feb..e188345d 100644 --- a/modules/packing/src/repack_uv_charts.cpp +++ b/modules/packing/src/repack_uv_charts.cpp @@ -14,58 +14,46 @@ #include #include #include +#include #include #include #include #include "pack_boxes.h" +#include #include +#include namespace lagrange::packing { -template -void repack_uv_charts(SurfaceMesh& mesh, const RepackOptions& options) -{ - UVMeshOptions uv_options; - uv_options.uv_attribute_name = options.uv_attribute_name; - auto uv_mesh = uv_mesh_ref(mesh, uv_options); +namespace { - AttributeId chart_attr_id = invalid_attribute_id(); - if (options.chart_attribute_name.empty()) { - // Compute chart id attribute name. - UVChartOptions chart_options; - chart_options.uv_attribute_name = options.uv_attribute_name; - chart_options.output_attribute_name = "@patch_id"; - chart_options.connectivity_type = ConnectivityType::Vertex; - compute_uv_charts(mesh, chart_options); - chart_attr_id = mesh.get_attribute_id(chart_options.output_attribute_name); - } else { - la_runtime_assert( - mesh.has_attribute(options.chart_attribute_name), - "Chart id attribute not found."); - chart_attr_id = mesh.get_attribute_id(options.chart_attribute_name); - } - // Map chart id attribute to vertex element. - auto chart_ids = attribute_vector_view(mesh, chart_attr_id); - Index num_charts = chart_ids.maxCoeff() + 1; +template +void repack_uv_charts_impl( + SurfaceMesh& uv_mesh, + span chart_ids, + const RepackOptions& options) +{ + if (chart_ids.empty()) return; + Index num_charts = *std::max_element(chart_ids.begin(), chart_ids.end()) + 1; uv_mesh.template create_attribute( "chart_id", AttributeElement::Facet, AttributeUsage::Scalar, 1, - {chart_ids.data(), static_cast(chart_ids.size())}); + chart_ids); map_attribute_in_place(uv_mesh, "chart_id", AttributeElement::Vertex); auto vertex_chart_ids = attribute_vector_view(uv_mesh, "chart_id"); auto uv_values = vertex_ref(uv_mesh); la_runtime_assert(uv_values.array().isFinite().all()); - Eigen::Matrix bbox_mins(num_charts, 2); - Eigen::Matrix bbox_maxs(num_charts, 2); - bbox_mins.setConstant(std::numeric_limits::max()); - bbox_maxs.setConstant(std::numeric_limits::lowest()); + Eigen::Matrix bbox_mins(num_charts, 2); + Eigen::Matrix bbox_maxs(num_charts, 2); + bbox_mins.setConstant(std::numeric_limits::max()); + bbox_maxs.setConstant(std::numeric_limits::lowest()); Index num_uvs = uv_mesh.get_num_vertices(); for (Index uv_id = 0; uv_id < num_uvs; uv_id++) { @@ -76,9 +64,9 @@ void repack_uv_charts(SurfaceMesh& mesh, const RepackOptions& opt bbox_maxs(chart_id, 1) = std::max(bbox_maxs(chart_id, 1), uv_values(uv_id, 1)); } - Eigen::Matrix centers(num_charts, 2); + Eigen::Matrix centers(num_charts, 2); std::vector rotated(num_charts); - Scalar canvas_size; + UVScalar canvas_size; #ifdef RECTANGLE_BIN_PACK_OSS bool allow_rotation = true; #else @@ -87,12 +75,12 @@ void repack_uv_charts(SurfaceMesh& mesh, const RepackOptions& opt std::tie(centers, rotated, canvas_size) = pack_boxes(bbox_mins, bbox_maxs, allow_rotation, options.margin); - Eigen::Matrix rot90; + Eigen::Matrix rot90; rot90 << 0, -1, 1, 0; for (Index uv_id = 0; uv_id < num_uvs; uv_id++) { Index chart_id = vertex_chart_ids[uv_id]; - const Eigen::Matrix comp_center = + const Eigen::Matrix comp_center = (bbox_mins.row(chart_id) + bbox_maxs.row(chart_id)) * 0.5; if (!rotated[chart_id]) { uv_values.row(uv_id) = (uv_values.row(uv_id) - comp_center) + centers.row(chart_id); @@ -106,6 +94,51 @@ void repack_uv_charts(SurfaceMesh& mesh, const RepackOptions& opt uv_values = (uv_values.rowwise() - all_bbox_min) / canvas_size; } +} // namespace + +template +void repack_uv_charts(SurfaceMesh& mesh, const RepackOptions& options) +{ + // Compute or retrieve chart ids. + AttributeId chart_attr_id = invalid_attribute_id(); + if (options.chart_attribute_name.empty()) { + UVChartOptions chart_options; + chart_options.uv_attribute_name = options.uv_attribute_name; + chart_options.output_attribute_name = "@patch_id"; + chart_options.connectivity_type = ConnectivityType::Vertex; + compute_uv_charts(mesh, chart_options); + chart_attr_id = mesh.get_attribute_id(chart_options.output_attribute_name); + } else { + la_runtime_assert( + mesh.has_attribute(options.chart_attribute_name), + "Chart id attribute not found."); + chart_attr_id = mesh.get_attribute_id(options.chart_attribute_name); + } + auto chart_ids = attribute_vector_view(mesh, chart_attr_id); + + // Extract UV mesh and dispatch based on UV scalar type. + UVMeshOptions uv_options; + uv_options.uv_attribute_name = options.uv_attribute_name; + uv_options.element_types = UVMeshOptions::ElementTypes::All; + + using OtherScalar = std::conditional_t, double, float>; + if (uv_attribute_id(mesh, uv_options)) { + auto uv_mesh = uv_mesh_ref(mesh, uv_options); + repack_uv_charts_impl( + uv_mesh, + {chart_ids.data(), static_cast(chart_ids.size())}, + options); + } else if (uv_attribute_id(mesh, uv_options)) { + auto uv_mesh = uv_mesh_ref(mesh, uv_options); + repack_uv_charts_impl( + uv_mesh, + {chart_ids.data(), static_cast(chart_ids.size())}, + options); + } else { + throw Error("repack_uv_charts: no suitable UV attribute found."); + } +} + #define LA_X_repack_uv_charts(_, Scalar, Index) \ template LA_PACKING_API void repack_uv_charts( \ SurfaceMesh&, \ diff --git a/modules/packing/tests/test_repack_uv_charts.cpp b/modules/packing/tests/test_repack_uv_charts.cpp new file mode 100644 index 00000000..879625cd --- /dev/null +++ b/modules/packing/tests/test_repack_uv_charts.cpp @@ -0,0 +1,264 @@ +/* + * 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 + +using namespace lagrange; + +namespace { + +using Scalar = double; +using Index = uint32_t; + +template +void add_indexed_uv( + SurfaceMesh& mesh, + std::vector uv_values, + std::vector uv_indices, + std::string_view name = "uv") +{ + mesh.template create_attribute( + name, + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + {uv_values.data(), uv_values.size()}, + {uv_indices.data(), uv_indices.size()}); +} + +// Helper to get UV bounding box as {min_u, min_v, max_u, max_v}. +template +std::array get_uv_bbox(SurfaceMesh& mesh, std::string_view name = "uv") +{ + auto& attr = mesh.template get_indexed_attribute(mesh.get_attribute_id(name)); + auto values = matrix_view(attr.values()); + auto indices = attr.indices().get_all(); + UVScalar min_u = std::numeric_limits::max(); + UVScalar min_v = std::numeric_limits::max(); + UVScalar max_u = std::numeric_limits::lowest(); + UVScalar max_v = std::numeric_limits::lowest(); + for (size_t i = 0; i < indices.size(); ++i) { + auto idx = indices[i]; + min_u = std::min(min_u, values(idx, 0)); + min_v = std::min(min_v, values(idx, 1)); + max_u = std::max(max_u, values(idx, 0)); + max_v = std::max(max_v, values(idx, 1)); + } + return {min_u, min_v, max_u, max_v}; +} + +} // namespace + +TEST_CASE("repack_uv_charts: empty mesh", "[packing][repack]") +{ + SECTION("No vertices, no facets") + { + SurfaceMesh mesh; + add_indexed_uv(mesh, {}, {}); + + packing::repack_uv_charts(mesh); + CHECK(mesh.get_num_vertices() == 0); + CHECK(mesh.get_num_facets() == 0); + } + + SECTION("Vertices but no facets") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + + add_indexed_uv(mesh, {0, 0, 1, 0, 0, 1}, {}); + + packing::repack_uv_charts(mesh); + CHECK(mesh.get_num_vertices() == 3); + CHECK(mesh.get_num_facets() == 0); + } +} + +TEST_CASE("repack_uv_charts: single chart", "[packing][repack]") +{ + SECTION("Single triangle normalized to unit square") + { + 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); + + // UVs span [0,2] x [0,2], should be normalized into ~[0,1] (margin shrinks it slightly) + add_indexed_uv(mesh, {0, 0, 2, 0, 0, 2}, {0, 1, 2}); + + packing::repack_uv_charts(mesh); + + 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)); + // Packing margin (default 1e-3) shrinks the chart slightly + CHECK(bbox[2] <= 1.0 + 1e-6); + CHECK(bbox[3] <= 1.0 + 1e-6); + CHECK(bbox[2] > 0.99); + CHECK(bbox[3] > 0.99); + } + + SECTION("Two triangles sharing an edge, single chart") + { + // 2---3 + // |\ | + // | \| + // 0---1 + 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); + + // UVs offset to [10,12] x [10,12] + add_indexed_uv(mesh, {10, 10, 12, 10, 10, 12, 12, 12}, {0, 1, 2, 1, 3, 2}); + + packing::repack_uv_charts(mesh); + + 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: multiple charts", "[packing][repack]") +{ + SECTION("Two disconnected triangles repacked into unit square") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 1, 0}); + mesh.add_vertex({2, 0, 0}); + mesh.add_vertex({3, 0, 0}); + mesh.add_vertex({2.5, 1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(3, 4, 5); + + // Two separate UV charts (no shared UV vertices or edges) + add_indexed_uv(mesh, {0, 0, 1, 0, 0.5, 1, 2, 0, 3, 0, 2.5, 1}, {0, 1, 2, 3, 4, 5}); + + packing::repack_uv_charts(mesh); + + auto bbox = get_uv_bbox(mesh); + // All UVs should fit within [0,1] x [0,1] + CHECK(bbox[0] >= -1e-6); + CHECK(bbox[1] >= -1e-6); + CHECK(bbox[2] <= 1.0 + 1e-6); + CHECK(bbox[3] <= 1.0 + 1e-6); + } + + SECTION("With pre-computed chart attribute") + { + // Two triangles sharing an edge, but forced into separate charts + 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}); + + // Force different chart ids + mesh.template create_attribute( + "@chart_id", + AttributeElement::Facet, + AttributeUsage::Scalar, + 1, + std::vector{0, 1}); + + packing::RepackOptions opts; + opts.chart_attribute_name = "@chart_id"; + packing::repack_uv_charts(mesh, opts); + + auto bbox = get_uv_bbox(mesh); + CHECK(bbox[0] >= -1e-6); + CHECK(bbox[1] >= -1e-6); + CHECK(bbox[2] <= 1.0 + 1e-6); + CHECK(bbox[3] <= 1.0 + 1e-6); + } +} + +TEST_CASE("repack_uv_charts: different UV scalar type", "[packing][repack]") +{ + using UVScalar = float; + + SECTION("Float UVs on double mesh") + { + 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.f, 0.f, 2.f, 0.f, 0.f, 2.f}, {0, 1, 2}); + + packing::repack_uv_charts(mesh); + + auto bbox = get_uv_bbox(mesh); + CHECK_THAT(static_cast(bbox[0]), Catch::Matchers::WithinAbs(0.0, 1e-5)); + CHECK_THAT(static_cast(bbox[1]), Catch::Matchers::WithinAbs(0.0, 1e-5)); + CHECK(bbox[2] <= 1.0f + 1e-5f); + CHECK(bbox[3] <= 1.0f + 1e-5f); + CHECK(bbox[2] > 0.99f); + CHECK(bbox[3] > 0.99f); + } + + SECTION("Float UVs with two charts") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 1, 0}); + mesh.add_vertex({2, 0, 0}); + mesh.add_vertex({3, 0, 0}); + mesh.add_vertex({2.5, 1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(3, 4, 5); + + add_indexed_uv( + mesh, + {0.f, 0.f, 1.f, 0.f, 0.5f, 1.f, 2.f, 0.f, 3.f, 0.f, 2.5f, 1.f}, + {0, 1, 2, 3, 4, 5}); + + packing::repack_uv_charts(mesh); + + auto bbox = get_uv_bbox(mesh); + CHECK(bbox[0] >= -1e-5f); + CHECK(bbox[1] >= -1e-5f); + CHECK(bbox[2] <= 1.0f + 1e-5f); + CHECK(bbox[3] <= 1.0f + 1e-5f); + } +} diff --git a/modules/partitioning/examples/partition_mesh_vertices.cpp b/modules/partitioning/examples/partition_mesh_vertices.cpp index fad72f43..668ce9d4 100644 --- a/modules/partitioning/examples/partition_mesh_vertices.cpp +++ b/modules/partitioning/examples/partition_mesh_vertices.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -107,7 +108,8 @@ int main(int argc, char const* argv[]) "Writing output to file: {}_*.obj", fs::path(args.output).stem().string()); for (size_t i = 0; i < res.size(); ++i) { - std::string name = fs::path(args.output).stem().string() + fmt::format("_{}.obj", i); + std::string name = + fs::path(args.output).stem().string() + lagrange::format("_{}.obj", i); lagrange::io::save_mesh(name, *res[i]); } } catch (std::exception& e) { diff --git a/modules/poisson/src/octree_depth.h b/modules/poisson/src/octree_depth.h index 1d826b0d..1fb69f22 100644 --- a/modules/poisson/src/octree_depth.h +++ b/modules/poisson/src/octree_depth.h @@ -12,6 +12,9 @@ #include +#include +#include + namespace lagrange::poisson { namespace { diff --git a/modules/polyscope/examples/mesh_viewer.cpp b/modules/polyscope/examples/mesh_viewer.cpp index 44d13ac2..4a5ec09b 100644 --- a/modules/polyscope/examples/mesh_viewer.cpp +++ b/modules/polyscope/examples/mesh_viewer.cpp @@ -19,12 +19,13 @@ #include #include -#include // clang-format off #include #include #include +#include +#include // clang-format on #include @@ -50,7 +51,7 @@ void prepare_mesh(SurfaceMesh& mesh) lagrange::logger().info( "Unifying index buffers for {} non-UV indexed attributes: {}", ids.size(), - fmt::join(attr_names, ", ")); + lagrange::join(attr_names, ", ")); mesh = lagrange::unify_index_buffer(mesh, ids); } diff --git a/modules/primitive/CMakeLists.txt b/modules/primitive/CMakeLists.txt index fdab2f38..9ac05c1e 100644 --- a/modules/primitive/CMakeLists.txt +++ b/modules/primitive/CMakeLists.txt @@ -34,3 +34,7 @@ endif() if(LAGRANGE_MODULE_PYTHON) add_subdirectory(python) endif() + +if(LAGRANGE_BINDINGS_JS) + add_subdirectory(js) +endif() diff --git a/modules/primitive/include/lagrange/primitive/legacy/generate_swept_surface.h b/modules/primitive/include/lagrange/primitive/legacy/generate_swept_surface.h index 2737e58e..da1dd3bc 100644 --- a/modules/primitive/include/lagrange/primitive/legacy/generate_swept_surface.h +++ b/modules/primitive/include/lagrange/primitive/legacy/generate_swept_surface.h @@ -30,6 +30,7 @@ #include #include +#include #include #include @@ -279,7 +280,7 @@ void generate_normal( } else { logger().debug("f0: {} quad0: {} row0: {} col0: {}", f0, quad0, row0, col0); logger().debug("f1: {} quad1: {} row1: {} col1: {}", f1, quad1, row1, col1); - throw Error(fmt::format("Facet {} and {} are not adjacent!", f0, f1)); + throw Error(format("Facet {} and {} are not adjacent!", f0, f1)); } } }); diff --git a/modules/primitive/js/CMakeLists.txt b/modules/primitive/js/CMakeLists.txt new file mode 100644 index 00000000..1891e499 --- /dev/null +++ b/modules/primitive/js/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_js_binding(primitive PrimitiveModule) diff --git a/modules/primitive/js/src/primitive.cpp b/modules/primitive/js/src/primitive.cpp new file mode 100644 index 00000000..cb78f2fc --- /dev/null +++ b/modules/primitive/js/src/primitive.cpp @@ -0,0 +1,299 @@ +/* + * 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 "bind_types.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace { + +using namespace emscripten; +using lagrange::js::bind::apply_opt; + +/// Apply common PrimitiveOptions fields from a JS object. +/// Attribute name fields (normal_attribute_name, uv_attribute_name, semantic_label_attribute_name) +/// are omitted because they are std::string_view and cannot safely bind to transient JS strings. +void apply_primitive_opts(const val& opts, lagrange::primitive::PrimitiveOptions& o) +{ + // JS default: triangulate = true (C++ default is false). + // Set unconditionally, then allow opts to override. + o.triangulate = true; + if (opts.isUndefined()) return; + auto center = opts["center"]; + if (!center.isUndefined()) { + o.center[0] = center[0].as(); + o.center[1] = center[1].as(); + o.center[2] = center[2].as(); + } + apply_opt(opts, "withTopCap", o.with_top_cap); + apply_opt(opts, "withBottomCap", o.with_bottom_cap); + apply_opt(opts, "withCrossSection", o.with_cross_section); + apply_opt(opts, "fixedUv", o.fixed_uv); + apply_opt(opts, "distThreshold", o.dist_threshold); + apply_opt(opts, "angleThreshold", o.angle_threshold); + apply_opt(opts, "epsilon", o.epsilon); + apply_opt(opts, "uvPadding", o.uv_padding); + apply_opt(opts, "triangulate", o.triangulate); +} + +} // namespace + +EMSCRIPTEN_BINDINGS(lagrange_primitive) +{ + using namespace emscripten; + using namespace lagrange; + + function( + "generateSphere", + +[](val opts) -> js::bind::MeshType { + primitive::SphereOptions o; + apply_primitive_opts(opts, o); + apply_opt(opts, "radius", o.radius); + apply_opt(opts, "numLongitudeSections", o.num_longitude_sections); + apply_opt(opts, "numLatitudeSections", o.num_latitude_sections); + apply_opt(opts, "startSweepAngle", o.start_sweep_angle); + apply_opt(opts, "endSweepAngle", o.end_sweep_angle); + return primitive::generate_sphere(std::move(o)); + }); + + function( + "generateTorus", + +[](val opts) -> js::bind::MeshType { + primitive::TorusOptions o; + apply_primitive_opts(opts, o); + apply_opt(opts, "majorRadius", o.major_radius); + apply_opt(opts, "minorRadius", o.minor_radius); + apply_opt(opts, "ringSegments", o.ring_segments); + apply_opt(opts, "pipeSegments", o.pipe_segments); + apply_opt(opts, "startSweepAngle", o.start_sweep_angle); + apply_opt(opts, "endSweepAngle", o.end_sweep_angle); + return primitive::generate_torus(std::move(o)); + }); + + function( + "generateRoundedCube", + +[](val opts) -> js::bind::MeshType { + primitive::RoundedCubeOptions o; + apply_primitive_opts(opts, o); + apply_opt(opts, "width", o.width); + apply_opt(opts, "height", o.height); + apply_opt(opts, "depth", o.depth); + apply_opt(opts, "widthSegments", o.width_segments); + apply_opt(opts, "heightSegments", o.height_segments); + apply_opt(opts, "depthSegments", o.depth_segments); + apply_opt(opts, "bevelRadius", o.bevel_radius); + apply_opt(opts, "bevelSegments", o.bevel_segments); + return primitive::generate_rounded_cube( + std::move(o)); + }); + + function( + "generateRoundedCone", + +[](val opts) -> js::bind::MeshType { + primitive::RoundedConeOptions o; + apply_primitive_opts(opts, o); + apply_opt(opts, "radiusTop", o.radius_top); + apply_opt(opts, "radiusBottom", o.radius_bottom); + apply_opt(opts, "height", o.height); + apply_opt(opts, "bevelRadiusTop", o.bevel_radius_top); + apply_opt(opts, "bevelRadiusBottom", o.bevel_radius_bottom); + apply_opt(opts, "radialSections", o.radial_sections); + apply_opt(opts, "bevelSegmentsTop", o.bevel_segments_top); + apply_opt(opts, "bevelSegmentsBottom", o.bevel_segments_bottom); + apply_opt(opts, "sideSegments", o.side_segments); + apply_opt(opts, "startSweepAngle", o.start_sweep_angle); + apply_opt(opts, "endSweepAngle", o.end_sweep_angle); + return primitive::generate_rounded_cone( + std::move(o)); + }); + + function( + "generateDisc", + +[](val opts) -> js::bind::MeshType { + primitive::DiscOptions o; + apply_primitive_opts(opts, o); + apply_opt(opts, "radius", o.radius); + apply_opt(opts, "startAngle", o.start_angle); + apply_opt(opts, "endAngle", o.end_angle); + apply_opt(opts, "radialSections", o.radial_sections); + apply_opt(opts, "numRings", o.num_rings); + return primitive::generate_disc(std::move(o)); + }); + + function( + "generateOctahedron", + +[](val opts) -> js::bind::MeshType { + primitive::OctahedronOptions o; + apply_primitive_opts(opts, o); + apply_opt(opts, "radius", o.radius); + return primitive::generate_octahedron(std::move(o)); + }); + + function( + "generateIcosahedron", + +[](val opts) -> js::bind::MeshType { + primitive::IcosahedronOptions o; + apply_primitive_opts(opts, o); + apply_opt(opts, "radius", o.radius); + return primitive::generate_icosahedron(std::move(o)); + }); + + function( + "generateRoundedPlane", + +[](val opts) -> js::bind::MeshType { + primitive::RoundedPlaneOptions o; + apply_primitive_opts(opts, o); + apply_opt(opts, "width", o.width); + apply_opt(opts, "height", o.height); + apply_opt(opts, "bevelRadius", o.bevel_radius); + apply_opt(opts, "widthSegments", o.width_segments); + apply_opt(opts, "heightSegments", o.height_segments); + apply_opt(opts, "bevelSegments", o.bevel_segments); + return primitive::generate_rounded_plane( + std::move(o)); + }); + + function( + "generateSubdividedSphere", + +[](val opts) -> js::bind::MeshType { + primitive::SubdividedSphereOptions o; + apply_primitive_opts(opts, o); + apply_opt(opts, "radius", o.radius); + apply_opt(opts, "subdivLevel", o.subdiv_level); + // Generate base icosahedron, then subdivide + auto base = primitive::generate_icosahedron({}); + return primitive::generate_subdivided_sphere( + base, + std::move(o)); + }); + + function( + "generateSweptSurface", + +[](val profile_js, val sweep_js, val opts) -> js::bind::MeshType { + using Scalar = js::bind::Scalar; + using Index = js::bind::Index; + using SweepOpts = primitive::SweepOptions; + using Point = typename SweepOpts::Point; + + // Parse profile: flat array of [x0,y0, x1,y1, ...] 2D coordinates + unsigned profile_len = profile_js["length"].as(); + if (profile_len % 2 != 0) { + throw std::runtime_error( + "generateSweptSurface: profile length must be even (flat [x,y] pairs)"); + } + std::vector profile(profile_len); + for (unsigned i = 0; i < profile_len; ++i) { + profile[i] = profile_js[i].as(); + } + + // Build SweepOptions from sweep_js + bool follow_tangent = true; + apply_opt(sweep_js, "followTangent", follow_tangent); + + SweepOpts sweep; + auto type = sweep_js["type"]; + if (!type.isUndefined() && type.as() == "linear") { + auto from_js = sweep_js["from"]; + auto to_js = sweep_js["to"]; + Point from( + from_js[0].as(), + from_js[1].as(), + from_js[2].as()); + Point to(to_js[0].as(), to_js[1].as(), to_js[2].as()); + sweep = SweepOpts::linear_sweep(from, to, follow_tangent); + } else { + // Default: circular sweep + auto point_js = sweep_js["point"]; + auto axis_js = sweep_js["axis"]; + Point point( + point_js[0].as(), + point_js[1].as(), + point_js[2].as()); + Point axis( + axis_js[0].as(), + axis_js[1].as(), + axis_js[2].as()); + Scalar angle = static_cast(2 * lagrange::internal::pi); + apply_opt(sweep_js, "angle", angle); + sweep = SweepOpts::circular_sweep(point, axis, angle, follow_tangent); + } + + // Common sweep config + size_t num_samples = 16; + apply_opt(sweep_js, "numSamples", num_samples); + sweep.set_num_samples(num_samples); + + bool periodic = sweep.is_periodic(); + apply_opt(sweep_js, "periodic", periodic); + sweep.set_periodic(periodic); + + auto domain_js = sweep_js["domain"]; + if (!domain_js.isUndefined()) { + sweep.set_domain({ + domain_js[0].as(), + domain_js[1].as(), + }); + } + + auto pivot_js = sweep_js["pivot"]; + if (!pivot_js.isUndefined()) { + sweep.set_pivot(Point( + pivot_js[0].as(), + pivot_js[1].as(), + pivot_js[2].as())); + } + + // Optional JS callback functions + auto twist_fn = sweep_js["twistFunction"]; + if (!twist_fn.isUndefined()) { + sweep.set_twist_function( + [twist_fn](Scalar t) -> Scalar { return twist_fn(t).as(); }); + } + + auto taper_fn = sweep_js["taperFunction"]; + if (!taper_fn.isUndefined()) { + sweep.set_taper_function( + [taper_fn](Scalar t) -> Scalar { return taper_fn(t).as(); }); + } + + auto offset_fn = sweep_js["offsetFunction"]; + if (!offset_fn.isUndefined()) { + sweep.set_offset_function( + [offset_fn](Scalar t) -> Scalar { return offset_fn(t).as(); }); + } + + // SweptSurfaceOptions + primitive::SweptSurfaceOptions surface_opts; + apply_primitive_opts(opts, surface_opts); + apply_opt(opts, "useUAsProfileLength", surface_opts.use_u_as_profile_length); + apply_opt(opts, "profileAngleThreshold", surface_opts.profile_angle_threshold); + apply_opt(opts, "maxProfileLength", surface_opts.max_profile_length); + + return primitive::generate_swept_surface( + lagrange::span(profile.data(), profile.size()), + sweep, + surface_opts); + }); +} diff --git a/modules/primitive/js/test/primitive.test.ts b/modules/primitive/js/test/primitive.test.ts new file mode 100644 index 00000000..f633212a --- /dev/null +++ b/modules/primitive/js/test/primitive.test.ts @@ -0,0 +1,197 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +describe("primitive", () => { + test("generateSphere with all options", () => { + const mesh = lagrange.primitive.generateSphere({ + radius: 1, + numLongitudeSections: 8, + numLatitudeSections: 8, + startSweepAngle: 0, + endSweepAngle: Math.PI * 2, + }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.getNumFacets()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateSphere with partial options", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 2 }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateSphere with empty options uses C++ defaults", () => { + const mesh = lagrange.primitive.generateSphere({}); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateSphere with no arguments uses C++ defaults", () => { + const mesh = lagrange.primitive.generateSphere(); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateTorus with partial options", () => { + const mesh = lagrange.primitive.generateTorus({ majorRadius: 3 }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.getNumFacets()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateTorus with empty options uses C++ defaults", () => { + const mesh = lagrange.primitive.generateTorus({}); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateRoundedCube with partial options", () => { + const mesh = lagrange.primitive.generateRoundedCube({ + width: 2, + height: 2, + depth: 2, + }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateRoundedCube with empty options uses C++ defaults", () => { + const mesh = lagrange.primitive.generateRoundedCube({}); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateRoundedCone with partial options", () => { + const mesh = lagrange.primitive.generateRoundedCone({ + radiusBottom: 2, + height: 3, + }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateDisc with partial options", () => { + const mesh = lagrange.primitive.generateDisc({ radius: 5 }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateRoundedPlane with partial options", () => { + const mesh = lagrange.primitive.generateRoundedPlane({ + width: 4, + height: 3, + }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateIcosahedron produces 12 vertices and 20 faces", () => { + const mesh = lagrange.primitive.generateIcosahedron({ radius: 1 }); + expect(mesh.getNumVertices()).toBe(12); + expect(mesh.getNumFacets()).toBe(20); + mesh.delete(); + }); + + test("generateIcosahedron with empty options uses C++ defaults", () => { + const mesh = lagrange.primitive.generateIcosahedron({}); + expect(mesh.getNumVertices()).toBe(12); + expect(mesh.getNumFacets()).toBe(20); + mesh.delete(); + }); + + test("generateOctahedron with empty options uses C++ defaults", () => { + const mesh = lagrange.primitive.generateOctahedron({}); + expect(mesh.getNumVertices()).toBe(6); + expect(mesh.getNumFacets()).toBe(8); + mesh.delete(); + }); + + test("center option offsets mesh position", () => { + const mesh = lagrange.primitive.generateSphere({ + radius: 1, + center: [10, 20, 30], + }); + // Check first vertex is near center offset + const pos = mesh.getPosition(0); + expect(pos[0]).toBeGreaterThan(8); + expect(pos[1]).toBeGreaterThan(18); + expect(pos[2]).toBeGreaterThan(28); + mesh.delete(); + }); + + test("generateSubdividedSphere with default options", () => { + const mesh = lagrange.primitive.generateSubdividedSphere({}); + expect(mesh.getNumVertices()).toBe(12); // base icosahedron, subdiv 0 + expect(mesh.getNumFacets()).toBe(20); + mesh.delete(); + }); + + test("generateSubdividedSphere with subdivision", () => { + const mesh = lagrange.primitive.generateSubdividedSphere({ + subdivLevel: 2, + }); + expect(mesh.getNumVertices()).toBeGreaterThan(12); + expect(mesh.isTriangleMesh()).toBe(true); + mesh.delete(); + }); + + test("generateSweptSurface circular sweep", () => { + // Simple square profile swept around Y axis + const profile = [0, 0, 1, 0, 1, 1, 0, 1]; + const mesh = lagrange.primitive.generateSweptSurface(profile, { + type: "circular", + point: [1, 0, 0], + axis: [0, 1, 0], + numSamples: 16, + }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.getNumFacets()).toBeGreaterThan(0); + mesh.delete(); + }); + + test("generateSweptSurface linear sweep", () => { + const profile = [0, 0, 1, 0, 1, 1, 0, 1]; + const mesh = lagrange.primitive.generateSweptSurface(profile, { + type: "linear", + from: [0, 0, 0], + to: [0, 2, 0], + numSamples: 4, + }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.getNumFacets()).toBeGreaterThan(0); + mesh.delete(); + }); + + test("generateSweptSurface with taper function", () => { + const profile = [0, 0, 1, 0, 1, 1, 0, 1]; + const mesh = lagrange.primitive.generateSweptSurface(profile, { + type: "linear", + from: [0, 0, 0], + to: [0, 3, 0], + numSamples: 8, + taperFunction: (t: number) => 1 - t * 0.5, + }); + expect(mesh.getNumVertices()).toBeGreaterThan(0); + expect(mesh.getNumFacets()).toBeGreaterThan(0); + mesh.delete(); + }); +}); diff --git a/modules/primitive/js/ts/primitive.ts b/modules/primitive/js/ts/primitive.ts new file mode 100644 index 00000000..edbdcd1c --- /dev/null +++ b/modules/primitive/js/ts/primitive.ts @@ -0,0 +1,266 @@ +/** + * Procedural mesh generators: spheres, cubes, cones, tori, swept surfaces. + * Each comes with UVs and normals ready for rendering. + * + * Omitted option fields fall back to sensible defaults. + */ + +import type { SurfaceMesh } from "./core.js"; + +/** + * Options shared by every primitive generator. Pass only the fields you want + * to change from the defaults. + */ +export interface PrimitiveOptions { + /** World-space center of the primitive. Default: `[0, 0, 0]`. */ + center?: [number, number, number]; + /** Include a top cap, for primitives that have one. Default: `true`. */ + withTopCap?: boolean; + /** Include a bottom cap, for primitives that have one. Default: `true`. */ + withBottomCap?: boolean; + /** Include cross-section geometry when the shape is swept less than a full turn. Default: `true`. */ + withCrossSection?: boolean; + /** Triangulate polygonal output. Default: `true`. */ + triangulate?: boolean; + /** Use a canonical UV layout instead of one parameterized by shape settings. Default: `false`. */ + fixedUv?: boolean; + /** Distance below which two vertices are considered coincident (weld tolerance). Default: `1e-6`. */ + distThreshold?: number; + /** Dihedral angle above which an edge is marked as sharp (radians). Default: `30° in radians`. */ + angleThreshold?: number; + /** Numerical tolerance for scalar comparisons during generation. Default: `1e-6`. */ + epsilon?: number; + /** Padding between UV charts to avoid texture bleeding. Default: `0.005`. */ + uvPadding?: number; +} + +/** UV-sphere: longitude/latitude grid. Set sweep angles to build a wedge. */ +export interface SphereOptions extends PrimitiveOptions { + /** Sphere radius. */ + radius?: number; + /** Divisions around the vertical axis. */ + numLongitudeSections?: number; + /** Divisions between the two poles. */ + numLatitudeSections?: number; + /** Start of the longitudinal sweep, in radians. */ + startSweepAngle?: number; + /** End of the longitudinal sweep, in radians. Full sphere: `2π`. */ + endSweepAngle?: number; +} + +/** Donut / ring shape. Set sweep angles to build a partial torus. */ +export interface TorusOptions extends PrimitiveOptions { + /** Distance from the torus center to the center of the tube. */ + majorRadius?: number; + /** Tube thickness (radius of the circular cross-section). */ + minorRadius?: number; + /** Divisions around the main ring. */ + ringSegments?: number; + /** Divisions around the tube cross-section. */ + pipeSegments?: number; + /** Start of the main-ring sweep, in radians. */ + startSweepAngle?: number; + /** End of the main-ring sweep, in radians. Full torus: `2π`. */ + endSweepAngle?: number; +} + +/** Rounded box. Set `bevelRadius = 0` for a sharp cube. */ +export interface RoundedCubeOptions extends PrimitiveOptions { + /** Extent along X. */ + width?: number; + /** Extent along Y. */ + height?: number; + /** Extent along Z. */ + depth?: number; + /** Subdivisions along X. */ + widthSegments?: number; + /** Subdivisions along Y. */ + heightSegments?: number; + /** Subdivisions along Z. */ + depthSegments?: number; + /** Corner/edge bevel radius. `0` gives a sharp cube. */ + bevelRadius?: number; + /** Bevel-arc subdivisions. */ + bevelSegments?: number; +} + +/** Cone/cylinder/truncated cone with optional rounded rims. */ +export interface RoundedConeOptions extends PrimitiveOptions { + /** Radius of the top disk. Equal to `radiusBottom` for a cylinder; `0` for a pointed cone. */ + radiusTop?: number; + /** Radius of the bottom disk. */ + radiusBottom?: number; + /** Distance between top and bottom caps. */ + height?: number; + /** Bevel radius where the top cap meets the side. */ + bevelRadiusTop?: number; + /** Bevel radius where the bottom cap meets the side. */ + bevelRadiusBottom?: number; + /** Divisions around the vertical axis. */ + radialSections?: number; + /** Bevel-arc subdivisions at the top. */ + bevelSegmentsTop?: number; + /** Bevel-arc subdivisions at the bottom. */ + bevelSegmentsBottom?: number; + /** Subdivisions along the side between top and bottom. */ + sideSegments?: number; + /** Start of the sweep around the axis, in radians. */ + startSweepAngle?: number; + /** End of the sweep around the axis, in radians. Full cone: `2π`. */ + endSweepAngle?: number; +} + +/** Flat 2D disc with optional angular wedge. */ +export interface DiscOptions extends PrimitiveOptions { + /** Outer radius. */ + radius?: number; + /** Start angle of the wedge, in radians. */ + startAngle?: number; + /** End angle of the wedge, in radians. Full disc: `2π`. */ + endAngle?: number; + /** Radial wedge subdivisions. */ + radialSections?: number; + /** Concentric rings. */ + numRings?: number; +} + +/** Regular octahedron. */ +export interface OctahedronOptions extends PrimitiveOptions { + /** Circumscribing sphere radius. */ + radius?: number; +} + +/** Regular icosahedron. */ +export interface IcosahedronOptions extends PrimitiveOptions { + /** Circumscribing sphere radius. */ + radius?: number; +} + +/** Rectangular plane with rounded corners. */ +export interface RoundedPlaneOptions extends PrimitiveOptions { + /** Extent along X. */ + width?: number; + /** Extent along Y. */ + height?: number; + /** Corner bevel radius. `0` gives a sharp rectangle. */ + bevelRadius?: number; + /** Subdivisions along X. */ + widthSegments?: number; + /** Subdivisions along Y. */ + heightSegments?: number; + /** Bevel-arc subdivisions. */ + bevelSegments?: number; +} + +/** + * The path along which {@link PrimitiveModule.generateSweptSurface} drags the + * 2D profile. Discriminated via `type`: + * - `"linear"`: straight segment from {@link LinearSweepOptions.from} to `to`. + * - `"circular"`: arc around {@link CircularSweepOptions.axis}. + */ +export type SweepOptions = LinearSweepOptions | CircularSweepOptions; + +interface SweepOptionsBase { + /** Number of evaluation samples along the sweep. Default: `16`. */ + numSamples?: number; + /** Close the sweep into a loop (wraps back on itself). */ + periodic?: boolean; + /** Parameter range used by `twistFunction`/`taperFunction`/`offsetFunction`. Default: `[0, 1]`. */ + domain?: [number, number]; + /** Pivot point applied to the profile before sweeping. Default: `[0, 0, 0]`. */ + pivot?: [number, number, number]; + /** If `true`, the profile rotates to stay perpendicular to the path tangent. Default: `true`. */ + followTangent?: boolean; + /** Rotation around the tangent as a function of `t`. Return radians. */ + twistFunction?: (t: number) => number; + /** Uniform profile scale as a function of `t`. Return `1` to keep original size. */ + taperFunction?: (t: number) => number; + /** Lateral offset of the profile as a function of `t`, in world units. */ + offsetFunction?: (t: number) => number; +} + +export interface LinearSweepOptions extends SweepOptionsBase { + type: "linear"; + /** Start point of the sweep `[x, y, z]`. */ + from: [number, number, number]; + /** End point of the sweep `[x, y, z]`. */ + to: [number, number, number]; +} + +export interface CircularSweepOptions extends SweepOptionsBase { + type: "circular"; + /** A point on the circular path `[x, y, z]` (defines the radius). */ + point: [number, number, number]; + /** Rotation axis `[x, y, z]`. Should be a unit vector. */ + axis: [number, number, number]; + /** Total sweep angle, in radians. Default: `2π` (full loop). */ + angle?: number; +} + +export interface SweptSurfaceOptions extends PrimitiveOptions { + /** Map the profile's arc length to the U axis of the generated UVs. Default: `true`. */ + useUAsProfileLength?: boolean; + /** Angle (radians) at which to split UVs across profile corners. Default: `π/4`. */ + profileAngleThreshold?: number; + /** Max profile segment length before the UV is split. `≤ 0` disables splitting. Default: `0`. */ + maxProfileLength?: number; +} + +export interface SubdividedSphereOptions extends PrimitiveOptions { + /** Sphere radius. */ + radius?: number; + /** + * Number of subdivisions on top of the base icosahedron. `0` = the bare + * icosahedron; each increment roughly quadruples the face count and + * makes the surface rounder. + */ + subdivLevel?: number; +} + +/** + * Procedural mesh generators. Accessible as `lagrange.primitive`. + */ +export interface PrimitiveModule { + /** UV-sphere built from latitude/longitude grid. */ + generateSphere(opts?: SphereOptions): SurfaceMesh; + /** Donut. */ + generateTorus(opts?: TorusOptions): SurfaceMesh; + /** Box with optional bevelled edges. */ + generateRoundedCube(opts?: RoundedCubeOptions): SurfaceMesh; + /** Cone, truncated cone, or cylinder (depending on top/bottom radii) with optional rim bevels. */ + generateRoundedCone(opts?: RoundedConeOptions): SurfaceMesh; + /** Flat disc / wedge. */ + generateDisc(opts?: DiscOptions): SurfaceMesh; + /** 8-face regular solid. */ + generateOctahedron(opts?: OctahedronOptions): SurfaceMesh; + /** 20-face regular solid. */ + generateIcosahedron(opts?: IcosahedronOptions): SurfaceMesh; + /** Rectangle with rounded corners. */ + generateRoundedPlane(opts?: RoundedPlaneOptions): SurfaceMesh; + /** + * Geodesic-style sphere: icosahedron repeatedly subdivided and projected + * to the sphere. More uniform triangle sizes than {@link generateSphere}. + */ + generateSubdividedSphere(opts?: SubdividedSphereOptions): SurfaceMesh; + /** + * Extrude/revolve a 2D profile curve along a sweep path to build a surface. + * + * @param profile Flat 2D polyline `[x0, y0, x1, y1, ...]`. + * @param sweep Either a linear or circular path (discriminated by `type`). + * @param opts Smoothing, UV, and general primitive options. + */ + generateSweptSurface(profile: number[] | Float64Array, sweep: SweepOptions, opts?: SweptSurfaceOptions): SurfaceMesh; +} + +export const primitiveModuleKeys = [ + "generateSphere", + "generateTorus", + "generateRoundedCube", + "generateRoundedCone", + "generateDisc", + "generateOctahedron", + "generateIcosahedron", + "generateRoundedPlane", + "generateSubdividedSphere", + "generateSweptSurface", +] as const satisfies readonly (keyof PrimitiveModule)[]; diff --git a/modules/primitive/src/generate_subdivided_sphere.cpp b/modules/primitive/src/generate_subdivided_sphere.cpp index 0cc6a29c..5372fe56 100644 --- a/modules/primitive/src/generate_subdivided_sphere.cpp +++ b/modules/primitive/src/generate_subdivided_sphere.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include "primitive_utils.h" @@ -40,12 +41,10 @@ SurfaceMesh generate_subdivided_sphere( if (setting.uv_attribute_name != "") { la_runtime_assert( base_shape.has_attribute(setting.uv_attribute_name), - fmt::format( - "UV attribute '{}' not found in the base shape.", - setting.uv_attribute_name)); + format("UV attribute '{}' not found in the base shape.", setting.uv_attribute_name)); la_runtime_assert( base_shape.is_attribute_indexed(setting.uv_attribute_name), - fmt::format("UV attribute '{}' must be indexed.", setting.uv_attribute_name)); + format("UV attribute '{}' must be indexed.", setting.uv_attribute_name)); subdiv_options.face_varying_interpolation = subdivision::FaceVaryingInterpolation::All; } diff --git a/modules/primitive/src/generate_swept_surface.cpp b/modules/primitive/src/generate_swept_surface.cpp index cce20863..563759c3 100644 --- a/modules/primitive/src/generate_swept_surface.cpp +++ b/modules/primitive/src/generate_swept_surface.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -433,7 +434,7 @@ AttributeId generate_normal( } else { logger().debug("f0: {} row0: {} col0: {}", f0, row0, col0); logger().debug("f1: {} row1: {} col1: {}", f1, row1, col1); - throw Error(fmt::format("Facet {} and {} are not adjacent!", f0, f1)); + throw Error(format("Facet {} and {} are not adjacent!", f0, f1)); } } }, diff --git a/modules/python/CMakeLists.txt b/modules/python/CMakeLists.txt index f4a66423..67b10dcf 100644 --- a/modules/python/CMakeLists.txt +++ b/modules/python/CMakeLists.txt @@ -224,13 +224,17 @@ function(lagrange_generate_python_binding_module) # Generate stubs for python binding within the install location. get_target_property(active_modules lagrange_python LAGRANGE_ACTIVE_MODULES) foreach(module_name IN ITEMS ${active_modules}) - message(STATUS "Generating stub for lagrange.${module_name}") - file(APPEND ${init_pyi_file} "from . import ${module_name}\n") + get_target_property(python_name lagrange_python LAGRANGE_PYTHON_NAME_${module_name}) + if(NOT python_name) + set(python_name ${module_name}) + endif() + message(STATUS "Generating stub for lagrange.${python_name}") + file(APPEND ${init_pyi_file} "from . import ${python_name}\n") nanobind_add_stub( lagrange_python_stubgen_${module_name} INSTALL_TIME - MODULE lagrange.${module_name} - OUTPUT ${SKBUILD_PLATLIB_DIR}/lagrange/${module_name}.pyi + MODULE lagrange.${python_name} + OUTPUT ${SKBUILD_PLATLIB_DIR}/lagrange/${python_name}.pyi DEPENDS lagrange_python COMPONENT Lagrange_Python_Runtime PYTHON_PATH ${SKBUILD_PLATLIB_DIR}/lagrange/ diff --git a/modules/raycasting/examples/picking_demo.cpp b/modules/raycasting/examples/picking_demo.cpp index 58420e03..47b161e4 100644 --- a/modules/raycasting/examples/picking_demo.cpp +++ b/modules/raycasting/examples/picking_demo.cpp @@ -259,8 +259,11 @@ int main(int argc, char** argv) spdlog::set_level(static_cast(args.log_level)); // Initialize Polyscope + polyscope::options::configureImGuiStyleCallback = []() { + ImGui::Spectrum::StyleColorsSpectrum(); + ImGui::Spectrum::LoadFont(); + }; polyscope::init(); - polyscope::options::configureImGuiStyleCallback = []() { ImGui::StyleColorsLight(); }; // Load mesh lagrange::logger().info("Loading mesh: {}", args.input.string()); diff --git a/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_closest_point.h b/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_closest_point.h index ccb8ce54..8d5b143c 100644 --- a/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_closest_point.h +++ b/modules/raycasting/include/lagrange/raycasting/legacy/project_attributes_closest_point.h @@ -12,7 +12,6 @@ #pragma once #include -#include #include #include #include diff --git a/modules/raycasting/src/RayCaster.cpp b/modules/raycasting/src/RayCaster.cpp index 1ef0f88f..fe46b49c 100644 --- a/modules/raycasting/src/RayCaster.cpp +++ b/modules/raycasting/src/RayCaster.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -92,10 +93,9 @@ void check_errors_runtime(const RTCDevice& device) case RTC_ERROR_UNSUPPORTED_CPU: throw Error("Embree: your CPU does not support SSE2"); case RTC_ERROR_CANCELLED: throw Error("Embree: cancelled"); default: - throw Error( - fmt::format( - "Embree: unknown error code: {}", - static_cast>(err))); + throw Error(format( + "Embree: unknown error code: {}", + static_cast>(err))); } } diff --git a/modules/raycasting/src/project_directional.cpp b/modules/raycasting/src/project_directional.cpp index 8e36da40..17395e56 100644 --- a/modules/raycasting/src/project_directional.cpp +++ b/modules/raycasting/src/project_directional.cpp @@ -30,8 +30,8 @@ // clang-format off #include #include -#include #include +#include // clang-format on #include @@ -93,7 +93,7 @@ void project_directional( 3, internal::ShouldBeWritable::No); if (!res.success) { - throw Error(fmt::format("Invalid direction attribute: {}", res.msg)); + throw Error(format("Invalid direction attribute: {}", res.msg)); } } else { // std::monostate: find existing vertex normal or compute one. @@ -134,11 +134,11 @@ void project_directional( auto name = source.get_attribute_name(src_id); la_runtime_assert( source.has_attribute(name), - fmt::format("Source mesh missing attribute: {}", name)); + format("Source mesh missing attribute: {}", name)); const auto& src_base = source.get_attribute_base(src_id); la_runtime_assert( src_base.get_element_type() == AttributeElement::Vertex, - fmt::format("Only vertex attributes are supported: {}", name)); + format("Only vertex attributes are supported: {}", name)); size_t num_channels = src_base.get_num_channels(); diff --git a/modules/raycasting/tests/test_project_attributes.cpp b/modules/raycasting/tests/test_project_attributes.cpp index e99c2df8..cf29c93f 100644 --- a/modules/raycasting/tests/test_project_attributes.cpp +++ b/modules/raycasting/tests/test_project_attributes.cpp @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +#include #include #include #include diff --git a/modules/raycasting/tests/test_raycasting_speed.cpp b/modules/raycasting/tests/test_raycasting_speed.cpp index 203b0069..844bd61d 100644 --- a/modules/raycasting/tests/test_raycasting_speed.cpp +++ b/modules/raycasting/tests/test_raycasting_speed.cpp @@ -22,6 +22,7 @@ // clang-format on #include +#include #include #include @@ -196,7 +197,7 @@ TEST_CASE("Raycasting Speed", "[raycasting][!benchmark]") // Lagrange 4-packed raycaster for (auto type : all_types) { for (auto quality : all_qualities) { - std::string name = fmt::format("{} + {}", type.first, quality.first); + std::string name = lagrange::format("{} + {}", type.first, quality.first); auto engine = lagrange::raycasting::create_ray_caster(type.second, quality.second); engine->add_mesh(mesh, Eigen::Matrix4f::Identity()); @@ -211,7 +212,7 @@ TEST_CASE("Raycasting Speed", "[raycasting][!benchmark]") // Lagrange single-ray raycaster for (auto type : all_types) { for (auto quality : all_qualities) { - std::string name = fmt::format("{} + {}", type.first, quality.first); + std::string name = lagrange::format("{} + {}", type.first, quality.first); auto engine = lagrange::raycasting::create_ray_caster(type.second, quality.second); engine->add_mesh(mesh, Eigen::Matrix4f::Identity()); diff --git a/modules/scene/CMakeLists.txt b/modules/scene/CMakeLists.txt index 72a12e9b..5b3a1b92 100644 --- a/modules/scene/CMakeLists.txt +++ b/modules/scene/CMakeLists.txt @@ -35,3 +35,7 @@ endif() if(LAGRANGE_MODULE_PYTHON) add_subdirectory(python) endif() + +if(LAGRANGE_BINDINGS_JS) + add_subdirectory(js) +endif() diff --git a/modules/scene/include/lagrange/scene/internal/shared_utils.h b/modules/scene/include/lagrange/scene/internal/shared_utils.h index d00edc61..34b2b476 100644 --- a/modules/scene/include/lagrange/scene/internal/shared_utils.h +++ b/modules/scene/include/lagrange/scene/internal/shared_utils.h @@ -18,6 +18,7 @@ #include #include #include +#include #include @@ -159,19 +160,17 @@ std::tuple, std::optional> single_mesh_from } if (mesh_node_ids.size() != 1) { - throw std::runtime_error( - fmt::format( - "Input scene contains {} mesh nodes. Expected exactly 1 mesh node.", - mesh_node_ids.size())); + throw std::runtime_error(format( + "Input scene contains {} mesh nodes. Expected exactly 1 mesh node.", + mesh_node_ids.size())); } const auto& mesh_node = scene.nodes[mesh_node_ids.front()]; if (mesh_node.meshes.size() != 1) { - throw std::runtime_error( - fmt::format( - "Input scene has a mesh node with {} instance per node. Expected " - "exactly 1 instance per node", - mesh_node.meshes.size())); + throw std::runtime_error(format( + "Input scene has a mesh node with {} instance per node. Expected " + "exactly 1 instance per node", + mesh_node.meshes.size())); } const auto& mesh_instance = mesh_node.meshes.front(); diff --git a/modules/scene/include/lagrange/scene/scene_utils.h b/modules/scene/include/lagrange/scene/scene_utils.h index 9f64ce1c..ea2acd37 100644 --- a/modules/scene/include/lagrange/scene/scene_utils.h +++ b/modules/scene/include/lagrange/scene/scene_utils.h @@ -58,11 +58,16 @@ Eigen::Affine3f compute_global_node_transform(const Scene& scene, } /// -/// Computes the (extrinsic) view matrix transform (world space -> camera space) +/// Computes the (extrinsic) view matrix transform (world space -> camera space). +/// +/// Following glTF 2.0 §3.10.2, the view matrix is derived from the node's global transform with +/// scaling ignored. This overload extracts the closest proper rotation from the linear part of +/// @p world_from_local via polar decomposition (SVD), correctly handling scale and shear. For +/// scale-free transforms, prefer the Isometry3f overload to avoid the SVD cost. /// /// @param[in] camera Camera object. /// @param[in] world_from_local Local -> world transform of the node containing the camera -/// instance. +/// instance. Any scale/shear component is stripped before use. /// /// @return The view transform mapping world space -> camera space. /// @@ -70,6 +75,22 @@ LA_SCENE_API Eigen::Affine3f camera_view_transform( const Camera& camera, const Eigen::Affine3f& world_from_local = Eigen::Affine3f::Identity()); +/// +/// @overload +/// +/// Overload accepting an isometry (rotation + translation, no scale). Use this when the transform +/// is known to have no scale component, to skip the SVD decomposition. +/// +/// @param[in] camera Camera object. +/// @param[in] world_from_local Local -> world transform of the node containing the camera +/// instance. +/// +/// @return The view transform mapping world space -> camera space. +/// +LA_SCENE_API Eigen::Affine3f camera_view_transform( + const Camera& camera, + const Eigen::Isometry3f& world_from_local); + /// /// Computes the (intrinsic) projection matrix projection (camera space -> clip space) /// diff --git a/modules/scene/js/CMakeLists.txt b/modules/scene/js/CMakeLists.txt new file mode 100644 index 00000000..0048888d --- /dev/null +++ b/modules/scene/js/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_js_binding(scene SceneModule) diff --git a/modules/scene/js/src/scene.cpp b/modules/scene/js/src/scene.cpp new file mode 100644 index 00000000..d9f3b64c --- /dev/null +++ b/modules/scene/js/src/scene.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 "bind_types.h" + +#include + +#include + +namespace lagrange::js::bind { +namespace { + +SceneType mesh_to_scene(MeshType mesh) +{ + return scene::mesh_to_scene(std::move(mesh)); +} + +MeshType scene_to_mesh(const SceneType& scene) +{ + return scene::scene_to_mesh(scene); +} + +} // namespace +} // namespace lagrange::js::bind + +EMSCRIPTEN_BINDINGS(lagrange_scene) +{ + using namespace emscripten; + using namespace lagrange::js::bind; + + class_("Scene") + .function("getNumMeshes", optional_override([](const SceneType& s) -> size_t { + return s.meshes.size(); + })) + .function("getNumNodes", optional_override([](const SceneType& s) -> size_t { + return s.nodes.size(); + })); + + function("meshToScene", &mesh_to_scene); + function("sceneToMesh", &scene_to_mesh); +} diff --git a/modules/scene/js/ts/scene.ts b/modules/scene/js/ts/scene.ts new file mode 100644 index 00000000..7dc82b47 --- /dev/null +++ b/modules/scene/js/ts/scene.ts @@ -0,0 +1,37 @@ +/** + * Scene graph: a collection of meshes linked to transform nodes plus + * materials. Returned by `lagrange.io.loadSceneFromBuffer` when loading + * formats that carry a hierarchy (`gltf` / `glb`). + */ + +import type { SurfaceMesh } from "./core.js"; + +/** + * A loaded scene graph. Holds multiple meshes plus the node hierarchy that + * places them in the world. Backed by WASM memory — call {@link delete} + * when done to release it. + */ +export interface Scene { + /** Meshes referenced anywhere in the node tree. */ + getNumMeshes(): number; + /** Transform-graph nodes (including empty groups). */ + getNumNodes(): number; + /** Free the underlying WASM memory. Do not use the scene afterwards. */ + delete(): void; +} + +/** + * Convert between {@link SurfaceMesh} and {@link Scene}. Accessible as + * `lagrange.scene`. + */ +export interface SceneModule { + /** Wrap a single mesh in a trivial one-node scene. Useful before exporting to `gltf`/`glb`. */ + meshToScene(mesh: SurfaceMesh): Scene; + /** Flatten every mesh in the scene graph into a single combined mesh. */ + sceneToMesh(scene: Scene): SurfaceMesh; +} + +export const sceneModuleKeys = [ + "meshToScene", + "sceneToMesh", +] as const satisfies readonly (keyof SceneModule)[]; diff --git a/modules/scene/src/internal/bake_scaling.cpp b/modules/scene/src/internal/bake_scaling.cpp index 7aae12ca..e21010e0 100644 --- a/modules/scene/src/internal/bake_scaling.cpp +++ b/modules/scene/src/internal/bake_scaling.cpp @@ -14,6 +14,7 @@ #include #include #include +#include namespace lagrange::scene::internal { @@ -72,7 +73,7 @@ SimpleScene unbake_scaling(SimpleScene(&instance.user_data); la_runtime_assert( data, - fmt::format( + format( "Cannot unbake scaling for instance {} of mesh {}. No previous transform was " "found.", instance_index, diff --git a/modules/scene/src/internal/scene_string_utils.cpp b/modules/scene/src/internal/scene_string_utils.cpp index 15774353..46b59c34 100644 --- a/modules/scene/src/internal/scene_string_utils.cpp +++ b/modules/scene/src/internal/scene_string_utils.cpp @@ -14,8 +14,9 @@ #include #include #include +#include +#include -#include // Support for std::optional<> added in fmt 10.0.0 // Uncomment after updating fmt version in our vcpkg registry... // #include @@ -30,7 +31,7 @@ template std::string fmt_optional(const std::optional& value) { if (value.has_value()) { - return fmt::format("{}", value.value()); + return format("{}", value.value()); } else { return ""; } @@ -40,7 +41,7 @@ std::string fmt_optional(const std::optional& value) std::string to_string(const std::vector& ids) { - return fmt::format("[{}]", fmt::join(ids, ", ")); + return format("[{}]", join(ids, ", ")); } std::string to_string(ElementId id) @@ -71,16 +72,16 @@ std::string to_string(AttributeValueType t) std::string to_string(const SceneMeshInstance& mesh_instance, size_t indent) { - return fmt::format("{:{}s}mesh: {}\n", "", indent, to_string(mesh_instance.mesh)) + - fmt::format("{:{}s}materials: {}\n", "", indent, to_string(mesh_instance.materials)); + return format("{:{}s}mesh: {}\n", "", indent, to_string(mesh_instance.mesh)) + + format("{:{}s}materials: {}\n", "", indent, to_string(mesh_instance.materials)); } std::string to_string(const Node& node, size_t indent) { auto M = node.transform.matrix(); - std::string r = fmt::format("{:{}s}name: {}\n", "", indent, node.name) + - fmt::format("{:{}s}transform:\n", "", indent) + - fmt::format( + std::string r = format("{:{}s}name: {}\n", "", indent, node.name) + + format("{:{}s}transform:\n", "", indent) + + format( "{:{}s}- [ {: 8.3f}, {: 8.3f}, {: 8.3f}, {: 8.3f} ]\n", "", indent, @@ -88,7 +89,7 @@ std::string to_string(const Node& node, size_t indent) M(0, 1), M(0, 2), M(0, 3)) + - fmt::format( + format( "{:{}s}- [ {: 8.3f}, {: 8.3f}, {: 8.3f}, {: 8.3f} ]\n", "", indent, @@ -96,7 +97,7 @@ std::string to_string(const Node& node, size_t indent) M(1, 1), M(1, 2), M(1, 3)) + - fmt::format( + format( "{:{}s}- [ {: 8.3f}, {: 8.3f}, {: 8.3f}, {: 8.3f} ]\n", "", indent, @@ -104,7 +105,7 @@ std::string to_string(const Node& node, size_t indent) M(2, 1), M(2, 2), M(2, 3)) + - fmt::format( + format( "{:{}s}- [ {: 8.3f}, {: 8.3f}, {: 8.3f}, {: 8.3f} ]\n", "", indent, @@ -112,20 +113,19 @@ std::string to_string(const Node& node, size_t indent) M(3, 1), M(3, 2), M(3, 3)) + - fmt::format("{:{}s}parent: {}\n", "", indent, to_string(node.parent)) + - fmt::format("{:{}s}children: {}\n", "", indent, to_string(node.children)) + - fmt::format("{:{}s}meshes:\n", "", indent); + format("{:{}s}parent: {}\n", "", indent, to_string(node.parent)) + + format("{:{}s}children: {}\n", "", indent, to_string(node.children)) + + format("{:{}s}meshes:\n", "", indent); for (const auto& mesh_instance : node.meshes) { auto s = to_string(mesh_instance, indent + 2); s[indent] = '-'; r += s; } - r += fmt::format("{:{}s}cameras: {}\n", "", indent, to_string(node.cameras)) + - fmt::format("{:{}s}lights: {}\n", "", indent, to_string(node.lights)); + r += format("{:{}s}cameras: {}\n", "", indent, to_string(node.cameras)) + + format("{:{}s}lights: {}\n", "", indent, to_string(node.lights)); if (!node.extensions.empty()) { - r += - fmt::format("{:{}s}extensions:\n", "", indent) + to_string(node.extensions, indent + 2); + r += format("{:{}s}extensions:\n", "", indent) + to_string(node.extensions, indent + 2); } return r; } @@ -133,30 +133,29 @@ std::string to_string(const Node& node, size_t indent) std::string to_string(const ImageBufferExperimental& img_buf, size_t indent) { std::string r = - fmt::format("{:{}s}width: {}\n", "", indent, img_buf.width) + - fmt::format("{:{}s}height: {}\n", "", indent, img_buf.height) + - fmt::format("{:{}s}num_channels: {}\n", "", indent, img_buf.num_channels) + - fmt::format("{:{}s}element_type: {}\n", "", indent, to_string(img_buf.element_type)) + - fmt::format("{:{}s}data: \"\"\n", "", indent, img_buf.data.size()); + format("{:{}s}width: {}\n", "", indent, img_buf.width) + + format("{:{}s}height: {}\n", "", indent, img_buf.height) + + format("{:{}s}num_channels: {}\n", "", indent, img_buf.num_channels) + + format("{:{}s}element_type: {}\n", "", indent, to_string(img_buf.element_type)) + + format("{:{}s}data: \"\"\n", "", indent, img_buf.data.size()); return r; } std::string to_string(const ImageExperimental& img, size_t indent) { - std::string r = fmt::format("{:{}s}name: {}\n", "", indent, img.name) + - fmt::format("{:{}s}image:\n{}", "", indent, to_string(img.image, indent + 2)) + - fmt::format("{:{}s}uri: {}\n", "", indent, img.uri.string()); + std::string r = format("{:{}s}name: {}\n", "", indent, img.name) + + format("{:{}s}image:\n{}", "", indent, to_string(img.image, indent + 2)) + + format("{:{}s}uri: {}\n", "", indent, img.uri.string()); if (!img.extensions.empty()) { - r += - fmt::format("{:{}s}extensions:\n{}", "", indent, to_string(img.extensions, indent + 2)); + r += format("{:{}s}extensions:\n{}", "", indent, to_string(img.extensions, indent + 2)); } return r; } std::string to_string(const TextureInfo& tex_info, size_t indent) { - return fmt::format("{:{}s}index: {}\n", "", indent, to_string(tex_info.index)) + - fmt::format("{:{}s}texcoord: {}\n", "", indent, tex_info.texcoord); + return format("{:{}s}index: {}\n", "", indent, to_string(tex_info.index)) + + format("{:{}s}texcoord: {}\n", "", indent, tex_info.texcoord); } std::string to_string(const MaterialExperimental::AlphaMode& mode) @@ -171,65 +170,61 @@ std::string to_string(const MaterialExperimental::AlphaMode& mode) std::string to_string(const MaterialExperimental& material, size_t indent) { - std::string r = - fmt::format("{:{}s}name: {}\n", "", indent, material.name) + - fmt::format( - "{:{}s}base_color_value: [{}, {}, {}, {}]\n", - "", - indent, - material.base_color_value[0], - material.base_color_value[1], - material.base_color_value[2], - material.base_color_value[3]) + - fmt::format( - "{:{}s}base_color_texture:\n{}", - "", - indent, - to_string(material.base_color_texture, indent + 2)) + - fmt::format( - "{:{}s}emissive_value: [{}, {}, {}]\n", - "", - indent, - material.emissive_value[0], - material.emissive_value[1], - material.emissive_value[2]) + - fmt::format( - "{:{}s}emissive_texture:\n{}", - "", - indent, - to_string(material.emissive_texture, indent + 2)) + - fmt::format( - "{:{}s}metallic_roughness_texture:\n{}", - "", - indent, - to_string(material.metallic_roughness_texture, indent + 2)) + - fmt::format("{:{}s}metallic_value: {}\n", "", indent, material.metallic_value) + - fmt::format("{:{}s}roughness_value: {}\n", "", indent, material.roughness_value) + - fmt::format("{:{}s}alpha_mode: {}\n", "", indent, to_string(material.alpha_mode)) + - fmt::format("{:{}s}alpha_cutoff: {}\n", "", indent, material.alpha_cutoff) + - fmt::format("{:{}s}normal_scale: {}\n", "", indent, material.normal_scale) + - fmt::format( - "{:{}s}normal_texture:\n{}", - "", - indent, - to_string(material.normal_texture, indent + 2)) + - fmt::format( - "{:{}s}occlusion_strength: {}\n", - "", - indent, - to_string(material.occlusion_strength)) + - fmt::format( - "{:{}s}occlusion_texture:\n{}", - "", - indent, - to_string(material.occlusion_texture, indent + 2)) + - fmt::format("{:{}s}double_sided: {}\n", "", indent, material.double_sided); + std::string r = format("{:{}s}name: {}\n", "", indent, material.name) + + format( + "{:{}s}base_color_value: [{}, {}, {}, {}]\n", + "", + indent, + material.base_color_value[0], + material.base_color_value[1], + material.base_color_value[2], + material.base_color_value[3]) + + format( + "{:{}s}base_color_texture:\n{}", + "", + indent, + to_string(material.base_color_texture, indent + 2)) + + format( + "{:{}s}emissive_value: [{}, {}, {}]\n", + "", + indent, + material.emissive_value[0], + material.emissive_value[1], + material.emissive_value[2]) + + format( + "{:{}s}emissive_texture:\n{}", + "", + indent, + to_string(material.emissive_texture, indent + 2)) + + format( + "{:{}s}metallic_roughness_texture:\n{}", + "", + indent, + to_string(material.metallic_roughness_texture, indent + 2)) + + format("{:{}s}metallic_value: {}\n", "", indent, material.metallic_value) + + format("{:{}s}roughness_value: {}\n", "", indent, material.roughness_value) + + format("{:{}s}alpha_mode: {}\n", "", indent, to_string(material.alpha_mode)) + + format("{:{}s}alpha_cutoff: {}\n", "", indent, material.alpha_cutoff) + + format("{:{}s}normal_scale: {}\n", "", indent, material.normal_scale) + + format( + "{:{}s}normal_texture:\n{}", + "", + indent, + to_string(material.normal_texture, indent + 2)) + + format( + "{:{}s}occlusion_strength: {}\n", + "", + indent, + to_string(material.occlusion_strength)) + + format( + "{:{}s}occlusion_texture:\n{}", + "", + indent, + to_string(material.occlusion_texture, indent + 2)) + + format("{:{}s}double_sided: {}\n", "", indent, material.double_sided); if (!material.extensions.empty()) { - r += fmt::format( - "{:{}s}extensions:\n{}", - "", - indent, - to_string(material.extensions, indent + 2)); + r += + format("{:{}s}extensions:\n{}", "", indent, to_string(material.extensions, indent + 2)); } return r; } @@ -262,21 +257,17 @@ std::string to_string(const Texture::WrapMode& mode) std::string to_string(const Texture& texture, size_t indent) { std::string r = - fmt::format("{:{}s}name: {}\n", "", indent, texture.name) + - fmt::format("{:{}s}image: {}\n", "", indent, to_string(texture.image)) + - fmt::format("{:{}s}mag_filter: {}\n", "", indent, to_string(texture.mag_filter)) + - fmt::format("{:{}s}min_filter: {}\n", "", indent, to_string(texture.min_filter)) + - fmt::format("{:{}s}wrap_u: {}\n", "", indent, to_string(texture.wrap_u)) + - fmt::format("{:{}s}wrap_v: {}\n", "", indent, to_string(texture.wrap_v)) + - fmt::format("{:{}s}scale: [{}, {}]\n", "", indent, texture.scale[0], texture.scale[1]) + - fmt::format("{:{}s}offset: [{}, {}]\n", "", indent, texture.offset[0], texture.offset[1]) + - fmt::format("{:{}s}rotation: {}\n", "", indent, texture.rotation); + format("{:{}s}name: {}\n", "", indent, texture.name) + + format("{:{}s}image: {}\n", "", indent, to_string(texture.image)) + + format("{:{}s}mag_filter: {}\n", "", indent, to_string(texture.mag_filter)) + + format("{:{}s}min_filter: {}\n", "", indent, to_string(texture.min_filter)) + + format("{:{}s}wrap_u: {}\n", "", indent, to_string(texture.wrap_u)) + + format("{:{}s}wrap_v: {}\n", "", indent, to_string(texture.wrap_v)) + + format("{:{}s}scale: [{}, {}]\n", "", indent, texture.scale[0], texture.scale[1]) + + format("{:{}s}offset: [{}, {}]\n", "", indent, texture.offset[0], texture.offset[1]) + + format("{:{}s}rotation: {}\n", "", indent, texture.rotation); if (!texture.extensions.empty()) { - r += fmt::format( - "{:{}s}extensions:\n{}", - "", - indent, - to_string(texture.extensions, indent + 2)); + r += format("{:{}s}extensions:\n{}", "", indent, to_string(texture.extensions, indent + 2)); } return r; } @@ -297,67 +288,55 @@ std::string to_string(const Light::Type& type) std::string to_string(const Light& light, size_t indent) { std::string r = - fmt::format("{:{}s}name: {}\n", "", indent, light.name) + - fmt::format("{:{}s}type: {}\n", "", indent, to_string(light.type)) + - fmt::format( + format("{:{}s}name: {}\n", "", indent, light.name) + + format("{:{}s}type: {}\n", "", indent, to_string(light.type)) + + format( "{:{}s}position: [{}, {}, {}]\n", "", indent, light.position[0], light.position[1], light.position[2]) + - fmt::format( + format( "{:{}s}direction: [{}, {}, {}]\n", "", indent, light.direction[0], light.direction[1], light.direction[2]) + - fmt::format("{:{}s}up: [{}, {}, {}]\n", "", indent, light.up[0], light.up[1], light.up[2]) + - fmt::format("{:{}s}intensity: {}\n", "", indent, light.intensity) + - fmt::format("{:{}s}attenuation_constant: {}\n", "", indent, light.attenuation_constant) + - fmt::format("{:{}s}attenuation_linear: {}\n", "", indent, light.attenuation_linear) + - fmt::format("{:{}s}attenuation_quadratic: {}\n", "", indent, light.attenuation_quadratic) + - fmt::format("{:{}s}attenuation_cubic: {}\n", "", indent, light.attenuation_cubic) + - fmt::format("{:{}s}range: {}\n", "", indent, light.range) + - fmt::format( + format("{:{}s}up: [{}, {}, {}]\n", "", indent, light.up[0], light.up[1], light.up[2]) + + format("{:{}s}intensity: {}\n", "", indent, light.intensity) + + format("{:{}s}attenuation_constant: {}\n", "", indent, light.attenuation_constant) + + format("{:{}s}attenuation_linear: {}\n", "", indent, light.attenuation_linear) + + format("{:{}s}attenuation_quadratic: {}\n", "", indent, light.attenuation_quadratic) + + format("{:{}s}attenuation_cubic: {}\n", "", indent, light.attenuation_cubic) + + format("{:{}s}range: {}\n", "", indent, light.range) + + format( "{:{}s}color_diffuse: [{}, {}, {}]\n", "", indent, light.color_diffuse[0], light.color_diffuse[1], light.color_diffuse[2]) + - fmt::format( + format( "{:{}s}color_specular: [{}, {}, {}]\n", "", indent, light.color_specular[0], light.color_specular[1], light.color_specular[2]) + - fmt::format( + format( "{:{}s}color_ambient: [{}, {}, {}]\n", "", indent, light.color_ambient[0], light.color_ambient[1], light.color_ambient[2]) + - fmt::format( - "{:{}s}angle_inner_cone: {}\n", - "", - indent, - fmt_optional(light.angle_inner_cone)) + - fmt::format( - "{:{}s}angle_outer_cone: {}\n", - "", - indent, - fmt_optional(light.angle_outer_cone)) + - fmt::format("{:{}s}size: [{}, {}]\n", "", indent, light.size[0], light.size[1]); + format("{:{}s}angle_inner_cone: {}\n", "", indent, fmt_optional(light.angle_inner_cone)) + + format("{:{}s}angle_outer_cone: {}\n", "", indent, fmt_optional(light.angle_outer_cone)) + + format("{:{}s}size: [{}, {}]\n", "", indent, light.size[0], light.size[1]); if (!light.extensions.empty()) { - r += fmt::format( - "{:{}s}extensions:\n{}", - "", - indent, - to_string(light.extensions, indent + 2)); + r += format("{:{}s}extensions:\n{}", "", indent, to_string(light.extensions, indent + 2)); } return r; } @@ -374,53 +353,43 @@ std::string to_string(const Camera::Type& type) std::string to_string(const Camera& camera, size_t indent) { std::string r = - fmt::format("{:{}s}name: {}\n", "", indent, camera.name) + - fmt::format( + format("{:{}s}name: {}\n", "", indent, camera.name) + + format( "{:{}s}position: [{}, {}, {}]\n", "", indent, camera.position[0], camera.position[1], camera.position[2]) + - fmt::format( - "{:{}s}up: [{}, {}, {}]\n", - "", - indent, - camera.up[0], - camera.up[1], - camera.up[2]) + - fmt::format( + format("{:{}s}up: [{}, {}, {}]\n", "", indent, camera.up[0], camera.up[1], camera.up[2]) + + format( "{:{}s}look_at: [{}, {}, {}]\n", "", indent, camera.look_at[0], camera.look_at[1], camera.look_at[2]) + - fmt::format("{:{}s}near_plane: {}\n", "", indent, camera.near_plane) + - fmt::format( + format("{:{}s}near_plane: {}\n", "", indent, camera.near_plane) + + format( "{:{}s}far_plane: {}\n", "", indent, camera.far_plane.value_or(std::numeric_limits::infinity())) + - fmt::format("{:{}s}type: {}\n", "", indent, to_string(camera.type)) + - fmt::format("{:{}s}orthographic_width: {}\n", "", indent, camera.orthographic_width) + - fmt::format("{:{}s}aspect_ratio: {}\n", "", indent, camera.aspect_ratio) + - fmt::format("{:{}s}horizontal_fov: {}\n", "", indent, camera.horizontal_fov); + format("{:{}s}type: {}\n", "", indent, to_string(camera.type)) + + format("{:{}s}orthographic_width: {}\n", "", indent, camera.orthographic_width) + + format("{:{}s}aspect_ratio: {}\n", "", indent, camera.aspect_ratio) + + format("{:{}s}horizontal_fov: {}\n", "", indent, camera.horizontal_fov); if (!camera.extensions.empty()) { - r += fmt::format( - "{:{}s}extensions:\n{}", - "", - indent, - to_string(camera.extensions, indent + 2)); + r += format("{:{}s}extensions:\n{}", "", indent, to_string(camera.extensions, indent + 2)); } return r; } std::string to_string(const Animation& animation, size_t indent) { - std::string r = fmt::format("{:{}s}name: {}\n", "", indent, animation.name); + std::string r = format("{:{}s}name: {}\n", "", indent, animation.name); if (!animation.extensions.empty()) { - r += fmt::format( + r += format( "{:{}s}extensions:\n{}", "", indent, @@ -431,13 +400,10 @@ std::string to_string(const Animation& animation, size_t indent) std::string to_string(const Skeleton& skeleton, size_t indent) { - std::string r = fmt::format("{:{}s}meshes: {}\n", "", indent, to_string(skeleton.meshes)); + std::string r = format("{:{}s}meshes: {}\n", "", indent, to_string(skeleton.meshes)); if (!skeleton.extensions.empty()) { - r += fmt::format( - "{:{}s}extensions:\n{}", - "", - indent, - to_string(skeleton.extensions, indent + 2)); + r += + format("{:{}s}extensions:\n{}", "", indent, to_string(skeleton.extensions, indent + 2)); } return r; } @@ -445,10 +411,10 @@ std::string to_string(const Skeleton& skeleton, size_t indent) template std::string to_string(const Scene& scene, size_t indent) { - std::string r = fmt::format("{:{}s}name: {}\n", "", indent, scene.name); + std::string r = format("{:{}s}name: {}\n", "", indent, scene.name); if (!scene.nodes.empty()) { - r += fmt::format("{:{}s}nodes:\n", "", indent); + r += format("{:{}s}nodes:\n", "", indent); for (const auto& node : scene.nodes) { std::string node_str = to_string(node, indent + 2); node_str[indent] = '-'; @@ -457,13 +423,13 @@ std::string to_string(const Scene& scene, size_t indent) } if (!scene.root_nodes.empty()) { - r += fmt::format("{:{}s}root_nodes: {}\n", "", indent, to_string(scene.root_nodes)); + r += format("{:{}s}root_nodes: {}\n", "", indent, to_string(scene.root_nodes)); } if (!scene.meshes.empty()) { - r += fmt::format("{:{}s}meshes:\n", "", indent); + r += format("{:{}s}meshes:\n", "", indent); for (const auto& mesh : scene.meshes) { - r += fmt::format( + r += format( "{:{}s}- \"\"\n", "", indent + 2, @@ -473,7 +439,7 @@ std::string to_string(const Scene& scene, size_t indent) } if (!scene.images.empty()) { - r += fmt::format("{:{}s}images:\n", "", indent); + r += format("{:{}s}images:\n", "", indent); for (const auto& img : scene.images) { std::string img_str = to_string(img, indent + 2); img_str[indent] = '-'; @@ -482,7 +448,7 @@ std::string to_string(const Scene& scene, size_t indent) } if (!scene.textures.empty()) { - r += fmt::format("{:{}s}textures:\n", "", indent); + r += format("{:{}s}textures:\n", "", indent); for (const auto& tex : scene.textures) { std::string tex_str = to_string(tex, indent + 2); tex_str[indent] = '-'; @@ -491,7 +457,7 @@ std::string to_string(const Scene& scene, size_t indent) } if (!scene.materials.empty()) { - r += fmt::format("{:{}s}materials:\n", "", indent); + r += format("{:{}s}materials:\n", "", indent); for (const auto& mat : scene.materials) { std::string mat_str = to_string(mat, indent + 2); mat_str[indent] = '-'; @@ -500,7 +466,7 @@ std::string to_string(const Scene& scene, size_t indent) } if (!scene.lights.empty()) { - r += fmt::format("{:{}s}lights:\n", "", indent); + r += format("{:{}s}lights:\n", "", indent); for (const auto& light : scene.lights) { std::string light_str = to_string(light, indent + 2); light_str[indent] = '-'; @@ -509,7 +475,7 @@ std::string to_string(const Scene& scene, size_t indent) } if (!scene.cameras.empty()) { - r += fmt::format("{:{}s}cameras:\n", "", indent); + r += format("{:{}s}cameras:\n", "", indent); for (const auto& camera : scene.cameras) { std::string camera_str = to_string(camera, indent + 2); camera_str[indent] = '-'; @@ -518,7 +484,7 @@ std::string to_string(const Scene& scene, size_t indent) } if (!scene.skeletons.empty()) { - r += fmt::format("{:{}s}skeletons:\n", "", indent); + r += format("{:{}s}skeletons:\n", "", indent); for (const auto& skeleton : scene.skeletons) { std::string skeleton_str = to_string(skeleton, indent + 2); skeleton_str[indent] = '-'; @@ -527,7 +493,7 @@ std::string to_string(const Scene& scene, size_t indent) } if (!scene.animations.empty()) { - r += fmt::format("{:{}s}animations:\n", "", indent); + r += format("{:{}s}animations:\n", "", indent); for (const auto& animation : scene.animations) { std::string animation_str = to_string(animation, indent + 2); animation_str[indent] = '-'; @@ -536,8 +502,7 @@ std::string to_string(const Scene& scene, size_t indent) } if (!scene.extensions.empty()) { - r += fmt::format("{:{}s}extensions:\n", "", indent) + - to_string(scene.extensions, indent + 2); + r += format("{:{}s}extensions:\n", "", indent) + to_string(scene.extensions, indent + 2); } return r; } @@ -553,7 +518,7 @@ std::string to_string(const Value& value, size_t indent) } else if (value.is_string()) { return value.get_string(); } else if (value.is_buffer()) { - return fmt::format("\"\"", value.get_buffer().size()); + return format("\"\"", value.get_buffer().size()); } else if (value.is_array()) { const auto& arr = value.get_array(); if (arr.empty()) { @@ -573,7 +538,7 @@ std::string to_string(const Value& value, size_t indent) std::string r = "\n"; for (size_t i = 0; i < value_strs.size(); i++) { if (value_strs[i].find('\n') != std::string::npos) { - r += fmt::format("{:{}s}- {}\n", "", indent, value_strs[i]); + r += format("{:{}s}- {}\n", "", indent, value_strs[i]); } else { value_strs[i][indent] = '-'; r += value_strs[i]; @@ -584,13 +549,13 @@ std::string to_string(const Value& value, size_t indent) } return r; } else { - return fmt::format("[{}]", fmt::join(value_strs, ", ")); + return format("[{}]", join(value_strs, ", ")); } } } else if (value.is_object()) { std::string r = "\n"; for (const auto& [key, val] : value.get_object()) { - r += fmt::format("{:{}s}{}: {}\n", "", indent, key, to_string(val, indent + 2)); + r += format("{:{}s}{}: {}\n", "", indent, key, to_string(val, indent + 2)); } while (!r.empty() && r.back() == '\n') { r.pop_back(); @@ -605,7 +570,7 @@ std::string to_string(const Extensions& extensions, size_t indent) { std::string r; for (const auto& [key, value] : extensions.data) { - r += fmt::format("{:{}s}{}: {}\n", "", indent, key, to_string(value, indent + 2)); + r += format("{:{}s}{}: {}\n", "", indent, key, to_string(value, indent + 2)); } return r; } diff --git a/modules/scene/src/scene_utils.cpp b/modules/scene/src/scene_utils.cpp index 490fc15b..1592985d 100644 --- a/modules/scene/src/scene_utils.cpp +++ b/modules/scene/src/scene_utils.cpp @@ -118,6 +118,26 @@ ortho(double left, double right, double bottom, double top, double z_near, doubl } // namespace Eigen::Affine3f camera_view_transform(const Camera& camera, const Eigen::Affine3f& world_from_local) +{ + // We follow the glTF 2.0 specification. §3.10.2: "The view matrix is derived from the global + // transform of the node containing the camera with the scaling ignored." + // + // Extract the closest proper rotation via polar decomposition (SVD internally). + Eigen::Affine3d wfl = world_from_local.cast(); + Eigen::Isometry3d world_no_scale = Eigen::Isometry3d::Identity(); + world_no_scale.linear() = wfl.rotation(); + world_no_scale.translation() = wfl.translation(); + + Eigen::Affine3d camera_from_local = look_at( + camera.position.cast(), + camera.look_at.cast(), + camera.up.cast()); + return (camera_from_local * world_no_scale.inverse()).cast(); +} + +Eigen::Affine3f camera_view_transform( + const Camera& camera, + const Eigen::Isometry3f& world_from_local) { Eigen::Affine3d camera_from_local = look_at( camera.position.cast(), @@ -140,10 +160,11 @@ Eigen::Projective3f camera_projection_transform(const Camera& camera) .cast(); } } else if (camera.type == Camera::Type::Orthographic) { + // orthographic_width is the half-width (xmag), so the view spans [-w, w] const double w = camera.orthographic_width; const double h = w / camera.aspect_ratio; const double far = camera.far_plane.value(); - return ortho(w / -2.0, w / 2.0, h / -2.0, h / 2.0, near, far).cast(); + return ortho(-w, w, -h, h, near, far).cast(); } else { throw Error("Unrecognized camera type"); } diff --git a/modules/scene/tests/test_camera.cpp b/modules/scene/tests/test_camera.cpp index 16e8d661..2a1509bb 100644 --- a/modules/scene/tests/test_camera.cpp +++ b/modules/scene/tests/test_camera.cpp @@ -11,18 +11,12 @@ */ #include -#include -#include -#include - #include -#include - TEST_CASE("camera matrices", "[scene]") { // clang-format off - Eigen::Affine3f world_from_camera; + Eigen::Isometry3f world_from_camera = Eigen::Isometry3f::Identity(); world_from_camera.linear() << 0.0, -0.859127402305603, 0.5117617249488831, 0.0, 0.5117617249488831, 0.859127402305603, @@ -47,8 +41,8 @@ TEST_CASE("camera matrices", "[scene]") Eigen::Matrix4f expected_view; expected_view << 0x0p+0, 0x0p+0, -0x1p+0, 0x0p+0, - -0x1.b7df8ep-1, 0x1.0605a2p-1, 0x0p+0, -0x1.50aee2p-27, - 0x1.0605a2p-1, 0x1.b7df8ep-1, 0x0p+0, -0x1.800002p+1, + -0x1.b7df8cp-1, 0x1.0605a2p-1, 0x0p+0, -0x1.50aeep-27, + 0x1.0605a2p-1, 0x1.b7df8cp-1, 0x0p+0, -0x1.8p+1, 0x0p+0, 0x0p+0, 0x0p+0, 0x1p+0; Eigen::Matrix4f expected_proj; expected_proj << @@ -74,8 +68,8 @@ TEST_CASE("camera matrices", "[scene]") Eigen::Matrix4f expected_view; expected_view << 0x0p+0, 0x0p+0, -0x1p+0, 0x0p+0, - -0x1.b7df8ep-1, 0x1.0605a2p-1, 0x0p+0, -0x1.50aee2p-27, - 0x1.0605a2p-1, 0x1.b7df8ep-1, 0x0p+0, -0x1.800002p+1, + -0x1.b7df8cp-1, 0x1.0605a2p-1, 0x0p+0, -0x1.50aeep-27, + 0x1.0605a2p-1, 0x1.b7df8cp-1, 0x0p+0, -0x1.8p+1, 0x0p+0, 0x0p+0, 0x0p+0, 0x1p+0; Eigen::Matrix4f expected_proj; expected_proj << @@ -102,13 +96,13 @@ TEST_CASE("camera matrices", "[scene]") Eigen::Matrix4f expected_view; expected_view << 0x0p+0, 0x0p+0, -0x1p+0, 0x0p+0, - -0x1.b7df8ep-1, 0x1.0605a2p-1, 0x0p+0, -0x1.50aee2p-27, - 0x1.0605a2p-1, 0x1.b7df8ep-1, 0x0p+0, -0x1.800002p+1, + -0x1.b7df8cp-1, 0x1.0605a2p-1, 0x0p+0, -0x1.50aeep-27, + 0x1.0605a2p-1, 0x1.b7df8cp-1, 0x0p+0, -0x1.8p+1, 0x0p+0, 0x0p+0, 0x0p+0, 0x1p+0; Eigen::Matrix4f expected_proj; expected_proj << - 0x1p+1, 0x0p+0, 0x0p+0, -0x0p+0, - 0x0p+0, 0x1p+1, 0x0p+0, -0x0p+0, + 0x1p+0, 0x0p+0, 0x0p+0, -0x0p+0, + 0x0p+0, 0x1p+0, 0x0p+0, -0x0p+0, 0x0p+0, 0x0p+0, -0x1.062b94p-9, -0x1.000d1cp+0, 0x0p+0, 0x0p+0, 0x0p+0, 0x1p+0; // clang-format on @@ -116,4 +110,57 @@ TEST_CASE("camera matrices", "[scene]") REQUIRE(view_transform.matrix() == expected_view); REQUIRE(proj_transform.matrix() == expected_proj); } + + SECTION("affine view transform without scale") + { + camera.far_plane = 1000.f; + + // Convert the Isometry3f to Affine3f (no scale added) + Eigen::Affine3f world_affine = Eigen::Affine3f::Identity(); + world_affine.linear() = world_from_camera.linear(); + world_affine.translation() = world_from_camera.translation(); + + namespace utils = lagrange::scene::utils; + auto view_isometry = utils::camera_view_transform(camera, world_from_camera); + auto view_affine = utils::camera_view_transform(camera, world_affine); + + REQUIRE(view_affine.matrix().isApprox(view_isometry.matrix())); + } + + SECTION("affine view transform with uniform scale") + { + camera.far_plane = 1000.f; + + // Add a uniform scale factor (e.g. Blender's unit conversion) + Eigen::Affine3f world_scaled = Eigen::Affine3f::Identity(); + world_scaled.linear() = 0.403f * world_from_camera.linear(); + world_scaled.translation() = world_from_camera.translation(); + + namespace utils = lagrange::scene::utils; + auto view_isometry = utils::camera_view_transform(camera, world_from_camera); + auto view_scaled = utils::camera_view_transform(camera, world_scaled); + + // Scale should be stripped per glTF §3.10.2, so results must match + REQUIRE(view_scaled.matrix().isApprox(view_isometry.matrix())); + } + + SECTION("affine view transform with non-uniform scale") + { + camera.far_plane = 1000.f; + + // Non-uniform scale on each axis + Eigen::Affine3f world_scaled = Eigen::Affine3f::Identity(); + world_scaled.linear() = world_from_camera.linear(); + world_scaled.linear().col(0) *= 2.0f; + world_scaled.linear().col(1) *= 0.5f; + world_scaled.linear().col(2) *= 3.0f; + world_scaled.translation() = world_from_camera.translation(); + + namespace utils = lagrange::scene::utils; + auto view_isometry = utils::camera_view_transform(camera, world_from_camera); + auto view_scaled = utils::camera_view_transform(camera, world_scaled); + + // Scale should be stripped per glTF §3.10.2, so results must match + REQUIRE(view_scaled.matrix().isApprox(view_isometry.matrix())); + } } diff --git a/modules/serialization2/CMakeLists.txt b/modules/serialization2/CMakeLists.txt index ec1911c3..b06f9a16 100644 --- a/modules/serialization2/CMakeLists.txt +++ b/modules/serialization2/CMakeLists.txt @@ -36,3 +36,7 @@ endif() if(LAGRANGE_UNIT_TESTS) add_subdirectory(tests) endif() + +if(LAGRANGE_BINDINGS_JS) + add_subdirectory(js) +endif() diff --git a/modules/serialization2/js/CMakeLists.txt b/modules/serialization2/js/CMakeLists.txt new file mode 100644 index 00000000..4246dac6 --- /dev/null +++ b/modules/serialization2/js/CMakeLists.txt @@ -0,0 +1 @@ +lagrange_add_js_binding(serialization SerializationModule) diff --git a/modules/serialization2/js/src/serialization2.cpp b/modules/serialization2/js/src/serialization2.cpp new file mode 100644 index 00000000..c66fce7b --- /dev/null +++ b/modules/serialization2/js/src/serialization2.cpp @@ -0,0 +1,96 @@ +/* + * 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 "bind_types.h" + +#include +#include + +#include +#include + +#include + +namespace { + +using namespace lagrange; +using namespace lagrange::js::bind; +using val = emscripten::val; + +} // namespace + +EMSCRIPTEN_BINDINGS(lagrange_serialization) +{ + using namespace emscripten; + + function( + "serializeMesh", + +[](const MeshType& mesh, val opts) -> val { + serialization::SerializeOptions o; + apply_opt(opts, "compress", o.compress); + apply_opt(opts, "compressionLevel", o.compression_level); + apply_opt(opts, "numThreads", o.num_threads); + + auto buf = serialization::serialize_mesh(mesh, o); + + return val::global("Uint8Array").new_(typed_memory_view(buf.size(), buf.data())); + }); + + function( + "deserializeMesh", + +[](const val& data, val opts) -> MeshType { + const auto length = data["length"].as(); + std::vector buf(length); + val memory_view = val(typed_memory_view(length, buf.data())); + memory_view.call("set", data); + + serialization::DeserializeOptions o; + apply_opt(opts, "allowSceneConversion", o.allow_scene_conversion); + apply_opt(opts, "allowTypeCast", o.allow_type_cast); + apply_opt(opts, "quiet", o.quiet); + + return serialization::deserialize_mesh( + span(buf.data(), buf.size()), + o); + }); + + function( + "serializeScene", + +[](const SceneType& scene, val opts) -> val { + serialization::SerializeOptions o; + apply_opt(opts, "compress", o.compress); + apply_opt(opts, "compressionLevel", o.compression_level); + apply_opt(opts, "numThreads", o.num_threads); + + auto buf = serialization::serialize_scene(scene, o); + + return val::global("Uint8Array").new_(typed_memory_view(buf.size(), buf.data())); + }); + + function( + "deserializeScene", + +[](const val& data, val opts) -> SceneType { + const auto length = data["length"].as(); + std::vector buf(length); + val memory_view = val(typed_memory_view(length, buf.data())); + memory_view.call("set", data); + + serialization::DeserializeOptions o; + apply_opt(opts, "allowSceneConversion", o.allow_scene_conversion); + apply_opt(opts, "allowTypeCast", o.allow_type_cast); + apply_opt(opts, "quiet", o.quiet); + + return serialization::deserialize_scene( + span(buf.data(), buf.size()), + o); + }); +} diff --git a/modules/serialization2/js/test/serialization.test.ts b/modules/serialization2/js/test/serialization.test.ts new file mode 100644 index 00000000..91c3ef4a --- /dev/null +++ b/modules/serialization2/js/test/serialization.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +describe("serialization", () => { + test("round-trip: serialize then deserialize preserves mesh", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const numVerts = mesh.getNumVertices(); + const numFacets = mesh.getNumFacets(); + + const data = lagrange.serialization.serializeMesh(mesh); + expect(data.length).toBeGreaterThan(0); + + const restored = lagrange.serialization.deserializeMesh(data); + expect(restored.getNumVertices()).toBe(numVerts); + expect(restored.getNumFacets()).toBe(numFacets); + + restored.delete(); + mesh.delete(); + }); + + test("serializeMesh with compression options", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + + const compressed = lagrange.serialization.serializeMesh(mesh, { compress: true, compressionLevel: 10 }); + const uncompressed = lagrange.serialization.serializeMesh(mesh, { compress: false }); + + expect(compressed.length).toBeLessThan(uncompressed.length); + + mesh.delete(); + }); + + test("deserializeMesh with options", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const data = lagrange.serialization.serializeMesh(mesh); + const restored = lagrange.serialization.deserializeMesh(data, { quiet: true }); + expect(restored.getNumVertices()).toBe(mesh.getNumVertices()); + restored.delete(); + mesh.delete(); + }); + + test("round-trip: serialize then deserialize preserves scene", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const scene = lagrange.scene.meshToScene(mesh); + const numMeshes = scene.getNumMeshes(); + const numNodes = scene.getNumNodes(); + + const data = lagrange.serialization.serializeScene(scene); + expect(data.length).toBeGreaterThan(0); + + const restored = lagrange.serialization.deserializeScene(data); + expect(restored.getNumMeshes()).toBe(numMeshes); + expect(restored.getNumNodes()).toBe(numNodes); + + restored.delete(); + scene.delete(); + mesh.delete(); + }); + + test("serializeScene with compression options", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const scene = lagrange.scene.meshToScene(mesh); + + const compressed = lagrange.serialization.serializeScene(scene, { compress: true, compressionLevel: 10 }); + const uncompressed = lagrange.serialization.serializeScene(scene, { compress: false }); + + expect(compressed.length).toBeLessThan(uncompressed.length); + + scene.delete(); + mesh.delete(); + }); + + test("deserializeScene with options", () => { + const mesh = lagrange.primitive.generateSphere({ radius: 1 }); + const scene = lagrange.scene.meshToScene(mesh); + const data = lagrange.serialization.serializeScene(scene); + const restored = lagrange.serialization.deserializeScene(data, { quiet: true }); + expect(restored.getNumMeshes()).toBe(scene.getNumMeshes()); + restored.delete(); + scene.delete(); + mesh.delete(); + }); +}); diff --git a/modules/serialization2/js/ts/serialization.ts b/modules/serialization2/js/ts/serialization.ts new file mode 100644 index 00000000..d148f875 --- /dev/null +++ b/modules/serialization2/js/ts/serialization.ts @@ -0,0 +1,59 @@ +/** + * Fast binary round-trip for a {@link SurfaceMesh} or {@link Scene}, including + * all attached attributes. Prefer this over `obj`/`ply`/`gltf` when storing + * data in IndexedDB, caching in blob storage, or sending over the network — + * it round-trips every attribute exactly and is significantly smaller than + * the text-based formats. + * + * Use `lagrange.io` when you need interoperability with other 3D tools. + * + * Omitted option fields fall back to library defaults. + */ + +import type { SurfaceMesh } from "./core.js"; +import type { Scene } from "./scene.js"; + +export interface SerializeOptions { + /** Apply zstd compression. Disable for faster write at the cost of size. Default: `true`. */ + compress?: boolean; + /** zstd compression level in `[1, 22]`. Higher = smaller but slower. Default: `3`. */ + compressionLevel?: number; + /** Worker thread count. `0` = automatic, `1` = single-threaded. Default: `0`. */ + numThreads?: number; +} + +export interface DeserializeOptions { + /** + * Accept input that was serialized as a scene (single-mesh scenes are + * unwrapped automatically). Default: `false`. + */ + allowSceneConversion?: boolean; + /** + * Allow casting between scalar / index types if they don't match the + * target mesh. Default: `false`. + */ + allowTypeCast?: boolean; + /** Suppress warnings printed to the console. Default: `false`. */ + quiet?: boolean; +} + +/** + * Binary serialization of Lagrange meshes and scenes. Accessible as `lagrange.serialization`. + */ +export interface SerializationModule { + /** Serialize the mesh (with all attributes) into a compact binary blob. */ + serializeMesh(mesh: SurfaceMesh, opts?: SerializeOptions): Uint8Array; + /** Restore a mesh previously produced by {@link serializeMesh}. */ + deserializeMesh(data: Uint8Array, opts?: DeserializeOptions): SurfaceMesh; + /** Serialize the scene (meshes, materials, nodes) into a compact binary blob. */ + serializeScene(scene: Scene, opts?: SerializeOptions): Uint8Array; + /** Restore a scene previously produced by {@link serializeScene}. */ + deserializeScene(data: Uint8Array, opts?: DeserializeOptions): Scene; +} + +export const serializationModuleKeys = [ + "serializeMesh", + "deserializeMesh", + "serializeScene", + "deserializeScene", +] as const satisfies readonly (keyof SerializationModule)[]; diff --git a/modules/serialization2/src/serialize_simple_scene.cpp b/modules/serialization2/src/serialize_simple_scene.cpp index 2ca694c3..c0095ee3 100644 --- a/modules/serialization2/src/serialize_simple_scene.cpp +++ b/modules/serialization2/src/serialize_simple_scene.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include "CistaSimpleScene.h" #include "compress.h" @@ -82,32 +83,32 @@ scene::SimpleScene from_cista_simple_scene(const Cista { la_runtime_assert( cscene.version == simple_scene_format_version(), - fmt::format( + format( "Unsupported encoding format version: expected {}, got {}", simple_scene_format_version(), cscene.version)); la_runtime_assert( cscene.scalar_type_size == sizeof(Scalar), - fmt::format( + format( "Scalar type size mismatch: expected {}, got {}", sizeof(Scalar), cscene.scalar_type_size)); la_runtime_assert( cscene.index_type_size == sizeof(Index), - fmt::format( + format( "Index type size mismatch: expected {}, got {}", sizeof(Index), cscene.index_type_size)); la_runtime_assert( cscene.dimension == Dimension, - fmt::format("Dimension mismatch: expected {}, got {}", Dimension, cscene.dimension)); + format("Dimension mismatch: expected {}, got {}", Dimension, cscene.dimension)); scene::SimpleScene scene; const size_t num_meshes = cscene.meshes.size(); la_runtime_assert( cscene.instances_per_mesh.size() == num_meshes, - fmt::format( + format( "instances_per_mesh size mismatch: expected {}, got {}", num_meshes, cscene.instances_per_mesh.size())); @@ -123,7 +124,7 @@ scene::SimpleScene from_cista_simple_scene(const Cista const size_t num_instances = static_cast(cscene.instances_per_mesh[i]); la_runtime_assert( instance_offset + num_instances <= cscene.instances.size(), - fmt::format( + format( "Instance offset out of bounds: offset={} + count={} > total={}", instance_offset, num_instances, @@ -142,7 +143,7 @@ scene::SimpleScene from_cista_simple_scene(const Cista constexpr size_t byte_size = matrix_size * sizeof(Scalar); la_runtime_assert( cinst.transform_bytes.size() == byte_size, - fmt::format( + format( "Transform data size mismatch: expected {}, got {}", byte_size, cinst.transform_bytes.size())); @@ -154,7 +155,7 @@ scene::SimpleScene from_cista_simple_scene(const Cista } la_runtime_assert( instance_offset == cscene.instances.size(), - fmt::format( + format( "Total instance count mismatch: expected {}, got {}", cscene.instances.size(), instance_offset)); @@ -174,13 +175,13 @@ scene::SimpleScene deserialize_simple_scene_with_c la_runtime_assert( cscene->version == simple_scene_format_version(), - fmt::format( + format( "Unsupported encoding format version: expected {}, got {}", simple_scene_format_version(), cscene->version)); la_runtime_assert( cscene->dimension == Dimension, - fmt::format("Dimension mismatch: expected {}, got {}", Dimension, cscene->dimension)); + format("Dimension mismatch: expected {}, got {}", Dimension, cscene->dimension)); const uint8_t ss = cscene->scalar_type_size; const uint8_t is = cscene->index_type_size; @@ -201,7 +202,7 @@ scene::SimpleScene deserialize_simple_scene_with_c return lagrange::cast(m); } else { throw std::runtime_error( - fmt::format("Unsupported scalar/index type sizes: scalar={} index={}", ss, is)); + format("Unsupported scalar/index type sizes: scalar={} index={}", ss, is)); } }; @@ -210,7 +211,7 @@ scene::SimpleScene deserialize_simple_scene_with_c const size_t num_meshes = cscene->meshes.size(); la_runtime_assert( cscene->instances_per_mesh.size() == num_meshes, - fmt::format( + format( "instances_per_mesh size mismatch: expected {}, got {}", num_meshes, cscene->instances_per_mesh.size())); @@ -227,7 +228,7 @@ scene::SimpleScene deserialize_simple_scene_with_c const size_t num_instances = static_cast(cscene->instances_per_mesh[i]); la_runtime_assert( instance_offset + num_instances <= cscene->instances.size(), - fmt::format( + format( "Instance offset out of bounds: offset={} + count={} > total={}", instance_offset, num_instances, @@ -245,7 +246,7 @@ scene::SimpleScene deserialize_simple_scene_with_c const size_t expected_byte_size = matrix_size * ss; la_runtime_assert( cinst.transform_bytes.size() == expected_byte_size, - fmt::format( + format( "Transform data size mismatch: expected {}, got {}", expected_byte_size, cinst.transform_bytes.size())); @@ -262,7 +263,7 @@ scene::SimpleScene deserialize_simple_scene_with_c inst.transform.matrix().data()[k] = static_cast(native[k]); } } else { - throw std::runtime_error(fmt::format("Unsupported scalar type size: {}", ss)); + throw std::runtime_error(format("Unsupported scalar type size: {}", ss)); } result.add_instance(std::move(inst)); @@ -271,7 +272,7 @@ scene::SimpleScene deserialize_simple_scene_with_c } la_runtime_assert( instance_offset == cscene->instances.size(), - fmt::format( + format( "Total instance count mismatch: expected {}, got {}", cscene->instances.size(), instance_offset)); @@ -320,14 +321,13 @@ SceneType deserialize_simple_scene(span buffer, const Deserialize if (cscene->scalar_type_size != sizeof(Scalar) || cscene->index_type_size != sizeof(Index)) { if (!options.allow_type_cast) { - throw std::runtime_error( - fmt::format( - "Scalar/Index type mismatch: buffer has scalar_size={} index_size={}, " - "expected scalar_size={} index_size={}", - cscene->scalar_type_size, - cscene->index_type_size, - sizeof(Scalar), - sizeof(Index))); + throw std::runtime_error(format( + "Scalar/Index type mismatch: buffer has scalar_size={} index_size={}, " + "expected scalar_size={} index_size={}", + cscene->scalar_type_size, + cscene->index_type_size, + sizeof(Scalar), + sizeof(Index))); } if (!options.quiet) { logger().warn( diff --git a/modules/subdivision/CMakeLists.txt b/modules/subdivision/CMakeLists.txt index 0891b27e..cd74893b 100644 --- a/modules/subdivision/CMakeLists.txt +++ b/modules/subdivision/CMakeLists.txt @@ -36,3 +36,7 @@ endif() if(LAGRANGE_MODULE_PYTHON) add_subdirectory(python) endif() + +if(LAGRANGE_BINDINGS_JS) + add_subdirectory(js) +endif() diff --git a/modules/subdivision/js/CMakeLists.txt b/modules/subdivision/js/CMakeLists.txt new file mode 100644 index 00000000..d9b9f4c8 --- /dev/null +++ b/modules/subdivision/js/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_js_binding(subdivision SubdivisionModule) diff --git a/modules/subdivision/js/src/subdivision.cpp b/modules/subdivision/js/src/subdivision.cpp new file mode 100644 index 00000000..84bd7137 --- /dev/null +++ b/modules/subdivision/js/src/subdivision.cpp @@ -0,0 +1,103 @@ +/* + * 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 "bind_types.h" + +#include +#include +#include + +#include +#include + +#include + +namespace { + +using namespace lagrange; +using namespace lagrange::js::bind; +using val = emscripten::val; + +subdivision::SchemeType parse_scheme(const std::string& s) +{ + if (s == "bilinear") return subdivision::SchemeType::Bilinear; + if (s == "loop") return subdivision::SchemeType::Loop; + return subdivision::SchemeType::CatmullClark; +} + +subdivision::VertexBoundaryInterpolation parse_vertex_boundary(const std::string& s) +{ + if (s == "none") return subdivision::VertexBoundaryInterpolation::None; + if (s == "edgeAndCorner") return subdivision::VertexBoundaryInterpolation::EdgeAndCorner; + return subdivision::VertexBoundaryInterpolation::EdgeOnly; +} + +subdivision::FaceVaryingInterpolation parse_fv_interpolation(const std::string& s) +{ + if (s == "cornersOnly") return subdivision::FaceVaryingInterpolation::CornersOnly; + if (s == "cornersPlus1") return subdivision::FaceVaryingInterpolation::CornersPlus1; + if (s == "cornersPlus2") return subdivision::FaceVaryingInterpolation::CornersPlus2; + if (s == "boundaries") return subdivision::FaceVaryingInterpolation::Boundaries; + if (s == "all") return subdivision::FaceVaryingInterpolation::All; + return subdivision::FaceVaryingInterpolation::None; +} + +} // namespace + +EMSCRIPTEN_BINDINGS(lagrange_subdivision) +{ + using namespace emscripten; + + function( + "subdivideMesh", + +[](const MeshType& mesh, val opts) -> MeshType { + subdivision::SubdivisionOptions o; + if (!opts.isUndefined()) { + auto scheme = opts["scheme"]; + if (!scheme.isUndefined()) o.scheme = parse_scheme(scheme.as()); + apply_opt(opts, "numLevels", o.num_levels); + auto vbi = opts["vertexBoundaryInterpolation"]; + if (!vbi.isUndefined()) { + o.vertex_boundary_interpolation = parse_vertex_boundary(vbi.as()); + } + auto fvi = opts["faceVaryingInterpolation"]; + if (!fvi.isUndefined()) { + o.face_varying_interpolation = parse_fv_interpolation(fvi.as()); + } + apply_opt(opts, "useLimitSurface", o.use_limit_surface); + apply_opt(opts, "validateTopology", o.validate_topology); + apply_opt(opts, "preserveSharedIndices", o.preserve_shared_indices); + + // Adaptive refinement + auto ref = opts["refinement"]; + if (!ref.isUndefined()) { + auto s = ref.as(); + if (s == "edgeAdaptive") { + o.refinement = subdivision::RefinementType::EdgeAdaptive; + } + } + auto mel = opts["maxEdgeLength"]; + if (!mel.isUndefined()) o.max_edge_length = mel.as(); + auto mcd = opts["maxChordalDeviation"]; + if (!mcd.isUndefined()) o.max_chordal_deviation = mcd.as(); + } + return subdivision::subdivide_mesh(mesh, o); + }); + + function( + "midpointSubdivision", + +[](const MeshType& mesh) -> MeshType { return subdivision::midpoint_subdivision(mesh); }); + + function( + "sqrtSubdivision", + +[](const MeshType& mesh) -> MeshType { return subdivision::sqrt_subdivision(mesh); }); +} diff --git a/modules/subdivision/js/test/subdivision.test.ts b/modules/subdivision/js/test/subdivision.test.ts new file mode 100644 index 00000000..2ed08216 --- /dev/null +++ b/modules/subdivision/js/test/subdivision.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { loadLagrange } from "../src/loadLagrange.node.js"; +import type { Lagrange } from "../src/types.js"; + +var lagrange: Lagrange; + +beforeAll(async () => { + lagrange = await loadLagrange(); +}); + +describe("subdivision", () => { + test("subdivideMesh with Catmull-Clark on quad mesh", () => { + const mesh = lagrange.primitive.generateRoundedCube({ triangulate: false }); + const subdivided = lagrange.subdivision.subdivideMesh(mesh, { + scheme: "catmullClark", + numLevels: 1, + }); + expect(subdivided.getNumVertices()).toBeGreaterThan(mesh.getNumVertices()); + expect(subdivided.getNumFacets()).toBeGreaterThan(mesh.getNumFacets()); + subdivided.delete(); + mesh.delete(); + }); + + test("subdivideMesh with Loop on triangle mesh", () => { + const mesh = lagrange.primitive.generateSphere(); + const subdivided = lagrange.subdivision.subdivideMesh(mesh, { + scheme: "loop", + numLevels: 1, + }); + expect(subdivided.getNumVertices()).toBeGreaterThan(mesh.getNumVertices()); + expect(subdivided.isTriangleMesh()).toBe(true); + subdivided.delete(); + mesh.delete(); + }); + + test("subdivideMesh with default options", () => { + const mesh = lagrange.primitive.generateSphere(); + const subdivided = lagrange.subdivision.subdivideMesh(mesh, {}); + expect(subdivided.getNumVertices()).toBeGreaterThan(mesh.getNumVertices()); + subdivided.delete(); + mesh.delete(); + }); + + test("midpointSubdivision increases vertex count", () => { + const mesh = lagrange.primitive.generateIcosahedron({}); + const subdivided = lagrange.subdivision.midpointSubdivision(mesh); + expect(subdivided.getNumVertices()).toBeGreaterThan(12); + subdivided.delete(); + mesh.delete(); + }); + + test("sqrtSubdivision on triangle mesh", () => { + const mesh = lagrange.primitive.generateIcosahedron({}); + const subdivided = lagrange.subdivision.sqrtSubdivision(mesh); + expect(subdivided.getNumVertices()).toBeGreaterThan(12); + expect(subdivided.isTriangleMesh()).toBe(true); + subdivided.delete(); + mesh.delete(); + }); +}); diff --git a/modules/subdivision/js/ts/subdivision.ts b/modules/subdivision/js/ts/subdivision.ts new file mode 100644 index 00000000..721c6e16 --- /dev/null +++ b/modules/subdivision/js/ts/subdivision.ts @@ -0,0 +1,102 @@ +/** + * Refine a mesh by inserting new vertices/faces. Use for LOD up-resing, + * smoothing low-poly cages, and displacement pipelines. + * + * Omitted option fields fall back to library defaults. + */ + +import type { SurfaceMesh } from "./core.js"; + +/** + * Subdivision algorithm: + * - `"catmullClark"`: smooth quad-dominant surfaces. The standard film/DCC choice. + * - `"loop"`: smooth triangle meshes. + * - `"bilinear"`: no smoothing — each face is split into smaller copies of + * itself. Useful for displacement-map pipelines where the smoothing is + * added later by the displacement. + */ +export type SchemeType = "bilinear" | "catmullClark" | "loop"; + +/** + * - `"uniform"`: split every face the same number of times. + * - `"edgeAdaptive"`: split only where `maxEdgeLength` / `maxChordalDeviation` + * are exceeded — saves polys in flat regions. + */ +export type RefinementType = "uniform" | "edgeAdaptive"; + +/** + * How vertices on mesh boundaries are handled. + * - `"none"`: boundaries drift inward (sharp corners lost). + * - `"edgeOnly"`: boundary edges stay put, corners can round. + * - `"edgeAndCorner"`: boundary edges and corner vertices are pinned. + */ +export type VertexBoundaryInterpolation = "none" | "edgeOnly" | "edgeAndCorner"; + +/** + * How per-corner (face-varying) attributes such as UVs are interpolated. + * Progressively smoother: `"none"` keeps the most detail / sharp seams, + * `"all"` smooths everything. `"corners*"` / `"boundaries"` are intermediate + * settings mirroring OpenSubdiv's face-varying rules. + */ +export type FaceVaryingInterpolation = + | "none" + | "cornersOnly" + | "cornersPlus1" + | "cornersPlus2" + | "boundaries" + | "all"; + +export interface SubdivisionOptions { + /** + * Subdivision scheme. Omit to let the library pick based on mesh topology + * (Catmull-Clark for quads, Loop for triangles, Bilinear for pure + * displacement inputs). + */ + scheme?: SchemeType; + /** How many times to subdivide. Default: `1`. */ + numLevels?: number; + /** Uniform vs. edge-adaptive refinement. Default: `"uniform"`. */ + refinement?: RefinementType; + /** Rule for boundary vertex smoothing. Default: `"edgeOnly"`. */ + vertexBoundaryInterpolation?: VertexBoundaryInterpolation; + /** Rule for per-corner attribute smoothing (e.g. UVs). Default: `"none"`. */ + faceVaryingInterpolation?: FaceVaryingInterpolation; + /** + * Evaluate the limit surface (what the mesh converges to at infinite + * subdivision) instead of the finite-level approximation. Default: `false`. + */ + useLimitSurface?: boolean; + /** Run a topology sanity check on the input. Default: `false`. */ + validateTopology?: boolean; + /** Keep shared-index connectivity where possible. Adaptive refinement only. Default: `false`. */ + preserveSharedIndices?: boolean; + /** Edge-adaptive: refine any edge longer than this. */ + maxEdgeLength?: number; + /** Edge-adaptive: refine whenever geometry deviates more than this from the limit surface. */ + maxChordalDeviation?: number; +} + +/** + * Mesh subdivision schemes. Accessible as `lagrange.subdivision`. + */ +export interface SubdivisionModule { + /** + * Smooth subdivision via OpenSubdiv. Picks Catmull-Clark, Loop, or + * Bilinear based on {@link SubdivisionOptions.scheme} (or mesh topology + * when unset). + */ + subdivideMesh(mesh: SurfaceMesh, opts?: SubdivisionOptions): SurfaceMesh; + /** + * Split each edge at its midpoint and retriangulate. No smoothing — + * geometry is preserved. Cheap way to add resolution before displacement. + */ + midpointSubdivision(mesh: SurfaceMesh): SurfaceMesh; + /** √3 subdivision, a triangle-only refiner with good isotropy. Triangle meshes only. */ + sqrtSubdivision(mesh: SurfaceMesh): SurfaceMesh; +} + +export const subdivisionModuleKeys = [ + "subdivideMesh", + "midpointSubdivision", + "sqrtSubdivision", +] as const satisfies readonly (keyof SubdivisionModule)[]; diff --git a/modules/subdivision/src/TopologyRefinerFactory.h b/modules/subdivision/src/TopologyRefinerFactory.h index 3b8d15ae..b6dbd90c 100644 --- a/modules/subdivision/src/TopologyRefinerFactory.h +++ b/modules/subdivision/src/TopologyRefinerFactory.h @@ -15,6 +15,7 @@ #include #include #include +#include namespace OpenSubdiv { namespace OPENSUBDIV_VERSION { @@ -87,7 +88,7 @@ bool TopologyRefinerFactory::assignComponentTags( la_runtime_assert(attr.get_num_channels() == 1); la_runtime_assert( std::is_floating_point_v, - fmt::format( + lagrange::format( "Edge sharpness attribute must use a floating point type. Received: {}", lagrange::internal::value_type_name())); la_runtime_assert(attr.get_element_type() == lagrange::AttributeElement::Edge); @@ -133,7 +134,7 @@ bool TopologyRefinerFactory::assignComponentTags( la_runtime_assert(attr.get_element_type() == lagrange::AttributeElement::Vertex); la_runtime_assert( std::is_floating_point_v, - fmt::format( + lagrange::format( "Vertex sharpness attribute must use a floating point type. Received: {}", lagrange::internal::value_type_name())); if constexpr (AttributeType::IsIndexed) { @@ -159,7 +160,7 @@ bool TopologyRefinerFactory::assignComponentTags( la_runtime_assert(attr.get_num_channels() == 1); la_runtime_assert( std::is_integral_v, - fmt::format( + lagrange::format( "Face holes attribute must use an integral type. Received: {}", lagrange::internal::value_type_name())); if constexpr (AttributeType::IsIndexed) { @@ -197,7 +198,7 @@ bool TopologyRefinerFactory::assignFaceVaryingTopology( if constexpr (!AttributeType::IsIndexed) { la_runtime_assert( false, - fmt::format( + lagrange::format( "Face varying attributes must indexed attributes. Received: {}", lagrange::internal::to_string(attr.get_element_type()))); } else { diff --git a/modules/subdivision/src/subdivide_mesh.cpp b/modules/subdivision/src/subdivide_mesh.cpp index aac490c4..3f2b81e0 100644 --- a/modules/subdivision/src/subdivide_mesh.cpp +++ b/modules/subdivision/src/subdivide_mesh.cpp @@ -29,6 +29,7 @@ #include #include #include +#include // clang-format on //------------------------------------------------------------------------------ @@ -185,13 +186,12 @@ InterpolatedAttributeIds prepare_interpolated_attribute_ids( if constexpr (!(std::is_same_v || std::is_same_v)) { if (check_attribute) { - throw Error( - fmt::format( - "Interpolated attribute '{}' (id: {}) type must be float or double. " - "Received: {}", - name, - id, - lagrange::internal::value_type_name())); + throw Error(format( + "Interpolated attribute '{}' (id: {}) type must be float or double. " + "Received: {}", + name, + id, + lagrange::internal::value_type_name())); } else { logger().debug( "Skipping attribute '{}' (id: {}) with incompatible value type: {}", @@ -214,13 +214,12 @@ InterpolatedAttributeIds prepare_interpolated_attribute_ids( result.face_varying_attributes.push_back(id); } else { if (check_attribute) { - throw Error( - fmt::format( - "Requested interpolation of a attribute '{}' (id: {}), which has " - "unsupported element type '{}'.", - name, - id, - lagrange::internal::to_string(attr.get_element_type()))); + throw Error(format( + "Requested interpolation of a attribute '{}' (id: {}), which has " + "unsupported element type '{}'.", + name, + id, + lagrange::internal::to_string(attr.get_element_type()))); } else { logger().debug( "Skipping attribute '{}' (id: {}) with unsupported element type: {}", diff --git a/modules/texproc/examples/extract_mesh_with_alpha_mask.cpp b/modules/texproc/examples/extract_mesh_with_alpha_mask.cpp index 58544bf8..e683b536 100644 --- a/modules/texproc/examples/extract_mesh_with_alpha_mask.cpp +++ b/modules/texproc/examples/extract_mesh_with_alpha_mask.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -122,7 +123,7 @@ int main(int argc, char** argv) test::scene_image_to_image_array(scene.images.at(payload.image_id).image); la_runtime_assert(image.extent(2) == 4, "must have alpha channel"); const auto texcoord_id = - mesh.get_attribute_id(fmt::format("texcoord_{}", payload.texcoord_id)); + mesh.get_attribute_id(lagrange::format("texcoord_{}", payload.texcoord_id)); if (texcoord_id == lagrange::invalid_attribute_id()) continue; if (!mesh.is_attribute_indexed(texcoord_id)) continue; lagrange::texproc::ExtractMeshWithAlphaMaskOptions extract_options; diff --git a/modules/texproc/examples/io_helpers.h b/modules/texproc/examples/io_helpers.h index 41498d72..6e18f782 100644 --- a/modules/texproc/examples/io_helpers.h +++ b/modules/texproc/examples/io_helpers.h @@ -95,7 +95,7 @@ inline Array3Df load_image(const lagrange::fs::path& path) } } -inline void save_image(lagrange::fs::path path, View3Df image) +inline void save_image(lagrange::fs::path path, ConstView3Df image) { if (path.extension() != ".exr") { lagrange::logger().warn("Only .exr output files are supported. Saving as .exr."); diff --git a/modules/texproc/examples/texture_processing_gui.cpp b/modules/texproc/examples/texture_processing_gui.cpp index c88b512d..329f171b 100644 --- a/modules/texproc/examples/texture_processing_gui.cpp +++ b/modules/texproc/examples/texture_processing_gui.cpp @@ -37,6 +37,7 @@ #include #include #include +#include // clang-format on #include @@ -233,9 +234,9 @@ bool UiState::has_valid_inputs() const } template -void ImGui_FmtText(fmt::format_string text, Args&&... args) +void ImGui_FmtText(lagrange::format_string text, Args&&... args) { - ImGui::Text("%s", fmt::format(text, std::forward(args)...).c_str()); + ImGui::Text("%s", lagrange::format(text, std::forward(args)...).c_str()); } void UiState::main_panel() diff --git a/modules/texproc/examples/texture_rasterization.cpp b/modules/texproc/examples/texture_rasterization.cpp index 83a19221..f9a99abf 100644 --- a/modules/texproc/examples/texture_rasterization.cpp +++ b/modules/texproc/examples/texture_rasterization.cpp @@ -16,8 +16,11 @@ #include #include #include +#include +#include #include #include +#include #include @@ -27,13 +30,22 @@ namespace fs = lagrange::fs; fs::path make_output_path(const fs::path& base_path, size_t index) { - return base_path.parent_path() / fmt::format( + return base_path.parent_path() / lagrange::format( "{}_{:02d}{}", base_path.stem().string(), index, base_path.extension().string()); } +/* Example usage: + + ./examples/Release/texture_rasterization \ + --scene-in ../data/corp/texproc/prepared/pumpkin.glb \ + --renders-in ../data/corp/texproc/prepared/view_*.png \ + --width 1024 --height 1024 --base-confidence 0 \ + --meshes-out pumpkin.glb + +*/ int main(int argc, char** argv) { struct @@ -41,8 +53,10 @@ int main(int argc, char** argv) fs::path input_scene; fs::path input_texture; std::vector input_renders; + fs::path input_render_grid; fs::path output_textures = "output_textures.exr"; fs::path output_weights = "output_weights.exr"; + fs::path output_meshes; std::optional width; std::optional height; float low_confidence_ratio = 0.75; @@ -62,7 +76,12 @@ int main(int argc, char** argv) app.add_option("--texture-in", args.input_texture, "Override input base texture.") ->check(CLI::ExistingFile); app.add_option("--renders-in", args.input_renders, "Input rendered images.") - ->required() + ->check(CLI::ExistingFile); + app.add_option( + "--render-grid-in", + args.input_render_grid, + "Input single grid image containing all renders. " + "The grid is split based on the number of cameras in the scene.") ->check(CLI::ExistingFile); app.add_option( "--textures-out", @@ -72,6 +91,10 @@ int main(int argc, char** argv) "--weights-out", args.output_weights, "Output base name for confidence weight texture images."); + app.add_option( + "--meshes-out", + args.output_meshes, + "Output base name for textured mesh files (.obj or .glb)."); app.add_option( "--base-confidence", args.base_confidence, @@ -107,27 +130,49 @@ int main(int argc, char** argv) base_texture = load_image(args.input_texture); } - // Sort input renders - sort_paths(args.input_renders); + la_runtime_assert( + !args.input_renders.empty() || !args.input_render_grid.empty(), + "Either --renders-in or --render-grid-in must be provided"); + la_runtime_assert( + args.input_renders.empty() || args.input_render_grid.empty(), + "--renders-in and --render-grid-in are mutually exclusive"); - // 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()); - } + std::vector> textures_and_weights; + 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); - // Rasterize textures from renders - auto textures_and_weights = lagrange::texproc::rasterize_textures_from_renders( - scene, - base_texture, - views, - args.width, - args.height, - args.low_confidence_ratio, - args.base_confidence); + 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); + } 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); + } // Save textures and confidences tbb::parallel_for(size_t(0), textures_and_weights.size(), [&](size_t i) { @@ -139,5 +184,23 @@ int main(int argc, char** argv) save_image(output_weight, textures_and_weights[i].second.to_mdspan()); }); + // Save textured meshes (one per view) + if (!args.output_meshes.empty()) { + auto mesh = lagrange::scene::scene_to_mesh(scene); + lagrange::io::SaveOptions save_options; + save_options.encoding = lagrange::io::FileEncoding::Binary; + save_options.embed_images = true; + save_options.attribute_conversion_policy = + lagrange::io::SaveOptions::AttributeConversionPolicy::ConvertAsNeeded; + for (size_t i = 0; i < textures_and_weights.size(); ++i) { + fs::path output_mesh = make_output_path(args.output_meshes, i); + lagrange::logger().info("Saving textured mesh: {}", output_mesh.string()); + auto output_scene = lagrange::scene::internal::single_mesh_to_scene( + mesh, + textures_and_weights[i].first.to_mdspan()); + lagrange::io::save_scene(output_mesh, output_scene, save_options); + } + } + return 0; } diff --git a/modules/texproc/shared/shared_utils.h b/modules/texproc/shared/shared_utils.h index e2227045..85b87316 100644 --- a/modules/texproc/shared/shared_utils.h +++ b/modules/texproc/shared/shared_utils.h @@ -20,11 +20,11 @@ #include #include #include +#include #include namespace lagrange::texproc { - using scene::internal::Array3Df; using scene::internal::ConstView3Df; using scene::internal::View3Df; @@ -148,8 +148,9 @@ std::vector> rasterize_textures_from_renders( rasterizer_options.height = base_image.extent(1); la_runtime_assert( renders.front().extent(2) == base_image.extent(2), - fmt::format( - "Input render image num channels (={}) must match base texture num channels (={})", + format( + "Input render image num channels (={}) must match base texture num channels " + "(={})", renders.front().extent(2), base_image.extent(2))); lagrange::logger().info( @@ -177,4 +178,104 @@ std::vector> rasterize_textures_from_renders( return textures_and_weights; } +/// +/// 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. +/// +template +std::vector> rasterize_textures_from_renders( + const lagrange::scene::Scene& scene, + std::optional base_texture_in, + const ConstView3Df& render_grid, + 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); + + // 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); + } + } + + return rasterize_textures_from_renders( + scene, + std::move(base_texture_in), + views, + tex_width, + tex_height, + low_confidence_ratio, + base_confidence); +} + } // namespace lagrange::texproc diff --git a/modules/texproc/src/TextureRasterizer.cpp b/modules/texproc/src/TextureRasterizer.cpp index 59239857..07c4200b 100644 --- a/modules/texproc/src/TextureRasterizer.cpp +++ b/modules/texproc/src/TextureRasterizer.cpp @@ -14,11 +14,14 @@ #include "mesh_utils.h" +#include + // clang-format off #include #include #include #include +#include // clang-format on #include @@ -40,6 +43,7 @@ struct CameraParameters Eigen::Affine3d view_from_world = Eigen::Affine3d::Identity(); // world -> view Eigen::Projective3d ndc_from_view = Eigen::Projective3d::Identity(); // world -> ndc Eigen::Affine2d screen_from_ndc = Eigen::Affine2d::Identity(); // ndc -> screen + bool is_orthographic = false; CameraParameters( const Eigen::Affine3d& view_from_world_, @@ -51,6 +55,24 @@ struct CameraParameters res[1] = height; view_from_world = view_from_world_; + // Detect orthographic vs perspective from the last row of the projection matrix. + // Perspective: row 3 = [0, 0, -1, 0] → P(3,2) = -1, P(3,3) = 0 + // Orthographic: row 3 = [0, 0, 0, 1] → P(3,2) = 0, P(3,3) = 1 + { + const auto& P = ndc_from_view_.matrix(); + const double p32 = P(3, 2); + const double p33 = P(3, 3); + is_orthographic = (std::abs(p33 - 1.0) < 1e-8 && std::abs(p32) < 1e-8); + la_runtime_assert( + is_orthographic || (std::abs(p33) < 1e-8 && std::abs(p32 + 1.0) < 1e-8), + "Projection matrix does not match perspective or orthographic convention"); + logger().debug( + "Camera projection: {} (P(3,2)={}, P(3,3)={})", + is_orthographic ? "orthographic" : "perspective", + p32, + p33); + } + // Remap depth from [-1, 1] to [0, 1] to improve numerical precision // https://www.reedbeta.com/blog/depth-precision-visualized/ ndc_from_view = Eigen::Scaling(1., 1., 0.5) * Eigen::Translation3d(0, 0, 1) * @@ -318,13 +340,22 @@ struct TextureAndConfidenceFromRender auto npos_screen = Texels::NodePosition(I); Eigen::Vector2d npos_ndc = ndc_from_screen * Eigen::Vector2d(npos_screen[0], npos_screen[1]); + // Unproject two points along the ray (near and far planes in NDC) + // to construct a ray that works for both perspective and orthographic cameras. + const double znear_ndc = 1.0; const double zfar_ndc = 0.0; - Eigen::Vector3d npos_view = + Eigen::Vector3d near_view = + (view_from_ndc * + Eigen::Vector3d(npos_ndc[0], npos_ndc[1], znear_ndc).homogeneous()) + .hnormalized(); + Eigen::Vector3d far_view = (view_from_ndc * Eigen::Vector3d(npos_ndc[0], npos_ndc[1], zfar_ndc).homogeneous()) .hnormalized(); + Eigen::Vector3d dir = far_view - near_view; Ray ray; - ray.direction = Vector(npos_view[0], npos_view[1], npos_view[2]); + ray.position = Vector(near_view[0], near_view[1], near_view[2]); + ray.direction = Vector(dir[0], dir[1], dir[2]); std::pair> _bc = c_tri.barycentricCoordinates(ray); Vector bc = _bc.second; Vector p_c = c_tri[0] * bc[0] + c_tri[1] * bc[1] + c_tri[2] * bc[2]; @@ -521,7 +552,11 @@ struct TextureAndConfidenceFromRender // Set the cumulative confidence based on the depth and normal confidence information { - Vector camera_position = camera_params.camera_position_world(); + // Cotransform (cofactor matrix) for transforming normals from world to view space. + // This correctly handles non-rigid transforms (scale/shear), unlike using the + // linear part directly. + Eigen::Matrix3d normal_matrix = + compute_normal_cotransform(Eigen::Affine3d(camera_params.view_from_world)); DepthMapWrapper depth_map(depth); @@ -531,28 +566,34 @@ struct TextureAndConfidenceFromRender // The position of the texel in world coordinates Vector p_w = world_texel->first; - // The normal of the texel in world coordinates - Vector n = world_texel->second; - n /= Vector::Length(n); + // The normal of the texel in view space + Eigen::Vector3d n_world( + world_texel->second[0], + world_texel->second[1], + world_texel->second[2]); + Eigen::Vector3d n_view = (normal_matrix * n_world).stableNormalized(); - // The direction from the camera to the world position of the texel - Vector dir = p_w - camera_position; - dir /= Vector::Length(dir); - - // The projection of the texl in the rendering + // The projection of the texel in the rendering Vector q = camera_params(p_w); - // The position of the texel in the camera coordinate system + // The position of the texel in view space Vector p_c = camera_params.world_to_view(p_w); const double z = -p_c[2]; // depth is -z in view space // The depth map at the texel double d = depth_map(q); - // The normal confidence based on the alignment of the normal with the camera's + // For perspective cameras, the view direction is the normalized position + // in view space (rays converge to origin). For orthographic cameras, all + // rays are parallel to -Z in view space. + Eigen::Vector3d dir_view = + camera_params.is_orthographic + ? Eigen::Vector3d(0, 0, -1) + : Eigen::Vector3d(p_c[0], p_c[1], p_c[2]).stableNormalized(); + + // The normal confidence based on the alignment of the normal with the // view direction - [[maybe_unused]] double normal_confidence = - fabs(Vector::Dot(n, dir)); + [[maybe_unused]] double normal_confidence = fabs(n_view.dot(dir_view)); // If the texel is visible, set the confidence to the product of the depth and // normal confidences @@ -667,10 +708,9 @@ auto TextureRasterizer::weighted_texture_from_render( options, m_impl->options); default: - throw Error( - fmt::format( - "Only 1, 2, 3, or 4 channels supported. Input render image has {} channels.", - num_channels)); + throw Error(format( + "Only 1, 2, 3, or 4 channels supported. Input render image has {} channels.", + num_channels)); } } diff --git a/modules/texproc/src/mesh_utils.h b/modules/texproc/src/mesh_utils.h index 36dadfa6..c0973622 100644 --- a/modules/texproc/src/mesh_utils.h +++ b/modules/texproc/src/mesh_utils.h @@ -40,6 +40,8 @@ using namespace lagrange::texproc::threadpool; #include #include #include +#include +#include // clang-format on #include @@ -213,14 +215,13 @@ void check_for_flipped_uv(const SurfaceMesh& mesh, AttributeId id 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( - fmt::format( - "The input mesh has flipped UVs:\n p0=({:.3g})\n p1=({:.3g})\n p2=(" - "{:.3g})\n" - "Please fix the input mesh before proceeding.", - fmt::join(p0, ", "), - fmt::join(p1, ", "), - fmt::join(p2, ", "))); + 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, ", "))); } } } diff --git a/modules/texproc/src/texture_compositing.cpp b/modules/texproc/src/texture_compositing.cpp index c784bbcb..4b8903ad 100644 --- a/modules/texproc/src/texture_compositing.cpp +++ b/modules/texproc/src/texture_compositing.cpp @@ -15,12 +15,14 @@ #include "mesh_utils.h" #include +#include // clang-format off #include #include #include #include +#include // clang-format on #include @@ -49,6 +51,9 @@ image::experimental::Array3D texture_compositing( RegularGrid> weights; }; + VerboseTimer timer("[compositing] "); + + timer.tick(); auto wrapper = mesh_utils::create_mesh_wrapper(mesh, RequiresIndexedTexcoords::Yes, CheckFlippedUV::Yes); std::vector in(textures.size()); @@ -73,6 +78,7 @@ image::experimental::Array3D texture_compositing( height += padding.height(); out.resize(width, height); + timer.tock("preprocessing"); // Construct the hierarchical gradient domain object // TODO: Compute number of levels based on texture size @@ -82,6 +88,7 @@ image::experimental::Array3D texture_compositing( #else const bool sanity_check = false; #endif + timer.tick(); HierarchicalGradientDomain> hgd( options.quadrature_samples, wrapper.num_simplices(), @@ -96,6 +103,25 @@ image::experimental::Array3D texture_compositing( options.solver.num_multigrid_levels, normalize, sanity_check); + timer.tock("hierarchy construction"); + + logger().debug( + "[compositing] mesh: {} triangles, {} vertices, {} texcoords", + wrapper.num_simplices(), + wrapper.num_vertices(), + wrapper.num_texcoords()); + logger().debug( + "[compositing] texture: {}x{} ({} channels), {} views", + width, + height, + NumChannels, + textures.size()); + logger().debug( + "[compositing] solver: {} nodes, {} edges, {} multigrid levels, {} v-cycles", + hgd.numNodes(), + hgd.numEdges(), + options.solver.num_multigrid_levels, + options.solver.num_v_cycles); // Get the pointers to the solver constraints and solution span> x{hgd.x(), hgd.numNodes()}; @@ -123,6 +149,7 @@ image::experimental::Array3D texture_compositing( }; // Compute the weighted sum of texture values + timer.tick(); for (size_t n = 0; n < hgd.numNodes(); n++) { auto [row, col] = hgd.node(n); @@ -132,6 +159,8 @@ image::experimental::Array3D texture_compositing( } } + timer.tock("weighted sum"); + // Set unobserved texels to the average observed color { Vector avg_observed_color; @@ -163,6 +192,7 @@ image::experimental::Array3D texture_compositing( } // Construct the constraints + timer.tick(); { std::vector> value_b(hgd.numNodes()), gradient_b(hgd.numNodes()); @@ -203,20 +233,27 @@ image::experimental::Array3D texture_compositing( } } + timer.tock("constraint assembly"); + // Compute the system matrix + timer.tick(); const double gradient_weight = 1.0; hgd.updateSystem(options.value_weight, gradient_weight); + timer.tock("system update"); // Relax the solution + timer.tick(); for (unsigned int v = 0; v < options.solver.num_v_cycles; ++v) { hgd.vCycle(options.solver.num_gauss_seidel_iterations); } + timer.tock("v-cycles"); if (options.clamp_to_range.has_value()) { mesh_utils::clamp_out_of_range(x, hgd, options.clamp_to_range.value()); } // Put the texel values back into the texture + timer.tick(); for (size_t n = 0; n < hgd.numNodes(); n++) { std::pair coords = hgd.node(n); out(coords.first, coords.second) = x[n]; @@ -239,6 +276,7 @@ image::experimental::Array3D texture_compositing( } } } + timer.tock("postprocessing"); return composite; } @@ -257,15 +295,14 @@ image::experimental::Array3D texture_compositing( if (texture.texture.extent(0) != textures[0].texture.extent(0) || texture.texture.extent(1) != textures[0].texture.extent(1) || texture.texture.extent(2) != textures[0].texture.extent(2)) { - throw std::runtime_error( - fmt::format( - "All textures must have the same dimensions: {}x{}x{} vs {}x{}x{}", - texture.texture.extent(0), - texture.texture.extent(1), - texture.texture.extent(2), - textures[0].texture.extent(0), - textures[0].texture.extent(1), - textures[0].texture.extent(2))); + throw std::runtime_error(format( + "All textures must have the same dimensions: {}x{}x{} vs {}x{}x{}", + texture.texture.extent(0), + texture.texture.extent(1), + texture.texture.extent(2), + textures[0].texture.extent(0), + textures[0].texture.extent(1), + textures[0].texture.extent(2))); } if (texture.weights.extent(0) != textures[0].weights.extent(0) || texture.weights.extent(1) != textures[0].weights.extent(1)) { diff --git a/modules/texproc/tests/test_mesh_with_alpha_mask.cpp b/modules/texproc/tests/test_mesh_with_alpha_mask.cpp index a5182834..df04d192 100644 --- a/modules/texproc/tests/test_mesh_with_alpha_mask.cpp +++ b/modules/texproc/tests/test_mesh_with_alpha_mask.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -50,8 +51,8 @@ void run_mesh_with_alpha_mask(const lagrange::fs::path& path) // retrieve mesh auto mesh = scene.meshes.at(instance.mesh); - const auto texcoord_id = - mesh.get_attribute_id(fmt::format("texcoord_{}", material.base_color_texture.texcoord)); + const auto texcoord_id = mesh.get_attribute_id( + lagrange::format("texcoord_{}", material.base_color_texture.texcoord)); REQUIRE(texcoord_id != lagrange::invalid_attribute_id()); REQUIRE(mesh.is_attribute_indexed(texcoord_id)); REQUIRE(mesh.is_triangle_mesh()); diff --git a/modules/texproc/tests/test_texture_filtering.cpp b/modules/texproc/tests/test_texture_filtering.cpp index b6bfecee..73b963e8 100644 --- a/modules/texproc/tests/test_texture_filtering.cpp +++ b/modules/texproc/tests/test_texture_filtering.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -68,7 +69,7 @@ TEST_CASE("texture filtering", "[texproc][filtering]" LA_SLOW_DEBUG_FLAG) auto subfolder = get_platform_subfolder(); auto expected = load_image( lagrange::testing::get_data_path( - fmt::format("open/texproc/{}/blub_smooth.exr", subfolder))); + lagrange::format("open/texproc/{}/blub_smooth.exr", subfolder))); require_approx_mdspan(img.to_mdspan(), expected.to_mdspan()); } @@ -85,7 +86,7 @@ TEST_CASE("texture filtering", "[texproc][filtering]" LA_SLOW_DEBUG_FLAG) auto subfolder = get_platform_subfolder(); auto expected = load_image( lagrange::testing::get_data_path( - fmt::format("open/texproc/{}/blub_sharp.exr", subfolder))); + lagrange::format("open/texproc/{}/blub_sharp.exr", subfolder))); require_approx_mdspan(img.to_mdspan(), expected.to_mdspan()); } } diff --git a/modules/texproc/tests/test_texture_processing.cpp b/modules/texproc/tests/test_texture_processing.cpp index 1fee47bb..749a7812 100644 --- a/modules/texproc/tests/test_texture_processing.cpp +++ b/modules/texproc/tests/test_texture_processing.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -100,7 +101,7 @@ TEST_CASE("Grid bounds", "[texproc]" LA_SLOW_DEBUG_FLAG LA_CORP_FLAG) for (const auto kk : lagrange::range(cameras.size())) { views.emplace_back(load_image( lagrange::testing::get_data_path( - fmt::format("corp/texproc/segfault/render_{:d}.exr", kk)))); + lagrange::format("corp/texproc/segfault/render_{:d}.exr", kk)))); } REQUIRE(cameras.size() == views.size()); @@ -133,7 +134,7 @@ TEST_CASE("Pumpkin pipeline", "[texproc]" LA_SLOW_DEBUG_FLAG LA_CORP_FLAG) for (const auto kk : lagrange::range(cameras.size())) { views.emplace_back(load_image( lagrange::testing::get_data_path( - fmt::format("corp/texproc/prepared/view_{:02d}.png", kk)))); + lagrange::format("corp/texproc/prepared/view_{:02d}.png", kk)))); } REQUIRE(cameras.size() == views.size()); @@ -174,7 +175,7 @@ TEST_CASE("Check benchmark", "[texproc][!benchmark]" LA_CORP_FLAG) for (const auto kk : lagrange::range(cameras.size())) { views.emplace_back(load_image( lagrange::testing::get_data_path( - fmt::format("corp/texproc/prepared/view_{:02d}.png", kk)))); + lagrange::format("corp/texproc/prepared/view_{:02d}.png", kk)))); } REQUIRE(cameras.size() == views.size()); diff --git a/modules/texproc/tests/test_texture_rasterizer.cpp b/modules/texproc/tests/test_texture_rasterizer.cpp new file mode 100644 index 00000000..fac7d7e7 --- /dev/null +++ b/modules/texproc/tests/test_texture_rasterizer.cpp @@ -0,0 +1,297 @@ +/* + * 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 + +using namespace lagrange; +using namespace lagrange::texproc; +using Array3Df = image::experimental::Array3D; + +namespace { + +// Create a unit-square quad (two triangles) in the XY plane with identity UV mapping. +// Vertices: (0,0,0), (1,0,0), (1,1,0), (0,1,0) +// UVs match vertex XY positions: (0,0), (1,0), (1,1), (0,1) +SurfaceMesh32f create_quad_mesh() +{ + SurfaceMesh32f 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_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + + mesh.create_attribute("uv", AttributeElement::Vertex, 2, AttributeUsage::UV); + attribute_matrix_ref(mesh, "uv") = vertex_view(mesh).leftCols<2>(); + map_attribute_in_place(mesh, "uv", AttributeElement::Indexed); + + return mesh; +} + +// Create a solid red render image of the given size. +Array3Df create_solid_render(size_t width, size_t height) +{ + auto img = image::experimental::create_image(width, height, 3); + for (size_t x = 0; x < width; ++x) { + for (size_t y = 0; y < height; ++y) { + img(x, y, 0) = 1.0f; + img(x, y, 1) = 0.0f; + img(x, y, 2) = 0.0f; + } + } + return img; +} + +// Build a look-at view matrix (world -> view). +Eigen::Affine3f look_at(Eigen::Vector3f eye, Eigen::Vector3f center, Eigen::Vector3f up) +{ + Eigen::Vector3d forward = (center - eye).cast().normalized(); + Eigen::Vector3d right = forward.cross(up.cast().normalized()).normalized(); + Eigen::Vector3d upwards = right.cross(forward); + + Eigen::Affine3d V = Eigen::Affine3d::Identity(); + V.linear() << right, upwards, -forward; + V.translation() << -right.dot(eye.cast()), -upwards.dot(eye.cast()), + forward.dot(eye.cast()); + return V.cast(); +} + +// Build a finite perspective projection matrix. +Eigen::Projective3f perspective(float fovy, float aspect, float z_near, float z_far) +{ + const double tan_half = std::tan(static_cast(fovy) / 2.0); + Eigen::Matrix4d P = Eigen::Matrix4d::Zero(); + P(0, 0) = 1.0 / (aspect * tan_half); + P(1, 1) = 1.0 / tan_half; + P(2, 2) = -(z_far + z_near) / (z_far - z_near); + P(3, 2) = -1.0; + P(2, 3) = -(2.0 * z_far * z_near) / (z_far - z_near); + return Eigen::Projective3d(P).cast(); +} + +// Build an orthographic projection matrix. +Eigen::Projective3f ortho(float width, float aspect, float z_near, float z_far) +{ + const double w = width; + const double h = w / aspect; + Eigen::Matrix4d P = Eigen::Matrix4d::Identity(); + P(0, 0) = 2.0 / w; + P(1, 1) = 2.0 / h; + P(2, 2) = -2.0 / (z_far - z_near); + P(0, 3) = 0; // symmetric + P(1, 3) = 0; + P(2, 3) = -(static_cast(z_far) + z_near) / (z_far - z_near); + return Eigen::Projective3d(P).cast(); +} + +// Sum all confidence values in a single-channel weight image. +double total_confidence(const Array3Df& weights) +{ + double sum = 0; + for (size_t x = 0; x < weights.extent(0); ++x) { + for (size_t y = 0; y < weights.extent(1); ++y) { + sum += weights(x, y, 0); + } + } + return sum; +} + +// Count texels with non-zero confidence. +size_t count_nonzero(const Array3Df& weights) +{ + size_t count = 0; + for (size_t x = 0; x < weights.extent(0); ++x) { + for (size_t y = 0; y < weights.extent(1); ++y) { + if (weights(x, y, 0) > 0) ++count; + } + } + return count; +} + +} // namespace + +TEST_CASE("TextureRasterizer perspective camera", "[texproc]") +{ + auto mesh = create_quad_mesh(); + + constexpr size_t tex_size = 64; + constexpr size_t render_size = 64; + + TextureRasterizerOptions opts; + opts.width = tex_size; + opts.height = tex_size; + 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( + static_cast(2.0 * std::atan(0.5 / 2.0)), // fov to see ~unit width at z=2 + 1.0f, + 0.1f, + 10.0f); + + auto render = create_solid_render(render_size, render_size); + auto [texture, weights] = rasterizer.weighted_texture_from_render(render, camera); + + REQUIRE(texture.extent(0) == tex_size); + REQUIRE(texture.extent(1) == tex_size); + REQUIRE(weights.extent(0) == tex_size); + REQUIRE(weights.extent(1) == tex_size); + + // Should have significant coverage with non-zero confidence + size_t nonzero = count_nonzero(weights); + REQUIRE(nonzero > 0); + CHECK(total_confidence(weights) > 0.0); + + // Visible texels should be red + for (size_t x = 0; x < tex_size; ++x) { + for (size_t y = 0; y < tex_size; ++y) { + if (weights(x, y, 0) > 0) { + CHECK(texture(x, y, 0) > 0.5f); // red channel + } + } + } +} + +TEST_CASE("TextureRasterizer orthographic camera", "[texproc]") +{ + auto mesh = create_quad_mesh(); + + constexpr size_t tex_size = 64; + constexpr size_t render_size = 64; + + TextureRasterizerOptions opts; + opts.width = tex_size; + opts.height = tex_size; + 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( + 1.5f, // width large enough to see the whole quad + 1.0f, + 0.1f, + 10.0f); + + auto render = create_solid_render(render_size, render_size); + auto [texture, weights] = rasterizer.weighted_texture_from_render(render, camera); + + REQUIRE(texture.extent(0) == tex_size); + REQUIRE(texture.extent(1) == tex_size); + REQUIRE(weights.extent(0) == tex_size); + REQUIRE(weights.extent(1) == tex_size); + + // Should have significant coverage with non-zero confidence + size_t nonzero = count_nonzero(weights); + REQUIRE(nonzero > 0); + CHECK(total_confidence(weights) > 0.0); + + // Visible texels should be red + for (size_t x = 0; x < tex_size; ++x) { + for (size_t y = 0; y < tex_size; ++y) { + if (weights(x, y, 0) > 0) { + CHECK(texture(x, y, 0) > 0.5f); + } + } + } +} + +TEST_CASE("TextureRasterizer ortho vs perspective confidence consistency", "[texproc]") +{ + // For a quad viewed head-on, orthographic and perspective cameras should produce + // similar confidence patterns (high confidence in the center). + auto mesh = create_quad_mesh(); + + constexpr size_t tex_size = 64; + constexpr size_t render_size = 64; + + TextureRasterizerOptions opts; + opts.width = tex_size; + opts.height = tex_size; + TextureRasterizer rasterizer(mesh, opts); + + Eigen::Vector3f eye(0.5f, 0.5f, 2.0f); + Eigen::Vector3f center(0.5f, 0.5f, 0.0f); + 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 = + 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); + + auto render = create_solid_render(render_size, render_size); + auto [persp_tex, persp_w] = rasterizer.weighted_texture_from_render(render, persp_camera); + auto [ortho_tex, ortho_w] = rasterizer.weighted_texture_from_render(render, ortho_camera); + (void)persp_tex; + (void)ortho_tex; + + size_t persp_nonzero = count_nonzero(persp_w); + size_t ortho_nonzero = count_nonzero(ortho_w); + + // Both should have significant coverage + REQUIRE(persp_nonzero > 0); + REQUIRE(ortho_nonzero > 0); + + // For a head-on view, orthographic confidence should be very uniform (close to 1.0 + // for all visible texels, since all view directions are parallel to the normal). + double ortho_total = total_confidence(ortho_w); + double ortho_avg = ortho_total / ortho_nonzero; + // Ortho head-on: normal_confidence = |dot(n, (0,0,-1))| = 1.0 for all visible texels. + // Average is reduced by depth discontinuity erosion near mesh boundaries, but should still + // be meaningfully positive. + CHECK(ortho_avg > 0.1); +} + +TEST_CASE("TextureRasterizer invalid projection matrix", "[texproc]") +{ + auto mesh = create_quad_mesh(); + + constexpr size_t tex_size = 16; + constexpr size_t render_size = 16; + + TextureRasterizerOptions opts; + opts.width = tex_size; + opts.height = tex_size; + TextureRasterizer rasterizer(mesh, opts); + + // Build a projection matrix that doesn't match either convention: + // row 3 = [0, 0, 0.5, 0.5] — neither perspective nor orthographic. + Eigen::Matrix4f P = Eigen::Matrix4f::Identity(); + 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); + + auto render = create_solid_render(render_size, render_size); + LA_REQUIRE_THROWS(rasterizer.weighted_texture_from_render(render, camera)); +} diff --git a/modules/texproc/tests/test_texture_stitching.cpp b/modules/texproc/tests/test_texture_stitching.cpp index 5fa5e481..25ef82ef 100644 --- a/modules/texproc/tests/test_texture_stitching.cpp +++ b/modules/texproc/tests/test_texture_stitching.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -104,7 +105,7 @@ TEST_CASE("texture stitching", "[texproc][stitching]" LA_SLOW_DEBUG_FLAG) auto subfolder = get_platform_subfolder(); auto expected = load_image( lagrange::testing::get_data_path( - fmt::format("open/texproc/{}/blub_stitched.exr", subfolder))); + lagrange::format("open/texproc/{}/blub_stitched.exr", subfolder))); require_approx_mdspan(img.to_mdspan(), expected.to_mdspan()); } @@ -121,7 +122,7 @@ TEST_CASE("texture stitching", "[texproc][stitching]" LA_SLOW_DEBUG_FLAG) auto subfolder = get_platform_subfolder(); auto expected = load_image( lagrange::testing::get_data_path( - fmt::format("open/texproc/{}/blub_stitched_rnd.exr", subfolder))); + lagrange::format("open/texproc/{}/blub_stitched_rnd.exr", subfolder))); require_approx_mdspan(img.to_mdspan(), expected.to_mdspan()); } } diff --git a/modules/ui/src/panels/LoggerPanel.cpp b/modules/ui/src/panels/LoggerPanel.cpp index eaa08fb1..774e296a 100644 --- a/modules/ui/src/panels/LoggerPanel.cpp +++ b/modules/ui/src/panels/LoggerPanel.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -77,7 +78,7 @@ class SpdlogUISink : public spdlog::sinks::base_sink if (m_data.data.size() > LOGUI_LIMIT) { m_data.data.pop_front(); } - m_data.data.emplace_back(color, fmt::to_string(formatted)); + m_data.data.emplace_back(color, std::string(formatted.data(), formatted.size())); } void flush_() override diff --git a/modules/ui/src/types/Camera.cpp b/modules/ui/src/types/Camera.cpp index 25a7ff24..c8ce0f7a 100644 --- a/modules/ui/src/types/Camera.cpp +++ b/modules/ui/src/types/Camera.cpp @@ -330,73 +330,8 @@ void Camera::rotate_turntable(float yaw_delta, float pitch_delta, Eigen::Vector3 } -void Camera::rotate_arcball( - const Eigen::Vector3f& camera_pos_start, - const Eigen::Vector3f& camera_up_start, - const Eigen::Vector2f& mouse_start, - const Eigen::Vector2f& mouse_current) -{ - // No change of camera - if (mouse_start.x() == mouse_current.x() && mouse_start.y() == mouse_current.y()) return; - - - const auto map_to_sphere = [&](const Eigen::Vector2f& pos) -> Eigen::Vector3f { - Eigen::Vector3f p = Eigen::Vector3f::Zero(); - // Map to fullscreen ellipse - p.x() = (2 * pos.x() / get_window_width() - 1.0f); - p.y() = (2 * (pos.y() / get_window_height()) - 1.0f); - - float lensq = p.x() * p.x() + p.y() * p.y(); - - if (lensq <= 1.0f) { - p.z() = std::sqrt(1 - lensq); - } else { - p = p.normalized(); - } - return p; - }; - - const auto decompose = - [](const Eigen::Matrix4f& m, Eigen::Vector3f& T, Eigen::Matrix3f& R, Eigen::Vector3f& S) { - T = m.col(3).head<3>(); - S = Eigen::Vector3f((m.col(0).norm()), (m.col(1).norm()), (m.col(2).norm())); - R.col(0) = m.col(0).head<3>() * (1.0f / S(0)); - R.col(1) = m.col(1).head<3>() * (1.0f / S(1)); - R.col(2) = m.col(2).head<3>() * (1.0f / S(2)); - }; - - - //Calculate points on sphere and rotation axis/angle - const Eigen::Vector3f p0 = map_to_sphere(mouse_start); - const Eigen::Vector3f p1 = map_to_sphere(mouse_current); - - // Axis is in default coord system - const Eigen::Vector3f axis = p0.cross(p1).normalized(); - const float angle = vector_angle(p0, p1); - - // Initial rotation - const Eigen::Matrix4f r_0 = look_at(camera_pos_start, m_lookat, camera_up_start); - // Rotate axis to current frame and rotate around it - const Eigen::Vector3f rotated_axis = (r_0.inverse().block<3, 3>(0, 0) * axis); - Eigen::Matrix4f r_arc = Eigen::Matrix4f::Identity(); - r_arc.block<3, 3>(0, 0) = Eigen::AngleAxisf(angle, rotated_axis).matrix(); - - // Get inverse new view matrix - const Eigen::Matrix4f r = (r_0 * r_arc).inverse().matrix(); - - Eigen::Vector3f new_pos, new_scale; - Eigen::Matrix3f r_decomp; - - // Decompose new position and new rotation - decompose(r, new_pos, r_decomp, new_scale); - - // Rotate default up axis - const Eigen::Vector3f up = r_decomp * Eigen::Vector3f(0, 1, 0); - - // Set new camera properties - set_position(new_pos); - set_up(up); -} +// rotate_arcball() moved to Camera_xcode264_workaround.cpp +// to work around Xcode 26.4 compiler bug void Camera::zoom(float delta) diff --git a/modules/ui/src/types/Camera_xcode264_workaround.cpp b/modules/ui/src/types/Camera_xcode264_workaround.cpp new file mode 100644 index 00000000..9c086c66 --- /dev/null +++ b/modules/ui/src/types/Camera_xcode264_workaround.cpp @@ -0,0 +1,124 @@ +/* + * Copyright 2019 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. + */ + +// Workaround for Xcode 26.4 compiler bug +// The VectorCombine pass in clang 21.0.0 crashes when compiling this function +// for x86_64 with optimization enabled. Disabling optimizations for this file +// prevents the crash. This works across all build systems (CMake, Make, etc.) +// +// Bug: VectorCombine::foldSelectShuffle() segfaults on (r_0 * r_arc).inverse() +// Affects: Apple Clang (Xcode 26.4, __apple_build_version__ 21000000-21999999), x86_64 only +// +// Why this function is in a separate file with file-level #pragma: +// - Localized #pragma around the function (function-level) does NOT work +// - The crash happens during Eigen template instantiation, which occurs during +// the compiler's code generation phase, BEFORE function-level optimization +// directives are applied +// - The VectorCombine pass runs at the translation-unit level, not function level +// - Therefore, the #pragma must be at FILE level to disable the pass for the +// entire compilation unit containing the problematic template instantiations +// +// TODO: Remove this file when Apple fixes the bug in future Xcode release + +#include + +// Only disable optimizations for the specific problematic configuration +// Note: __apple_build_version__ is only defined by Apple Clang, not Homebrew LLVM +#if LAGRANGE_TARGET_OS(APPLE) && LAGRANGE_TARGET_PLATFORM(x86_64) && \ + defined(__apple_build_version__) && __apple_build_version__ >= 21000000 && \ + __apple_build_version__ < 22000000 + #pragma clang optimize off +#endif + +#include +#include + +#include +namespace lagrange { +namespace ui { + +void Camera::rotate_arcball( + const Eigen::Vector3f& camera_pos_start, + const Eigen::Vector3f& camera_up_start, + const Eigen::Vector2f& mouse_start, + const Eigen::Vector2f& mouse_current) +{ + // No change of camera + if (mouse_start.x() == mouse_current.x() && mouse_start.y() == mouse_current.y()) return; + + const auto map_to_sphere = [&](const Eigen::Vector2f& pos) -> Eigen::Vector3f { + Eigen::Vector3f p = Eigen::Vector3f::Zero(); + // Map to fullscreen ellipse + p.x() = (2 * pos.x() / get_window_width() - 1.0f); + p.y() = (2 * (pos.y() / get_window_height()) - 1.0f); + + float lensq = p.x() * p.x() + p.y() * p.y(); + + if (lensq <= 1.0f) { + p.z() = std::sqrt(1 - lensq); + } else { + p = p.normalized(); + } + return p; + }; + + const auto decompose = + [](const Eigen::Matrix4f& m, Eigen::Vector3f& T, Eigen::Matrix3f& R, Eigen::Vector3f& S) { + T = m.col(3).head<3>(); + S = Eigen::Vector3f((m.col(0).norm()), (m.col(1).norm()), (m.col(2).norm())); + R.col(0) = m.col(0).head<3>() * (1.0f / S(0)); + R.col(1) = m.col(1).head<3>() * (1.0f / S(1)); + R.col(2) = m.col(2).head<3>() * (1.0f / S(2)); + }; + + // Calculate points on sphere and rotation axis/angle + const Eigen::Vector3f p0 = map_to_sphere(mouse_start); + const Eigen::Vector3f p1 = map_to_sphere(mouse_current); + + // Axis is in default coord system + const Eigen::Vector3f axis = p0.cross(p1).normalized(); + const float angle = vector_angle(p0, p1); + + // Initial rotation + const Eigen::Matrix4f r_0 = look_at(camera_pos_start, m_lookat, camera_up_start); + + // Rotate axis to current frame and rotate around it + const Eigen::Vector3f rotated_axis = (r_0.inverse().block<3, 3>(0, 0) * axis); + Eigen::Matrix4f r_arc = Eigen::Matrix4f::Identity(); + r_arc.block<3, 3>(0, 0) = Eigen::AngleAxisf(angle, rotated_axis).matrix(); + + // Get inverse new view matrix + const Eigen::Matrix4f r = (r_0 * r_arc).inverse().matrix(); + + Eigen::Vector3f new_pos, new_scale; + Eigen::Matrix3f r_decomp; + + // Decompose new position and new rotation + decompose(r, new_pos, r_decomp, new_scale); + + // Rotate default up axis + const Eigen::Vector3f up = r_decomp * Eigen::Vector3f(0, 1, 0); + + // Set new camera properties + set_position(new_pos); + set_up(up); +} + +} // namespace ui +} // namespace lagrange + +// Re-enable optimizations if they were disabled +#if LAGRANGE_TARGET_OS(APPLE) && LAGRANGE_TARGET_PLATFORM(x86_64) && \ + defined(__apple_build_version__) && __apple_build_version__ >= 21000000 && \ + __apple_build_version__ < 22000000 + #pragma clang optimize on +#endif diff --git a/modules/ui/src/utils/math.cpp b/modules/ui/src/utils/math.cpp index b871898a..646eeae0 100644 --- a/modules/ui/src/utils/math.cpp +++ b/modules/ui/src/utils/math.cpp @@ -97,22 +97,9 @@ Eigen::Projective3f ortho(float left, float right, float bottom, float top, floa return Eigen::Projective3f(result); } -Eigen::Vector3f unproject_point( - const Eigen::Vector3f& v, - const Eigen::Matrix4f& view, - const Eigen::Matrix4f& perspective, - const Eigen::Vector4f& viewport) -{ - Eigen::Vector4f tmp = Eigen::Vector4f(v.x(), v.y(), v.z(), 1.0f); - tmp.x() = (tmp.x() - viewport(0)) / viewport(2); - tmp.y() = (tmp.y() - viewport(1)) / viewport(3); - tmp = tmp * 2.0f - Eigen::Vector4f::Ones(); - - Eigen::Vector4f obj = (perspective * view).inverse() * tmp; - obj *= 1.0f / obj.w(); +// unproject_point() moved to math_xcode264_workaround.cpp +// to work around Xcode 26.4 compiler bug - return obj.head<3>(); -} float pi() { diff --git a/modules/ui/src/utils/math_xcode264_workaround.cpp b/modules/ui/src/utils/math_xcode264_workaround.cpp new file mode 100644 index 00000000..70358a45 --- /dev/null +++ b/modules/ui/src/utils/math_xcode264_workaround.cpp @@ -0,0 +1,72 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// Workaround for Xcode 26.4 compiler bug +// The VectorCombine pass in clang 21.0.0 crashes when compiling this function +// for x86_64 with optimization enabled. Disabling optimizations for this file +// prevents the crash. This works across all build systems (CMake, Make, etc.) +// +// Bug: VectorCombine::foldSelectShuffle() segfaults on (perspective * view).inverse() +// Affects: Apple Clang (Xcode 26.4, __apple_build_version__ 21000000-21999999), x86_64 only +// +// Why this function is in a separate file with file-level #pragma: +// - Localized #pragma around the function (function-level) does NOT work +// - The crash happens during Eigen template instantiation, which occurs during +// the compiler's code generation phase, BEFORE function-level optimization +// directives are applied +// - The VectorCombine pass runs at the translation-unit level, not function level +// - Therefore, the #pragma must be at FILE level to disable the pass for the +// entire compilation unit containing the problematic template instantiations +// +// TODO: Remove this file when Apple fixes the bug in future Xcode release + +#include + +// Only disable optimizations for the specific problematic configuration +// Note: __apple_build_version__ is only defined by Apple Clang, not Homebrew LLVM +#if LAGRANGE_TARGET_OS(APPLE) && LAGRANGE_TARGET_PLATFORM(x86_64) && \ + defined(__apple_build_version__) && __apple_build_version__ >= 21000000 && \ + __apple_build_version__ < 22000000 + #pragma clang optimize off +#endif + +#include + +namespace lagrange { +namespace ui { + +Eigen::Vector3f unproject_point( + const Eigen::Vector3f& v, + const Eigen::Matrix4f& view, + const Eigen::Matrix4f& perspective, + const Eigen::Vector4f& viewport) +{ + Eigen::Vector4f tmp = Eigen::Vector4f(v.x(), v.y(), v.z(), 1.0f); + tmp.x() = (tmp.x() - viewport(0)) / viewport(2); + tmp.y() = (tmp.y() - viewport(1)) / viewport(3); + tmp = tmp * 2.0f - Eigen::Vector4f::Ones(); + + Eigen::Vector4f obj = (perspective * view).inverse() * tmp; + obj *= 1.0f / obj.w(); + + return obj.head<3>(); +} + +} // namespace ui +} // namespace lagrange + +// Re-enable optimizations if they were disabled +#if LAGRANGE_TARGET_OS(APPLE) && LAGRANGE_TARGET_PLATFORM(x86_64) && \ + defined(__apple_build_version__) && __apple_build_version__ >= 21000000 && \ + __apple_build_version__ < 22000000 + #pragma clang optimize on +#endif diff --git a/modules/volume/include/lagrange/volume/mesh_to_volume.h b/modules/volume/include/lagrange/volume/mesh_to_volume.h index d9b91ede..f46cf19c 100644 --- a/modules/volume/include/lagrange/volume/mesh_to_volume.h +++ b/modules/volume/include/lagrange/volume/mesh_to_volume.h @@ -44,10 +44,13 @@ struct MeshToVolumeOptions }; /// -/// Converts a triangle mesh to a OpenVDB sparse voxel grid. +/// Converts a mesh to an OpenVDB sparse voxel grid. /// -/// @param[in] mesh Input mesh. Must be a triangle mesh, a quad mesh, or a quad-dominant -/// mesh. +/// @param[in] mesh Input mesh. Must be a triangle mesh, a quad mesh, a quad-dominant +/// mesh, or contain edges (size-2 facets) and/or points (size-1 facets). +/// For hybrid meshes, facet sizes 1-4 are supported. Size-1 and size-2 +/// facets are forwarded to the OpenVDB adapter as degenerate triangles for +/// all signing methods (FloodFill, WindingNumber, Unsigned). /// @param[in] options Conversion options. /// /// @tparam GridScalar Output OpenVDB Grid scalar type. Only float or double are supported. diff --git a/modules/volume/src/mesh_to_volume.cpp b/modules/volume/src/mesh_to_volume.cpp index 3d830d12..5fb79c7a 100644 --- a/modules/volume/src/mesh_to_volume.cpp +++ b/modules/volume/src/mesh_to_volume.cpp @@ -20,11 +20,14 @@ #include #include +#include + // clang-format off #include #include #include #include +#include // clang-format on namespace lagrange::volume { @@ -57,10 +60,13 @@ class SurfaceMeshAdapter /// Number of mesh vertices. size_t pointCount() const { return static_cast(m_mesh.get_num_vertices()); } - /// Number of vertices for a given facet. + /// Number of vertices for a given facet. Facets with fewer than 3 vertices are reported as + /// degenerate triangles. size_t vertexCount(size_t f) const { - return static_cast(m_mesh.get_facet_size(static_cast(f))); + return std::max( + size_t(3), + static_cast(m_mesh.get_facet_size(static_cast(f)))); } /// @@ -72,6 +78,11 @@ class SurfaceMeshAdapter /// void getIndexSpacePoint(size_t f, size_t lv, openvdb::Vec3d& pos) const { + // For facets with fewer than 3 vertices, wrap lv to create a degenerate triangle. + auto nv = static_cast(m_mesh.get_facet_size(static_cast(f))); + if (lv >= nv) { + lv = 0; + } auto p = m_mesh.get_position( m_mesh.get_facet_vertex(static_cast(f), static_cast(lv))); pos = openvdb::Vec3d(p[0], p[1], p[2]); @@ -105,24 +116,32 @@ auto mesh_to_volume(const SurfaceMesh& mesh_, const MeshToVolumeO triangulate_polygonal_facets(mesh); } } else { + bool needs_triangulation = false; if (mesh.is_hybrid() || mesh.get_vertex_per_facet() > 4) { - // Check that the maximum facet size is <= 4. If not, we need to triangulate. for (Index f = 0; f < mesh.get_num_facets(); ++f) { - Index nv = mesh.get_facet_size(f); - if (nv > 4) { - logger().debug("Triangulating mesh because of facets with > 4 vertices"); - triangulate_polygonal_facets(mesh); + if (mesh.get_facet_size(f) > 4) { + needs_triangulation = true; break; } } } + if (needs_triangulation) { + logger().debug("Triangulating mesh because of facets with > 4 vertices"); + TriangulationOptions tri_opt; + if (options.signing_method == MeshToVolumeOptions::Sign::Unsigned) { + // Preserve sub-triangle facets for unsigned distance field computation. + tri_opt.preserve_edges = true; + tri_opt.preserve_points = true; + } + triangulate_polygonal_facets(mesh, tri_opt); + } } if (mesh.is_hybrid()) { for (Index f = 0; f < mesh.get_num_facets(); ++f) { - if (auto nv = mesh.get_facet_size(f); nv < 3 || nv > 4) { + if (auto nv = mesh.get_facet_size(f); nv < 1 || nv > 4) { throw Error( - fmt::format("Facet size should be 3 or 4, but f{} has #{} vertices", f, nv)); + format("Facet size should be 1, 2, 3 or 4, but f{} has #{} vertices", f, nv)); } } } diff --git a/modules/volume/tests/test_voxelization.cpp b/modules/volume/tests/test_voxelization.cpp index 0b169de5..34bfbf1d 100644 --- a/modules/volume/tests/test_voxelization.cpp +++ b/modules/volume/tests/test_voxelization.cpp @@ -70,6 +70,166 @@ TEST_CASE("voxelization: winding number", "[volume]") REQUIRE(mesh3.get_num_facets() > mesh2.get_num_facets()); } +TEST_CASE("mesh_to_volume: edge facets", "[volume]") +{ + using Scalar = float; + using Index = uint32_t; + + lagrange::volume::MeshToVolumeOptions m2v_opt; + m2v_opt.signing_method = lagrange::volume::MeshToVolumeOptions::Sign::Unsigned; + m2v_opt.voxel_size = 0.1; + + // For unsigned grids, extract isosurface at voxel_size * sqrt(3). + lagrange::volume::VolumeToMeshOptions v2m_opt; + v2m_opt.isovalue = m2v_opt.voxel_size * std::sqrt(3.0); + + SECTION("pure edge mesh") + { + // Inline wireframe unit cube [0,1]^3 + lagrange::SurfaceMesh mesh; + mesh.add_vertices(8); + auto vertices = vertex_ref(mesh); + vertices.row(0) << 0, 0, 0; + vertices.row(1) << 1, 0, 0; + vertices.row(2) << 1, 1, 0; + vertices.row(3) << 0, 1, 0; + vertices.row(4) << 0, 0, 1; + vertices.row(5) << 1, 0, 1; + vertices.row(6) << 1, 1, 1; + vertices.row(7) << 0, 1, 1; + // 12 edges of the cube + for (auto [a, b] : + {std::pair{0u, 1u}, + {1u, 2u}, + {2u, 3u}, + {3u, 0u}, + {4u, 5u}, + {5u, 6u}, + {6u, 7u}, + {7u, 4u}, + {0u, 4u}, + {1u, 5u}, + {2u, 6u}, + {3u, 7u}}) { + mesh.add_polygon({a, b}); + } + REQUIRE(mesh.is_regular()); + REQUIRE(mesh.get_vertex_per_facet() == 2); + auto grid = lagrange::volume::mesh_to_volume(mesh, m2v_opt); + REQUIRE(grid->activeVoxelCount() > 0); + auto out = + lagrange::volume::volume_to_mesh>(*grid, v2m_opt); + REQUIRE(out.get_num_facets() > 0); + } + + SECTION("hybrid mesh with edges and triangles") + { + lagrange::SurfaceMesh mesh; + mesh.add_vertices(4); + auto vertices = vertex_ref(mesh); + vertices.row(0) << 0, 0, 0; + vertices.row(1) << 1, 0, 0; + vertices.row(2) << 0.5f, 1, 0; + vertices.row(3) << 0.5f, 0.5f, 1; + mesh.add_triangle(0, 1, 2); + mesh.add_polygon({0, 3}); + REQUIRE(mesh.is_hybrid()); + auto grid = lagrange::volume::mesh_to_volume(mesh, m2v_opt); + REQUIRE(grid->activeVoxelCount() > 0); + auto out = + lagrange::volume::volume_to_mesh>(*grid, v2m_opt); + REQUIRE(out.get_num_facets() > 0); + } + SECTION("edges contribute to voxelization with polygonal mesh") + { + // Create a triangle mesh with an additional edge that extends far from the triangle. + // If the edge is properly voxelized, the grid should have more active voxels than the + // triangle alone. This tests the preserve_edges path during triangulation. + lagrange::SurfaceMesh mesh; + mesh.add_vertices(7); + auto vertices = vertex_ref(mesh); + vertices.row(0) << 0, 0, 0; + vertices.row(1) << 1, 0, 0; + vertices.row(2) << 0.5f, 1, 0; + vertices.row(3) << 0, 0, 0; + vertices.row(4) << 1, 0, 0; + vertices.row(5) << 0.5f, 1, 0; + vertices.row(6) << 0, 0, 5; // far away from triangle + // Add a polygon (n-gon > 4) to force triangulation path + mesh.add_polygon({0, 1, 2, 3, 4}); + mesh.add_polygon({0, 6}); + REQUIRE(mesh.is_hybrid()); + auto grid = lagrange::volume::mesh_to_volume(mesh, m2v_opt); + REQUIRE(grid->activeVoxelCount() > 0); + auto bbox = grid->evalActiveVoxelBoundingBox(); + auto nz = bbox.max().z() - bbox.min().z() + 1; + // The triangle is at z=0, the edge extends to z=5. + // With voxel_size=0.1, the z-extent should be at least 50 voxels if the edge is included. + REQUIRE(nz > 40); + } + + SECTION("pure point mesh") + { + lagrange::SurfaceMesh mesh; + mesh.add_vertices(4); + auto vertices = vertex_ref(mesh); + vertices.row(0) << 0, 0, 0; + vertices.row(1) << 1, 0, 0; + vertices.row(2) << 0, 1, 0; + vertices.row(3) << 0, 0, 1; + for (uint32_t i = 0; i < 4; ++i) { + mesh.add_polygon({i}); + } + REQUIRE(mesh.is_regular()); + REQUIRE(mesh.get_vertex_per_facet() == 1); + auto grid = lagrange::volume::mesh_to_volume(mesh, m2v_opt); + REQUIRE(grid->activeVoxelCount() > 0); + } + + SECTION("hybrid mesh with points, edges, and triangles") + { + lagrange::SurfaceMesh mesh; + mesh.add_vertices(5); + auto vertices = vertex_ref(mesh); + vertices.row(0) << 0, 0, 0; + vertices.row(1) << 1, 0, 0; + vertices.row(2) << 0.5f, 1, 0; + vertices.row(3) << 0.5f, 0.5f, 1; + vertices.row(4) << 0, 0, 3; + mesh.add_triangle(0, 1, 2); + mesh.add_polygon({0, 3}); + mesh.add_polygon({4}); + REQUIRE(mesh.is_hybrid()); + auto grid = lagrange::volume::mesh_to_volume(mesh, m2v_opt); + REQUIRE(grid->activeVoxelCount() > 0); + } + + SECTION("points contribute to voxelization with polygonal mesh") + { + lagrange::SurfaceMesh mesh; + mesh.add_vertices(7); + auto vertices = vertex_ref(mesh); + vertices.row(0) << 0, 0, 0; + vertices.row(1) << 1, 0, 0; + vertices.row(2) << 0.5f, 1, 0; + vertices.row(3) << 0, 0, 0; + vertices.row(4) << 1, 0, 0; + vertices.row(5) << 0.5f, 1, 0; + vertices.row(6) << 0, 0, 5; // far away from polygon + // Add a polygon (n-gon > 4) to force triangulation path + mesh.add_polygon({0, 1, 2, 3, 4}); + mesh.add_polygon({6}); // point facet + REQUIRE(mesh.is_hybrid()); + auto grid = lagrange::volume::mesh_to_volume(mesh, m2v_opt); + REQUIRE(grid->activeVoxelCount() > 0); + auto bbox = grid->evalActiveVoxelBoundingBox(); + auto nz = bbox.max().z() - bbox.min().z() + 1; + // The polygon is at z=0, the point is at z=5. + // With voxel_size=0.1, the z-extent should be at least 50 voxels if the point is included. + REQUIRE(nz > 40); + } +} + TEST_CASE("mesh_to_volume: polygonal mesh", "[volume]") { using Scalar = float; diff --git a/modules/winding/examples/fix_orientation.cpp b/modules/winding/examples/fix_orientation.cpp index 29f2aa01..2a597c86 100644 --- a/modules/winding/examples/fix_orientation.cpp +++ b/modules/winding/examples/fix_orientation.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include #include #include @@ -90,8 +92,8 @@ int main(int argc, char** argv) const Eigen::Vector3f cc = vvs.row(input_mesh.get_facet_vertex(ff, 2)); const auto normal = (bb - aa).cross(cc - aa).normalized(); const auto barycenter = (aa + bb + cc) / 3; - // logger.debug("normal [{}]", fmt::join(normal, ", ")); - // logger.debug("barycenter [{}]", fmt::join(barycenter, ", ")); + // logger.debug("normal [{}]", lagrange::join(normal, ", ")); + // logger.debug("barycenter [{}]", lagrange::join(barycenter, ", ")); const auto pp_ = barycenter + options.epsilon * normal; const auto qq_ = barycenter - options.epsilon * normal; diff --git a/modules/winding/examples/sample_points_in_mesh.cpp b/modules/winding/examples/sample_points_in_mesh.cpp index 9402219f..cee8a7c8 100644 --- a/modules/winding/examples/sample_points_in_mesh.cpp +++ b/modules/winding/examples/sample_points_in_mesh.cpp @@ -12,10 +12,11 @@ #include #include #include +#include +#include #include #include -#include #include #include @@ -79,7 +80,7 @@ int main(int argc, char** argv) lagrange::logger().info("Saving filtered sample points: {}", args.output); fs::ofstream out(args.output); for (auto p : points) { - out << fmt::format("{}\n", fmt::join(p, " ")); + out << lagrange::format("{}\n", lagrange::join(p, " ")); } return 0; From b97368a1245d8721a075ea48adab67f2c02afc96 Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Mon, 27 Apr 2026 14:12:16 -0400 Subject: [PATCH 2/6] Update sccache-action --- .github/workflows/continuous.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index dc0153ad..bd8cf1aa 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -236,7 +236,7 @@ jobs: id: cpu-cores - name: Sccache - uses: mozilla-actions/sccache-action@v0.0.9 + uses: mozilla-actions/sccache-action@v0.0.10 # We run configure + build in the same step, since they both need to call VsDevCmd # Also, cmd uses ^ to break commands into multiple lines (in powershell this is `) From 1b2da59ec2979470d0d722869c7b4d6a060e566a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 27 Apr 2026 22:35:29 -0700 Subject: [PATCH 3/6] Fix sccache setup for Ninja + MSVC --- CMakeLists.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index fa103299..f40a457c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,20 @@ set_property(CACHE LAGRANGE_CCACHE_PROGRAM PROPERTY STRINGS ${LAGRANGE_CCACHE_PR # Enable sscache if available if((LAGRANGE_CCACHE_PROGRAM STREQUAL "sccache") AND SCCACHE_PROGRAM) message(STATUS "Using sccache: ${SCCACHE_PROGRAM}") + + if(MSVC) + # sccache cannot cache /Zi unless each cl.exe writes to its own PDB. + # Replace the per-target PDB in CMake's compile rule with a per-object PDB. + set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$:ProgramDatabase>") + foreach(lang IN ITEMS C CXX) + string(REPLACE + "/Fd" + "/Fd.pdb" + CMAKE_${lang}_COMPILE_OBJECT + "${CMAKE_${lang}_COMPILE_OBJECT}") + endforeach() + endif() + foreach(lang IN ITEMS C CXX) set(CMAKE_${lang}_COMPILER_LAUNCHER ${SCCACHE_PROGRAM}) endforeach() From 4690ea072995dd94477e5e73ab1606fb68c326cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Tue, 28 Apr 2026 14:23:15 -0700 Subject: [PATCH 4/6] Second attempt. --- CMakeLists.txt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f40a457c..5190e992 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,12 +80,18 @@ if((LAGRANGE_CCACHE_PROGRAM STREQUAL "sccache") AND SCCACHE_PROGRAM) # Replace the per-target PDB in CMake's compile rule with a per-object PDB. set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$:ProgramDatabase>") foreach(lang IN ITEMS C CXX) - string(REPLACE - "/Fd" - "/Fd.pdb" + # Match either '/Fd...' or '-Fd...' (CMake/Ninja uses the dash form). + string(REGEX REPLACE + "[-/]Fd" + "-Fd.pdb" CMAKE_${lang}_COMPILE_OBJECT "${CMAKE_${lang}_COMPILE_OBJECT}") + # /FS is no longer required once PDBs aren't shared; harmless to leave, + # but you can drop it too: + string(REGEX REPLACE "[-/]FS( |$)" "" CMAKE_${lang}_COMPILE_OBJECT + "${CMAKE_${lang}_COMPILE_OBJECT}") endforeach() + message(STATUS "Patched CXX rule: ${CMAKE_CXX_COMPILE_OBJECT}") endif() foreach(lang IN ITEMS C CXX) From 0dae24e3784077e345a0974754ae46147d6b7118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Tue, 28 Apr 2026 14:37:06 -0700 Subject: [PATCH 5/6] Third attempt. --- CMakeLists.txt | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5190e992..c34b82cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,26 +74,6 @@ set_property(CACHE LAGRANGE_CCACHE_PROGRAM PROPERTY STRINGS ${LAGRANGE_CCACHE_PR # Enable sscache if available if((LAGRANGE_CCACHE_PROGRAM STREQUAL "sccache") AND SCCACHE_PROGRAM) message(STATUS "Using sccache: ${SCCACHE_PROGRAM}") - - if(MSVC) - # sccache cannot cache /Zi unless each cl.exe writes to its own PDB. - # Replace the per-target PDB in CMake's compile rule with a per-object PDB. - set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$:ProgramDatabase>") - foreach(lang IN ITEMS C CXX) - # Match either '/Fd...' or '-Fd...' (CMake/Ninja uses the dash form). - string(REGEX REPLACE - "[-/]Fd" - "-Fd.pdb" - CMAKE_${lang}_COMPILE_OBJECT - "${CMAKE_${lang}_COMPILE_OBJECT}") - # /FS is no longer required once PDBs aren't shared; harmless to leave, - # but you can drop it too: - string(REGEX REPLACE "[-/]FS( |$)" "" CMAKE_${lang}_COMPILE_OBJECT - "${CMAKE_${lang}_COMPILE_OBJECT}") - endforeach() - message(STATUS "Patched CXX rule: ${CMAKE_CXX_COMPILE_OBJECT}") - endif() - foreach(lang IN ITEMS C CXX) set(CMAKE_${lang}_COMPILER_LAUNCHER ${SCCACHE_PROGRAM}) endforeach() @@ -156,6 +136,25 @@ project(Lagrange VERSION ${lagrange_version}) ################################################################################ +# sccache cannot cache /Zi unless each cl.exe writes to its own PDB. +# Replace the per-target PDB in CMake's compile rule with a per-object PDB. +if(MSVC AND (LAGRANGE_CCACHE_PROGRAM STREQUAL "sccache") AND SCCACHE_PROGRAM) + set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$:ProgramDatabase>") + foreach(lang IN ITEMS C CXX) + # Match either '/Fd...' or '-Fd...' (CMake/Ninja uses the dash form). + string(REGEX REPLACE + "[-/]Fd" + "-Fd.pdb" + CMAKE_${lang}_COMPILE_OBJECT + "${CMAKE_${lang}_COMPILE_OBJECT}") + # /FS is no longer required once PDBs aren't shared; harmless to leave, + # but you can drop it too: + string(REGEX REPLACE "[-/]FS( |$)" "" CMAKE_${lang}_COMPILE_OBJECT + "${CMAKE_${lang}_COMPILE_OBJECT}") + endforeach() + message(STATUS "Patched CXX rule: ${CMAKE_CXX_COMPILE_OBJECT}") +endif() + if(MINGW) message(FATAL_ERROR "Please use the MSVC compiler on Windows") endif() From 1fa7968237907ca9fc83e5463a9188825b7a32f5 Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Tue, 28 Apr 2026 21:55:24 -0400 Subject: [PATCH 6/6] Add copyright header to .ts files. --- modules/bvh/js/test/bvh.test.ts | 12 ++++++++++++ modules/bvh/js/ts/bvh.ts | 12 ++++++++++++ modules/core/js/test/SurfaceMesh.test.ts | 12 ++++++++++++ modules/core/js/test/bindings.test.ts | 12 ++++++++++++ modules/core/js/test/core.test.ts | 12 ++++++++++++ modules/core/js/test/core_utilities.test.ts | 12 ++++++++++++ modules/core/js/ts/core.ts | 12 ++++++++++++ modules/filtering/js/test/filtering.test.ts | 12 ++++++++++++ modules/filtering/js/ts/filtering.ts | 12 ++++++++++++ modules/io/js/test/io.test.ts | 12 ++++++++++++ modules/io/js/ts/io.ts | 12 ++++++++++++ modules/primitive/js/test/primitive.test.ts | 12 ++++++++++++ modules/primitive/js/ts/primitive.ts | 12 ++++++++++++ modules/scene/js/ts/scene.ts | 12 ++++++++++++ modules/serialization2/js/test/serialization.test.ts | 12 ++++++++++++ modules/serialization2/js/ts/serialization.ts | 12 ++++++++++++ modules/subdivision/js/test/subdivision.test.ts | 12 ++++++++++++ modules/subdivision/js/ts/subdivision.ts | 12 ++++++++++++ 18 files changed, 216 insertions(+) diff --git a/modules/bvh/js/test/bvh.test.ts b/modules/bvh/js/test/bvh.test.ts index 60721b28..bce1bbf5 100644 --- a/modules/bvh/js/test/bvh.test.ts +++ b/modules/bvh/js/test/bvh.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/bvh/js/ts/bvh.ts b/modules/bvh/js/ts/bvh.ts index 7c41e5cd..40127da5 100644 --- a/modules/bvh/js/ts/bvh.ts +++ b/modules/bvh/js/ts/bvh.ts @@ -1,3 +1,15 @@ +/* + * 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. + */ + /** * Spatial queries powered by a bounding-volume hierarchy: * closest-point distances between meshes, mesh-to-mesh distance metrics, diff --git a/modules/core/js/test/SurfaceMesh.test.ts b/modules/core/js/test/SurfaceMesh.test.ts index 8f8881a1..50254cff 100644 --- a/modules/core/js/test/SurfaceMesh.test.ts +++ b/modules/core/js/test/SurfaceMesh.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/core/js/test/bindings.test.ts b/modules/core/js/test/bindings.test.ts index 4c8e5bb7..2c51938c 100644 --- a/modules/core/js/test/bindings.test.ts +++ b/modules/core/js/test/bindings.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/core/js/test/core.test.ts b/modules/core/js/test/core.test.ts index 45288b02..fce91122 100644 --- a/modules/core/js/test/core.test.ts +++ b/modules/core/js/test/core.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/core/js/test/core_utilities.test.ts b/modules/core/js/test/core_utilities.test.ts index 70efba86..71636193 100644 --- a/modules/core/js/test/core_utilities.test.ts +++ b/modules/core/js/test/core_utilities.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/core/js/ts/core.ts b/modules/core/js/ts/core.ts index c489ea61..203c23d0 100644 --- a/modules/core/js/ts/core.ts +++ b/modules/core/js/ts/core.ts @@ -1,3 +1,15 @@ +/* + * 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. + */ + /** * Core mesh type and the operations that run on it. */ diff --git a/modules/filtering/js/test/filtering.test.ts b/modules/filtering/js/test/filtering.test.ts index 6dab3b1a..e6bcca8d 100644 --- a/modules/filtering/js/test/filtering.test.ts +++ b/modules/filtering/js/test/filtering.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/filtering/js/ts/filtering.ts b/modules/filtering/js/ts/filtering.ts index 2836e3c2..895b6369 100644 --- a/modules/filtering/js/ts/filtering.ts +++ b/modules/filtering/js/ts/filtering.ts @@ -1,3 +1,15 @@ +/* + * 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. + */ + /** * Anisotropic smoothing of mesh geometry and per-vertex scalar fields. * Uses a normal/vertex diffusion formulation that preserves sharp features diff --git a/modules/io/js/test/io.test.ts b/modules/io/js/test/io.test.ts index 895d3e87..f61b4770 100644 --- a/modules/io/js/test/io.test.ts +++ b/modules/io/js/test/io.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/io/js/ts/io.ts b/modules/io/js/ts/io.ts index 5128bb5a..e5898148 100644 --- a/modules/io/js/ts/io.ts +++ b/modules/io/js/ts/io.ts @@ -1,3 +1,15 @@ +/* + * 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. + */ + /** * Read and write mesh / scene files from in-memory byte buffers — no * filesystem access. To load from a URL, `fetch` it first and pass the diff --git a/modules/primitive/js/test/primitive.test.ts b/modules/primitive/js/test/primitive.test.ts index f633212a..22667bb3 100644 --- a/modules/primitive/js/test/primitive.test.ts +++ b/modules/primitive/js/test/primitive.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/primitive/js/ts/primitive.ts b/modules/primitive/js/ts/primitive.ts index edbdcd1c..4dbde0c5 100644 --- a/modules/primitive/js/ts/primitive.ts +++ b/modules/primitive/js/ts/primitive.ts @@ -1,3 +1,15 @@ +/* + * 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. + */ + /** * Procedural mesh generators: spheres, cubes, cones, tori, swept surfaces. * Each comes with UVs and normals ready for rendering. diff --git a/modules/scene/js/ts/scene.ts b/modules/scene/js/ts/scene.ts index 7dc82b47..91ed6d25 100644 --- a/modules/scene/js/ts/scene.ts +++ b/modules/scene/js/ts/scene.ts @@ -1,3 +1,15 @@ +/* + * 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. + */ + /** * Scene graph: a collection of meshes linked to transform nodes plus * materials. Returned by `lagrange.io.loadSceneFromBuffer` when loading diff --git a/modules/serialization2/js/test/serialization.test.ts b/modules/serialization2/js/test/serialization.test.ts index 91c3ef4a..adae45f9 100644 --- a/modules/serialization2/js/test/serialization.test.ts +++ b/modules/serialization2/js/test/serialization.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/serialization2/js/ts/serialization.ts b/modules/serialization2/js/ts/serialization.ts index d148f875..5929d367 100644 --- a/modules/serialization2/js/ts/serialization.ts +++ b/modules/serialization2/js/ts/serialization.ts @@ -1,3 +1,15 @@ +/* + * 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. + */ + /** * Fast binary round-trip for a {@link SurfaceMesh} or {@link Scene}, including * all attached attributes. Prefer this over `obj`/`ply`/`gltf` when storing diff --git a/modules/subdivision/js/test/subdivision.test.ts b/modules/subdivision/js/test/subdivision.test.ts index 2ed08216..72cf9d47 100644 --- a/modules/subdivision/js/test/subdivision.test.ts +++ b/modules/subdivision/js/test/subdivision.test.ts @@ -1,3 +1,15 @@ +/* + * 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 { describe, test, expect, beforeAll } from "vitest"; import { loadLagrange } from "../src/loadLagrange.node.js"; import type { Lagrange } from "../src/types.js"; diff --git a/modules/subdivision/js/ts/subdivision.ts b/modules/subdivision/js/ts/subdivision.ts index 721c6e16..814b5682 100644 --- a/modules/subdivision/js/ts/subdivision.ts +++ b/modules/subdivision/js/ts/subdivision.ts @@ -1,3 +1,15 @@ +/* + * 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. + */ + /** * Refine a mesh by inserting new vertices/faces. Use for LOD up-resing, * smoothing low-poly cages, and displacement pipelines.