From 642a94097c766c6b71c807825b53e1c07f043967 Mon Sep 17 00:00:00 2001 From: Giorgio Gori Date: Fri, 19 Jun 2026 16:47:12 -0700 Subject: [PATCH] Sync with 547290d --- .github/workflows/continuous.yaml | 87 +- .gitignore | 5 + CMakeLists.txt | 14 + LagrangeOptions.cmake.sample | 1 + cmake/lagrange/lagrange_vcpkg_toolchain.cmake | 2 +- cmake/recipes/external/gklib.cmake | 19 +- cmake/recipes/external/imgui.cmake | 4 +- cmake/recipes/external/imgui_fonts.cmake | 22 +- cmake/recipes/external/imguizmo.cmake | 15 +- cmake/recipes/external/implot.cmake | 42 + cmake/recipes/external/polyscope.cmake | 14 +- cmake/recipes/external/simde.cmake | 10 +- cmake/recipes/external/winding_number.cmake | 2 +- cmake/recipes/external/xatlas.cmake | 53 ++ docs/cpp/Doxyfile | 6 +- docs/cpp/pixi.toml | 13 + modules/bvh/examples/uv_overlap.cpp | 2 + .../bvh/include/lagrange/bvh/EdgeAABBTree.h | 2 +- .../lagrange/bvh/compute_intersecting_pairs.h | 6 +- modules/bvh/python/src/bvh.cpp | 12 +- modules/core/core.md | 3 +- modules/core/include/lagrange/Attribute.h | 2 +- .../lagrange/ExactPredicatesShewchuk.h | 6 +- modules/core/include/lagrange/SurfaceMesh.h | 1 - .../lagrange/compute_facet_facet_adjacency.h | 30 +- .../include/lagrange/compute_pointcloud_pca.h | 2 +- .../compute_vertex_vertex_adjacency.h | 25 +- .../include/lagrange/experimental/Array.h | 2 + .../lagrange/get_unique_attribute_name.h | 2 +- .../internal/clipped_triangle_circumcenter.h | 93 ++ .../internal/interpolate_attribute_row.h | 62 ++ .../lagrange/internal/set_indexed_values.h | 104 +++ .../internal/set_invalid_indexed_values.h | 69 -- .../core/include/lagrange/internal/skinning.h | 4 +- modules/core/include/lagrange/isoline.h | 31 + .../mesh_cleanup/remove_duplicate_facets.h | 2 +- .../mesh_cleanup/split_obtuse_triangles.h | 64 ++ .../core/include/lagrange/remap_vertices.h | 1 + .../lagrange/split_facets_by_material.h | 2 +- .../lagrange/types/DualConnectivityType.h | 25 + .../include/lagrange/utils/AdjacencyList.h | 2 +- .../core/include/lagrange/utils/BitField.h | 4 +- .../include/lagrange/utils/DisjointSets.h | 2 +- .../core/include/lagrange/utils/SmallVector.h | 4 +- .../core/include/lagrange/utils/StackSet.h | 4 +- .../core/include/lagrange/utils/StackVector.h | 4 +- modules/core/include/lagrange/utils/assert.h | 16 +- .../core/include/lagrange/utils/chain_edges.h | 4 +- .../core/include/lagrange/utils/fmt/join.h | 4 + .../core/include/lagrange/utils/fmt_eigen.h | 2 + modules/core/include/lagrange/utils/fpe.h | 4 +- .../include/lagrange/utils/function_ref.h | 8 +- .../core/include/lagrange/utils/geometry3d.h | 1 + .../include/lagrange/utils/point_on_segment.h | 6 +- modules/core/include/lagrange/utils/range.h | 2 + modules/core/include/lagrange/utils/strings.h | 4 +- .../utils/triangle_triangle_intersection.h | 4 +- .../core/include/lagrange/utils/value_ptr.h | 9 +- .../include/lagrange/weld_indexed_attribute.h | 3 +- .../include/lagrange/python/utils/StubType.h | 72 ++ .../lagrange/python/utils/bind_safe_vector.h | 12 + modules/core/python/scripts/meshstat.py | 2 +- modules/core/python/src/bind_mesh_cleanup.h | 33 + modules/core/python/src/bind_utilities.h | 100 ++- modules/core/python/tests/test_isoline.py | 18 + .../core/python/tests/test_meshstat_logic.py | 28 + .../tests/test_split_obtuse_triangles.py | 68 ++ .../core/python/tests/test_surface_mesh.py | 6 +- .../core/python/tests/test_transform_mesh.py | 34 + modules/core/src/Attribute.cpp | 8 + modules/core/src/IndexedAttribute.cpp | 4 + .../src/compute_facet_facet_adjacency.cpp | 74 +- .../src/compute_vertex_vertex_adjacency.cpp | 139 ++- modules/core/src/internal/map_attributes.cpp | 70 +- modules/core/src/isoline.cpp | 563 +++++++++--- .../src/mesh_cleanup/split_long_edges.cpp | 32 +- .../mesh_cleanup/split_obtuse_triangles.cpp | 261 ++++++ .../src/mesh_cleanup/unflip_uv_triangles.cpp | 2 +- modules/core/src/orientation.cpp | 2 +- modules/core/src/reorder_mesh.cpp | 12 +- modules/core/src/topology.cpp | 2 +- modules/core/src/utils/point_on_segment.cpp | 23 +- modules/core/tests/fmt/user_fmt_formatter.h | 2 + .../test_split_obtuse_triangles.cpp | 239 ++++++ .../test_clipped_triangle_circumcenter.cpp | 86 ++ .../test_compute_facet_facet_adjacency.cpp | 126 +++ .../test_compute_vertex_vertex_adjacency.cpp | 209 +++++ modules/core/tests/test_isoline.cpp | 265 ++++++ .../core/tests/test_set_indexed_values.cpp | 272 ++++++ .../tests/test_set_invalid_indexed_values.cpp | 132 --- modules/geodesic/geodesic.md | 10 + modules/geodesic/src/GeodesicEngineHeat.cpp | 2 + modules/geodesic/src/GeodesicEngineMMP.cpp | 2 + modules/image/image.md | 7 +- .../include/lagrange/python/image_utils.h | 29 +- modules/image/python/src/image.cpp | 10 +- modules/image/tests/test_image_sampling.cpp | 4 + modules/image_io/image_io.md | 8 + .../lagrange/io/load_simple_scene_fbx.h | 2 +- .../lagrange/io/load_simple_scene_gltf.h | 4 +- modules/io/io.md | 10 +- modules/io/src/load_fbx.cpp | 19 +- modules/io/src/load_obj.cpp | 2 +- modules/io/src/save_gltf.cpp | 59 +- modules/io/tests/test_fbx.cpp | 3 + modules/io/tests/test_save_scene.cpp | 53 ++ modules/main.md | 14 +- modules/packing/packing.md | 9 + .../python/tests/test_repack_uv_charts.py | 2 +- modules/partitioning/partitioning.md | 5 +- .../lagrange/poisson/AttributeEvaluator.h | 5 + .../poisson/mesh_from_oriented_points.h | 5 + modules/poisson/poisson.md | 9 + .../polyddg/compute_principal_curvatures.h | 4 +- .../src/compute_smooth_direction_field.cpp | 5 +- modules/polyscope/examples/mesh_viewer.cpp | 2 + modules/polyscope/src/register_attributes.h | 10 + .../polyscope/src/register_edge_network.cpp | 4 + modules/polyscope/src/register_mesh.cpp | 27 + .../polyscope/src/register_point_cloud.cpp | 4 + modules/polyscope/src/register_structure.cpp | 4 + modules/polyscope/tests/test_polyscope.cpp | 59 ++ .../primitive/legacy/generate_rounded_plane.h | 2 +- modules/primitive/src/primitive_utils.h | 4 +- modules/python/CMakeLists.txt | 3 +- modules/python/lagrange/_logging.py | 2 +- modules/python/lagrange/scripts/meshstat.py | 216 +++-- modules/raycasting/examples/picking_demo.cpp | 2 + .../include/lagrange/raycasting/Options.h | 2 +- .../include/lagrange/raycasting/RayCaster.h | 2 +- .../raycasting/compute_local_feature_size.h | 2 +- .../include/lagrange/raycasting/project.h | 2 +- .../raycasting/project_closest_point.h | 2 +- .../raycasting/project_closest_vertex.h | 2 +- .../lagrange/raycasting/project_directional.h | 2 +- .../raycasting/remove_occluded_facets.h | 2 +- .../raycasting/remove_occluded_instances.h | 4 +- modules/raycasting/python/src/raycasting.cpp | 802 +++++++++++++++++- .../python/tests/test_raycasting.py | 287 ++++++- .../python/tests/test_remove_occluded.py | 2 +- modules/raycasting/raycasting.md | 15 +- modules/raycasting/src/RayCaster.cpp | 4 + .../raycasting/src/remove_occluded_facets.cpp | 2 + .../src/remove_occluded_instances.cpp | 2 + .../scene/python/scripts/extract_texture.py | 10 +- modules/scene/python/src/bind_scene.h | 45 +- modules/scene/python/src/bind_simple_scene.h | 17 +- modules/scene/scene.md | 10 + .../lagrange/serialization/serialize_mesh.h | 5 +- .../lagrange/serialization/serialize_scene.h | 2 +- .../serialization/serialize_simple_scene.h | 2 +- modules/serialization2/serialization2.md | 10 + modules/solver/CMakeLists.txt | 16 +- modules/solver/solver.md | 9 + .../subdivision/src/TopologyRefinerFactory.h | 4 + .../examples/texture_processing_gui.cpp | 2 + .../python/scripts/geodesic_dilation.py | 4 +- .../python/scripts/texture_from_multiview.py | 4 +- modules/texproc/python/src/texproc.cpp | 20 +- .../python/tests/test_mesh_with_alpha_mask.py | 8 +- modules/ui/README.md | 693 --------------- .../ui/include/lagrange/ui/imgui/imconfig.h | 4 + .../lagrange/ui/systems/update_mesh_hovered.h | 2 +- .../lagrange/ui/systems/update_scene_bounds.h | 2 +- modules/ui/include/lagrange/ui/types/AABB.h | 4 +- modules/ui/include/lagrange/ui/types/Camera.h | 6 +- .../ui/include/lagrange/ui/types/Keybinds.h | 1 - modules/ui/include/lagrange/ui/types/Shader.h | 18 +- .../ui/include/lagrange/ui/types/Systems.h | 4 +- modules/ui/include/lagrange/ui/utils/bounds.h | 4 +- modules/ui/include/lagrange/ui/utils/events.h | 1 - modules/ui/include/lagrange/ui/utils/ibl.h | 6 +- modules/ui/include/lagrange/ui/utils/io.h | 3 +- modules/ui/include/lagrange/ui/utils/math.h | 2 +- modules/ui/include/lagrange/ui/utils/render.h | 3 +- .../ui/include/lagrange/ui/utils/template.h | 2 + .../ui/include/lagrange/ui/utils/treenode.h | 10 +- .../ui/include/lagrange/ui/utils/uipanel.h | 6 +- modules/ui/src/Viewer.cpp | 8 +- modules/ui/src/default_components.cpp | 7 +- modules/ui/src/imgui/UIWidget.cpp | 11 +- modules/ui/src/imgui/buttons.cpp | 6 +- modules/ui/src/imgui/progress.cpp | 2 +- modules/ui/src/panels/KeybindsPanel.cpp | 3 +- modules/ui/src/panels/LoggerPanel.cpp | 17 +- modules/ui/src/panels/ViewportPanel.cpp | 11 +- .../ui/src/systems/update_mesh_hovered.cpp | 2 +- modules/ui/src/types/AABB.cpp | 4 +- modules/ui/src/types/Camera.cpp | 9 +- modules/ui/src/types/Shader.cpp | 16 +- modules/ui/src/utils/render.cpp | 5 +- modules/volume/examples/grid_viewer.cpp | 2 + modules/volume/examples/voxelize_gui.cpp | 2 + modules/volume/python/src/volume.cpp | 4 +- modules/volume/python/tests/test_volume.py | 7 +- modules/volume/volume.md | 10 + modules/winding/CMakeLists.txt | 5 + modules/winding/python/CMakeLists.txt | 12 + .../python/include/lagrange/python/winding.h | 22 + modules/winding/python/src/winding.cpp | 132 +++ .../python/tests/test_fast_winding_number.py | 58 ++ modules/winding/winding.md | 7 + modules/xatlas/CMakeLists.txt | 38 + modules/xatlas/examples/CMakeLists.txt | 16 + modules/xatlas/examples/unwrap_xatlas.cpp | 134 +++ .../xatlas/include/lagrange/xatlas/Options.h | 207 +++++ modules/xatlas/include/lagrange/xatlas/api.h | 34 + .../include/lagrange/xatlas/repack_mesh.h | 45 + .../include/lagrange/xatlas/repack_scene.h | 48 ++ .../include/lagrange/xatlas/unwrap_mesh.h | 44 + .../include/lagrange/xatlas/unwrap_scene.h | 49 ++ modules/xatlas/python/CMakeLists.txt | 12 + .../python/include/lagrange/python/xatlas.h | 18 + modules/xatlas/python/src/xatlas.cpp | 561 ++++++++++++ modules/xatlas/python/tests/conftest.py | 46 + modules/xatlas/python/tests/test_repack.py | 27 + modules/xatlas/python/tests/test_unwrap.py | 63 ++ modules/xatlas/src/AtlasEngine.cpp | 424 +++++++++ modules/xatlas/src/AtlasEngine.h | 121 +++ modules/xatlas/src/internal/atlas_to_mesh.cpp | 214 +++++ modules/xatlas/src/internal/atlas_to_mesh.h | 41 + .../xatlas/src/internal/mesh_to_xatlas.cpp | 423 +++++++++ modules/xatlas/src/internal/mesh_to_xatlas.h | 107 +++ .../xatlas/src/internal/progress_bridge.cpp | 115 +++ modules/xatlas/src/internal/progress_bridge.h | 65 ++ modules/xatlas/src/repack_mesh.cpp | 55 ++ modules/xatlas/src/repack_scene.cpp | 132 +++ modules/xatlas/src/unwrap_mesh.cpp | 55 ++ modules/xatlas/src/unwrap_scene.cpp | 244 ++++++ modules/xatlas/tests/CMakeLists.txt | 12 + modules/xatlas/tests/test_atlas_engine.cpp | 204 +++++ modules/xatlas/tests/test_repack_mesh.cpp | 247 ++++++ modules/xatlas/tests/test_unwrap_mesh.cpp | 175 ++++ modules/xatlas/tests/test_unwrap_scene.cpp | 98 +++ modules/xatlas/xatlas.md | 15 + pyproject.toml | 2 + 236 files changed, 9362 insertions(+), 1629 deletions(-) create mode 100644 cmake/recipes/external/implot.cmake create mode 100644 cmake/recipes/external/xatlas.cmake create mode 100644 docs/cpp/pixi.toml create mode 100644 modules/core/include/lagrange/internal/clipped_triangle_circumcenter.h create mode 100644 modules/core/include/lagrange/internal/interpolate_attribute_row.h create mode 100644 modules/core/include/lagrange/internal/set_indexed_values.h delete mode 100644 modules/core/include/lagrange/internal/set_invalid_indexed_values.h create mode 100644 modules/core/include/lagrange/mesh_cleanup/split_obtuse_triangles.h create mode 100644 modules/core/include/lagrange/types/DualConnectivityType.h create mode 100644 modules/core/python/include/lagrange/python/utils/StubType.h create mode 100644 modules/core/python/tests/test_split_obtuse_triangles.py create mode 100644 modules/core/src/mesh_cleanup/split_obtuse_triangles.cpp create mode 100644 modules/core/tests/mesh_cleanup/test_split_obtuse_triangles.cpp create mode 100644 modules/core/tests/test_clipped_triangle_circumcenter.cpp create mode 100644 modules/core/tests/test_set_indexed_values.cpp delete mode 100644 modules/core/tests/test_set_invalid_indexed_values.cpp create mode 100644 modules/geodesic/geodesic.md create mode 100644 modules/image_io/image_io.md create mode 100644 modules/packing/packing.md create mode 100644 modules/poisson/poisson.md create mode 100644 modules/scene/scene.md create mode 100644 modules/serialization2/serialization2.md create mode 100644 modules/solver/solver.md delete mode 100644 modules/ui/README.md create mode 100644 modules/volume/volume.md create mode 100644 modules/winding/python/CMakeLists.txt create mode 100644 modules/winding/python/include/lagrange/python/winding.h create mode 100644 modules/winding/python/src/winding.cpp create mode 100644 modules/winding/python/tests/test_fast_winding_number.py create mode 100644 modules/winding/winding.md create mode 100644 modules/xatlas/CMakeLists.txt create mode 100644 modules/xatlas/examples/CMakeLists.txt create mode 100644 modules/xatlas/examples/unwrap_xatlas.cpp create mode 100644 modules/xatlas/include/lagrange/xatlas/Options.h create mode 100644 modules/xatlas/include/lagrange/xatlas/api.h create mode 100644 modules/xatlas/include/lagrange/xatlas/repack_mesh.h create mode 100644 modules/xatlas/include/lagrange/xatlas/repack_scene.h create mode 100644 modules/xatlas/include/lagrange/xatlas/unwrap_mesh.h create mode 100644 modules/xatlas/include/lagrange/xatlas/unwrap_scene.h create mode 100644 modules/xatlas/python/CMakeLists.txt create mode 100644 modules/xatlas/python/include/lagrange/python/xatlas.h create mode 100644 modules/xatlas/python/src/xatlas.cpp create mode 100644 modules/xatlas/python/tests/conftest.py create mode 100644 modules/xatlas/python/tests/test_repack.py create mode 100644 modules/xatlas/python/tests/test_unwrap.py create mode 100644 modules/xatlas/src/AtlasEngine.cpp create mode 100644 modules/xatlas/src/AtlasEngine.h create mode 100644 modules/xatlas/src/internal/atlas_to_mesh.cpp create mode 100644 modules/xatlas/src/internal/atlas_to_mesh.h create mode 100644 modules/xatlas/src/internal/mesh_to_xatlas.cpp create mode 100644 modules/xatlas/src/internal/mesh_to_xatlas.h create mode 100644 modules/xatlas/src/internal/progress_bridge.cpp create mode 100644 modules/xatlas/src/internal/progress_bridge.h create mode 100644 modules/xatlas/src/repack_mesh.cpp create mode 100644 modules/xatlas/src/repack_scene.cpp create mode 100644 modules/xatlas/src/unwrap_mesh.cpp create mode 100644 modules/xatlas/src/unwrap_scene.cpp create mode 100644 modules/xatlas/tests/CMakeLists.txt create mode 100644 modules/xatlas/tests/test_atlas_engine.cpp create mode 100644 modules/xatlas/tests/test_repack_mesh.cpp create mode 100644 modules/xatlas/tests/test_unwrap_mesh.cpp create mode 100644 modules/xatlas/tests/test_unwrap_scene.cpp create mode 100644 modules/xatlas/xatlas.md diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index bd8cf1aa..bfa9289e 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -195,13 +195,14 @@ jobs: #################### Windows: - name: windows-2025 (${{ matrix.config }}) - runs-on: windows-2025 + name: ${{ matrix.os }} (${{ matrix.config }}) + runs-on: ${{ matrix.os }} env: SCCACHE_GHA_ENABLED: "true" strategy: fail-fast: false matrix: + os: [windows-2025] config: [Release, Debug] steps: - name: Show disk space @@ -214,9 +215,26 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v5 + if: matrix.os != 'windows-11-arm' with: python-version: 3.13 + # On windows-11-arm the hostedtoolcache Python ships only the interpreter binary; + # it lacks include/ headers and libs/python3XX.lib, so CMake cannot satisfy the + # Development.Module component. Use uv instead: it pulls python-build-standalone + # distributions which include full dev files. uv defaults to x64-emulated Python + # on ARM64 Windows (uv PR #13724), so we must pin the aarch64 specifier. + - uses: astral-sh/setup-uv@v6 + if: matrix.os == 'windows-11-arm' + + - name: Install native ARM64 Python via uv + if: matrix.os == 'windows-11-arm' + shell: pwsh + run: | + uv python install cpython-3.13-windows-aarch64 + $pyExe = (uv python find cpython-3.13-windows-aarch64).Trim() + echo "PYTHON_ARM64_EXE=$($pyExe -replace '\\', '/')" >> $env:GITHUB_ENV + - name: Install Ninja uses: seanmiddleditch/gha-setup-ninja@master @@ -227,10 +245,6 @@ jobs: # starving sccache of requests until the default 600s timeout kills the server. echo "SCCACHE_IDLE_TIMEOUT=0" >> ${env:GITHUB_ENV} - - name: Select embree isa (Windows) - if: runner.os == 'Windows' - run: echo "embree_max_isa=AVX2" >> ${env:GITHUB_ENV} - - name: Get number of CPU cores uses: SimenB/github-actions-cpu-cores@v1 id: cpu-cores @@ -238,22 +252,55 @@ jobs: - name: Sccache 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 `) - - name: Configure and build - shell: cmd + - name: Set x64 vars + if: matrix.os == 'windows-2025' + run: | + echo "BUILD_DIR=D:/build" >> ${env:GITHUB_ENV} + echo "ARCH=x64" >> ${env:GITHUB_ENV} + + - name: Set arm64 vars + if: matrix.os == 'windows-11-arm' + run: | + echo "BUILD_DIR=C:/build" >> ${env:GITHUB_ENV} + echo "ARCH=arm64" >> ${env:GITHUB_ENV} + + - name: Setup MSVC Developer Command Prompt + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: ${{ env.ARCH }} + + # Cmd uses ^ to break commands into multiple lines, powershell uses ` + - name: Configure + if: matrix.os != 'windows-11-arm' + run: | + cmake --version + cmake -G Ninja ` + -DCMAKE_BUILD_TYPE=${{ matrix.config }} ` + -DLAGRANGE_JENKINS=ON ` + -DLAGRANGE_ALL=ON ` + -DLAGRANGE_POLYSCOPE_MOCK_BACKEND=ON ` + -DEMBREE_MAX_ISA=AVX2 ` + -B ${{ env.BUILD_DIR }} ` + -S . + + # Force CMake to use the ARM64 Python (now complete with dev files) + # and skip the Windows registry so it doesn't fall back to x64 Python. + - name: Configure (ARM64) + if: matrix.os == 'windows-11-arm' run: | - call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat" -arch=x64 cmake --version - cmake -G Ninja ^ - -DCMAKE_BUILD_TYPE=${{ matrix.config }} ^ - -DLAGRANGE_JENKINS=ON ^ - -DLAGRANGE_ALL=ON ^ - -DLAGRANGE_POLYSCOPE_MOCK_BACKEND=ON ^ - -DEMBREE_MAX_ISA=${{ env.embree_max_isa }} ^ - -B "D:/build" ^ + cmake -G Ninja ` + -DCMAKE_BUILD_TYPE=${{ matrix.config }} ` + -DLAGRANGE_JENKINS=ON ` + -DLAGRANGE_ALL=ON ` + -DLAGRANGE_POLYSCOPE_MOCK_BACKEND=ON ` + -DPython_EXECUTABLE="$env:PYTHON_ARM64_EXE" ` + -DPython_FIND_REGISTRY=NEVER ` + -B ${{ env.BUILD_DIR }} ` -S . - cmake --build "D:/build" -j ${{ steps.cpu-cores.outputs.count }} + + - name: Build + run: cmake --build ${{ env.BUILD_DIR }} -j ${{ steps.cpu-cores.outputs.count }} - name: Sccache stats if: always() @@ -265,4 +312,4 @@ jobs: run: Get-PSDrive - name: Tests - run: cd "D:/build"; ctest --verbose -j ${{ steps.cpu-cores.outputs.count }} + run: cd ${{ env.BUILD_DIR }}; ctest --verbose -j ${{ steps.cpu-cores.outputs.count }} diff --git a/.gitignore b/.gitignore index 08992044..a2678c70 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ tensor_*.vdb func_*.json # Extra directories +/.cursor /.vscode /.vs /.idea @@ -86,6 +87,10 @@ func_*.json /data /docs/cpp/build +# pixi environments (doc tooling, see docs/cpp/pixi.toml) +.pixi +pixi.lock + # Lagrange specifics LagrangeOptions.cmake # The MetaBuild equivalent diff --git a/CMakeLists.txt b/CMakeLists.txt index c34b82cc..193f26f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,6 +114,20 @@ if(LAGRANGE_TOPLEVEL_PROJECT) set(CMAKE_EXPORT_COMPILE_COMMANDS ON CACHE BOOL "Enable/Disable output of compile commands during generation.") endif() +if(WIN32 AND SKBUILD) + include(cmake/lagrange/lagrange_cpm_cache.cmake) + include(cmake/recipes/external/CPM.cmake) + CPMAddPackage( + NAME windowstoolchain + GIT_REPOSITORY https://github.com/MarkSchofield/WindowsToolchain.git + GIT_TAG v0.14.0 + QUIET + DOWNLOAD_ONLY ON + ) + FetchContent_GetProperties(windowstoolchain) + set(CMAKE_TOOLCHAIN_FILE "${windowstoolchain_SOURCE_DIR}/Windows.MSVC.toolchain.cmake") +endif() + ################################################################################ if(SKBUILD) diff --git a/LagrangeOptions.cmake.sample b/LagrangeOptions.cmake.sample index ed69a040..956db65a 100644 --- a/LagrangeOptions.cmake.sample +++ b/LagrangeOptions.cmake.sample @@ -72,6 +72,7 @@ # option(LAGRANGE_MODULE_UI "Build module lagrange::ui" ON) # option(LAGRANGE_MODULE_VOLUME "Build module lagrange::volume" ON) # option(LAGRANGE_MODULE_WINDING "Build module lagrange::winding" ON) +# option(LAGRANGE_MODULE_XATLAS "Build module lagrange::xatlas" ON) # General options # option(LAGRANGE_BINDINGS_JS "Build JavaScript/WebAssembly bindings" OFF) diff --git a/cmake/lagrange/lagrange_vcpkg_toolchain.cmake b/cmake/lagrange/lagrange_vcpkg_toolchain.cmake index 727d2be7..051c196b 100644 --- a/cmake/lagrange/lagrange_vcpkg_toolchain.cmake +++ b/cmake/lagrange/lagrange_vcpkg_toolchain.cmake @@ -48,7 +48,7 @@ if(WIN32) CPMAddPackage( NAME windowstoolchain GIT_REPOSITORY https://github.com/MarkSchofield/WindowsToolchain.git - GIT_TAG v0.13.0 + GIT_TAG v0.14.0 QUIET DOWNLOAD_ONLY ON ) diff --git a/cmake/recipes/external/gklib.cmake b/cmake/recipes/external/gklib.cmake index d62b7ad5..ac26186d 100644 --- a/cmake/recipes/external/gklib.cmake +++ b/cmake/recipes/external/gklib.cmake @@ -19,14 +19,14 @@ include(CPM) CPMAddPackage( NAME gklib GITHUB_REPOSITORY KarypisLab/GKlib - GIT_TAG 67c6e4322bb326a04727995775c3eafc47d7a252 + GIT_TAG e2856c2f595b153ca1ce9258c5301dbabc4f39f5 DOWNLOAD_ONLY ON ) -file(GLOB INC_FILES "${gklib_SOURCE_DIR}/*.h" ) -file(GLOB SRC_FILES "${gklib_SOURCE_DIR}/*.c" ) +file(GLOB INC_FILES "${gklib_SOURCE_DIR}/include/*.h" ) +file(GLOB SRC_FILES "${gklib_SOURCE_DIR}/src/*.c" ) if(NOT MSVC) - list(REMOVE_ITEM SRC_FILES "${gklib_SOURCE_DIR}/gkregex.c") + list(REMOVE_ITEM SRC_FILES "${gklib_SOURCE_DIR}/src/gkregex.c") endif() add_library(GKlib STATIC ${INC_FILES} ${SRC_FILES}) @@ -35,11 +35,20 @@ add_library(GKlib::GKlib ALIAS GKlib) if(MSVC) target_compile_definitions(GKlib PUBLIC USE_GKREGEX) target_compile_definitions(GKlib PUBLIC "__thread=__declspec(thread)") + # gk_ms_stdint.h / gk_ms_inttypes.h are 2006-era polyfills for pre-VS2010 MSVC. + # Modern MSVC (VS2010+) ships natively, but on ARM64 it defines + # int_fast16_t as 'int' (32-bit) while the polyfill defines it as 'int16_t', + # causing a redefinition error. Suppress the polyfills via their include guards + # and force-include the real system header so the types are still available. + # Upstream fix: https://github.com/KarypisLab/GKlib/pull/59 + # (remove this workaround once it lands in our pinned GIT_TAG above). + target_compile_definitions(GKlib PUBLIC _MSC_STDINT_H_ _MSC_INTTYPES_H_) + target_compile_options(GKlib PUBLIC "/FIstdint.h" "/FIinttypes.h") endif() include(GNUInstallDirs) target_include_directories(GKlib SYSTEM PUBLIC - "$" + "$" "$" ) diff --git a/cmake/recipes/external/imgui.cmake b/cmake/recipes/external/imgui.cmake index de4af406..6f1fc9c4 100644 --- a/cmake/recipes/external/imgui.cmake +++ b/cmake/recipes/external/imgui.cmake @@ -33,10 +33,12 @@ message(STATUS "Third-party (external): creating target 'imgui::imgui' ('docking block() set(BUILD_SHARED_LIBS OFF) include(CPM) + set(IMGUI_BACKEND_RENDERER "opengl3;null") + set(IMGUI_BACKEND_PLATFORM "glfw;null") CPMAddPackage( NAME imgui GITHUB_REPOSITORY adobe/imgui - GIT_TAG dff188effaa59c5c4d502868b96bd717207adb9c # docking_v1.91.5 + GIT_TAG 8c63f38b165efed6b1ba7bc638b698ab4ca3f36b # docking_v1.92.8 ) endblock() diff --git a/cmake/recipes/external/imgui_fonts.cmake b/cmake/recipes/external/imgui_fonts.cmake index 7205250f..74cc9fdd 100644 --- a/cmake/recipes/external/imgui_fonts.cmake +++ b/cmake/recipes/external/imgui_fonts.cmake @@ -10,18 +10,32 @@ # governing permissions and limitations under the License. # +# Ensure IconFontCppHeaders target exists +if(NOT TARGET juliettef::IconFontCppHeaders) + message(STATUS "Third-party (external): creating target 'juliettef::IconFontCppHeaders'") + include(CPM) + CPMAddPackage( + NAME IconFontCppHeaders + GITHUB_REPOSITORY juliettef/IconFontCppHeaders + GIT_TAG 8a381189ecfe58e732466cc52e79ca887dd6a297 + ) + + add_library(IconFontCppHeadersPrime INTERFACE) + target_sources(IconFontCppHeadersPrime PUBLIC "${IconFontCppHeaders_SOURCE_DIR}/IconsFontAwesome6.h") + target_include_directories(IconFontCppHeadersPrime INTERFACE "${IconFontCppHeaders_SOURCE_DIR}") + + add_library(juliettef::IconFontCppHeaders ALIAS IconFontCppHeadersPrime) +endif() + # The fonts repo does not add any target by default, but it does add this function if(NOT COMMAND fonts_add_font) - message(STATUS "Third-party (external): creating target 'imgui::fonts'") - include(CPM) CPMAddPackage( NAME imgui_fonts GITHUB_REPOSITORY HasKha/imgui-fonts GIT_TAG aa4a4c83be6a6b275a74809e853fd272b4eaaaa1 ) - endif() block() @@ -32,4 +46,4 @@ endblock() set_target_properties(fonts_fontawesome6 PROPERTIES FOLDER third_party) set_target_properties(fonts_source_sans_pro_regular PROPERTIES FOLDER third_party) -set_target_properties(IconFontCppHeaders PROPERTIES FOLDER third_party) +set_target_properties(IconFontCppHeadersPrime PROPERTIES FOLDER third_party) diff --git a/cmake/recipes/external/imguizmo.cmake b/cmake/recipes/external/imguizmo.cmake index 2ceae42d..3df47f1b 100644 --- a/cmake/recipes/external/imguizmo.cmake +++ b/cmake/recipes/external/imguizmo.cmake @@ -18,18 +18,13 @@ message(STATUS "Third-party (external): creating target 'imguizmo::imguizmo'") include(CPM) CPMAddPackage( NAME imguizmo - GITHUB_REPOSITORY CedricGuillemet/ImGuizmo - GIT_TAG e3174578bdc99c715e51c5ad88e7d50b4eeb19b0 + GITHUB_REPOSITORY jdumas/ImGuizmo + GIT_TAG 097e4da69386a6351ec441b7839a3b385eec6a0e + OPTIONS "IMGUIZMO_BUILD_EXAMPLE OFF" ) -add_library(imguizmo STATIC - "${imguizmo_SOURCE_DIR}/ImGuizmo.h" - "${imguizmo_SOURCE_DIR}/ImGuizmo.cpp" -) -add_library(imguizmo::imguizmo ALIAS imguizmo) - -target_include_directories(imguizmo PUBLIC "${imguizmo_SOURCE_DIR}") - +# The upstream CMakeLists.txt defines imguizmo::imguizmo but does not link imgui. +# We add the dependency here so our build picks up the correct imgui target. include(imgui) target_link_libraries(imguizmo PUBLIC imgui::imgui) diff --git a/cmake/recipes/external/implot.cmake b/cmake/recipes/external/implot.cmake new file mode 100644 index 00000000..20923d2d --- /dev/null +++ b/cmake/recipes/external/implot.cmake @@ -0,0 +1,42 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +if(TARGET implot::implot) + return() +endif() + +message(STATUS "Third-party (external): creating target 'implot::implot'") + +include(CPM) +CPMAddPackage( + NAME implot + GITHUB_REPOSITORY epezent/implot + GIT_TAG d65a2bef53d32502407de3a4be80f191e2f412d7 # post-v1.0, includes imgui 1.92.8 AddRect signature fix (#703) +) + +add_library(implot STATIC) +target_sources(implot + PUBLIC + "${implot_SOURCE_DIR}/implot.h" + PRIVATE + "${implot_SOURCE_DIR}/implot_internal.h" + "${implot_SOURCE_DIR}/implot.cpp" + "${implot_SOURCE_DIR}/implot_items.cpp" +) +add_library(implot::implot ALIAS implot) + +target_include_directories(implot PUBLIC "${implot_SOURCE_DIR}") + +include(imgui) +target_link_libraries(implot PUBLIC imgui::imgui) + +set_target_properties(implot PROPERTIES POSITION_INDEPENDENT_CODE ON) +set_target_properties(implot PROPERTIES FOLDER third_party) diff --git a/cmake/recipes/external/polyscope.cmake b/cmake/recipes/external/polyscope.cmake index 85a73cd2..c8f86dbb 100644 --- a/cmake/recipes/external/polyscope.cmake +++ b/cmake/recipes/external/polyscope.cmake @@ -24,17 +24,27 @@ include(glfw) include(imgui) include(nlohmann_json) include(glad) +include(implot) +include(imguizmo) block() include(CPM) set(BUILD_SHARED_LIBS OFF) CPMAddPackage( NAME polyscope - GITHUB_REPOSITORY nmwsharp/polyscope - GIT_TAG f0245f375f4585c0fdd5d57179c28f25aa03cf22 + GITHUB_REPOSITORY jdumas/polyscope + GIT_TAG 3e57795adc4eefae5ec8c4a5afb03ef62e99c110 # jdumas/imgui: imgui 1.92.8 + AddRect fix + ImGuizmo -> nmwsharp@097e4da ) endblock() +# polyscope expects imgui::imgui to contains implot and imguizmo. +# explicitly link them here to ensure proper build. +target_link_libraries(polyscope + PUBLIC + implot::implot + imguizmo::imguizmo +) + add_library(polyscope::polyscope ALIAS polyscope) set_target_properties(polyscope PROPERTIES FOLDER third_party) set_target_properties(glm PROPERTIES FOLDER third_party) diff --git a/cmake/recipes/external/simde.cmake b/cmake/recipes/external/simde.cmake index cffa746e..9a5f6cae 100644 --- a/cmake/recipes/external/simde.cmake +++ b/cmake/recipes/external/simde.cmake @@ -19,14 +19,8 @@ include(CPM) CPMAddPackage( NAME simde GITHUB_REPOSITORY simd-everywhere/simde - GIT_TAG 48edfa906d835525e2061fbf6062b7c326d66840 + GIT_TAG 1747b2482589fe894d49989159421da08c2a8bcd ) -add_library(simde::simde INTERFACE IMPORTED GLOBAL) -target_include_directories(simde::simde INTERFACE "${simde_SOURCE_DIR}") - # Enables native aliases. Not ideal but makes it easier to convert old code. -target_compile_definitions(simde::simde INTERFACE SIMDE_ENABLE_NATIVE_ALIASES) - -# Uncomment this line to ensure code can be compiled without native SIMD (i.e. emulates everything) -# target_compile_definitions(simde::simde INTERFACE SIMDE_NO_NATIVE) +target_compile_definitions(simde INTERFACE SIMDE_ENABLE_NATIVE_ALIASES) diff --git a/cmake/recipes/external/winding_number.cmake b/cmake/recipes/external/winding_number.cmake index 970709f4..93753f9e 100644 --- a/cmake/recipes/external/winding_number.cmake +++ b/cmake/recipes/external/winding_number.cmake @@ -22,7 +22,7 @@ include(CPM) CPMAddPackage( NAME WindingNumber GITHUB_REPOSITORY jdumas/WindingNumber - GIT_TAG a48b8f555b490afe7aab9159c7daaf83fa2cdf8e + GIT_TAG 81443613dd2ab66000249401f10589f9794dc048 ) set_target_properties(WindingNumber PROPERTIES FOLDER third_party) diff --git a/cmake/recipes/external/xatlas.cmake b/cmake/recipes/external/xatlas.cmake new file mode 100644 index 00000000..d31b7211 --- /dev/null +++ b/cmake/recipes/external/xatlas.cmake @@ -0,0 +1,53 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +if(TARGET xatlas::xatlas) + return() +endif() + +message(STATUS "Third-party (external): creating target 'xatlas::xatlas'") + +include(CPM) +CPMAddPackage( + NAME xatlas + GITHUB_REPOSITORY jdumas/xatlas + GIT_TAG 649fae81dd20801db55338bc86e0a7eab745ddf4 + DOWNLOAD_ONLY ON +) + +add_library(xatlas STATIC + ${xatlas_SOURCE_DIR}/source/xatlas/xatlas.cpp + ${xatlas_SOURCE_DIR}/source/xatlas/xatlas.h +) + +set_target_properties(xatlas PROPERTIES + POSITION_INDEPENDENT_CODE ON + FOLDER "third_party" +) + +target_include_directories(xatlas PUBLIC + ${xatlas_SOURCE_DIR}/source +) + +add_library(xatlas::xatlas ALIAS xatlas) + +# Install rules. Use per-install COMPONENT to avoid leaking +# CMAKE_INSTALL_DEFAULT_COMPONENT_NAME to other recipes processed after this one. +install(DIRECTORY ${xatlas_BINARY_DIR}/source/xatlas + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} + COMPONENT xatlas +) +install(TARGETS xatlas EXPORT xatlas_Targets COMPONENT xatlas) +install(EXPORT xatlas_Targets + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/xatlas + NAMESPACE xatlas:: + COMPONENT xatlas +) diff --git a/docs/cpp/Doxyfile b/docs/cpp/Doxyfile index b71e63bd..80814432 100644 --- a/docs/cpp/Doxyfile +++ b/docs/cpp/Doxyfile @@ -892,7 +892,7 @@ WARNINGS = YES # will automatically be disabled. # The default value is: YES. -WARN_IF_UNDOCUMENTED = YES +WARN_IF_UNDOCUMENTED = NO # If the WARN_IF_DOC_ERROR tag is set to YES, Doxygen will generate warnings for # potential errors in the documentation, such as documenting some parameters in @@ -949,7 +949,7 @@ WARN_LAYOUT_FILE = YES # Possible values are: NO, YES, FAIL_ON_WARNINGS and FAIL_ON_WARNINGS_PRINT. # The default value is: NO. -WARN_AS_ERROR = NO +WARN_AS_ERROR = FAIL_ON_WARNINGS # The WARN_FORMAT tag determines the format of the warning messages that Doxygen # can produce. The string should contain the $file, $line, and $text tags, which @@ -1125,7 +1125,7 @@ EXCLUDE_SYMBOLS = # that contain example code fragments that are included (see the \include # command). -EXAMPLE_PATH = examples +EXAMPLE_PATH = # If the value of the EXAMPLE_PATH tag contains directories, you can use the # EXAMPLE_PATTERNS tag to specify one or more wildcard pattern (like *.cpp and diff --git a/docs/cpp/pixi.toml b/docs/cpp/pixi.toml new file mode 100644 index 00000000..f8480b3e --- /dev/null +++ b/docs/cpp/pixi.toml @@ -0,0 +1,13 @@ +# Pixi manifest for the C++ (Doxygen) documentation tooling. +# +# The doxygen version is pinned to match the one used to render the documentation website. + +[workspace] +channels = ["conda-forge"] +platforms = ["linux-64", "osx-arm64", "win-64"] + +[dependencies] +doxygen = "~=1.13.2" + +[environments] +docs = { features = [], solve-group = "docs" } diff --git a/modules/bvh/examples/uv_overlap.cpp b/modules/bvh/examples/uv_overlap.cpp index c116a9bf..cea7d4c1 100644 --- a/modules/bvh/examples/uv_overlap.cpp +++ b/modules/bvh/examples/uv_overlap.cpp @@ -42,6 +42,8 @@ #include // clang-format on +#include + #include // ============================================================================ diff --git a/modules/bvh/include/lagrange/bvh/EdgeAABBTree.h b/modules/bvh/include/lagrange/bvh/EdgeAABBTree.h index b6bb498c..6f444bfc 100644 --- a/modules/bvh/include/lagrange/bvh/EdgeAABBTree.h +++ b/modules/bvh/include/lagrange/bvh/EdgeAABBTree.h @@ -118,7 +118,7 @@ struct EdgeAABBTree function_ref filter_func = [](Index) { return true; }) const; }; -/// @ +/// @} } // namespace bvh } // namespace lagrange diff --git a/modules/bvh/include/lagrange/bvh/compute_intersecting_pairs.h b/modules/bvh/include/lagrange/bvh/compute_intersecting_pairs.h index e2605953..4935140e 100644 --- a/modules/bvh/include/lagrange/bvh/compute_intersecting_pairs.h +++ b/modules/bvh/include/lagrange/bvh/compute_intersecting_pairs.h @@ -17,11 +17,7 @@ namespace lagrange::bvh { /// -/// @defgroup group-bvh-intersecting-pairs Intersecting Pairs -/// @ingroup group-bvh -/// -/// Compute intersecting facet pairs in a mesh using BVH acceleration. -/// +/// @addtogroup module-bvh /// @{ /// diff --git a/modules/bvh/python/src/bvh.cpp b/modules/bvh/python/src/bvh.cpp index 97991335..b4eb1fdf 100644 --- a/modules/bvh/python/src/bvh.cpp +++ b/modules/bvh/python/src/bvh.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include "PyEdgeAABBTree.h" @@ -28,6 +29,11 @@ using namespace nb::literals; namespace lagrange::python { +// Renders the dynamically-built `UVOverlapResult` NamedTuple (a runtime nb::object) +// with the right stub type. TODO: retire once nanobind supports NamedTuple directly. +LA_STUB_HINT(UVOverlapResultHint, "UVOverlapResult"); +using UVOverlapResultObject = StubType; + void populate_bvh_module(nb::module_& m) { using Scalar = double; @@ -508,7 +514,7 @@ Both meshes must have the same spatial dimension and must be triangle meshes. bool compute_overlap_coloring, std::string overlap_coloring_attribute_name, bool compute_overlapping_pairs, - bvh::UVOverlapMethod method) -> nb::object { + bvh::UVOverlapMethod method) -> UVOverlapResultObject { bvh::UVOverlapOptions opts; opts.uv_attribute_name = std::move(uv_attribute_name); opts.compute_overlap_area = compute_overlap_area; @@ -527,11 +533,11 @@ Both meshes must have the same spatial dimension and must be triangle meshes. pairs.append(nb::make_tuple(i, j)); } - return UVOverlapResult( + return UVOverlapResultObject{UVOverlapResult( nb::cast(result.has_overlap), area, pairs, - nb::cast(result.overlap_coloring_id)); + nb::cast(result.overlap_coloring_id))}; }, "mesh"_a, "uv_attribute_name"_a = bvh::UVOverlapOptions{}.uv_attribute_name, diff --git a/modules/core/core.md b/modules/core/core.md index 6d866385..ed971959 100644 --- a/modules/core/core.md +++ b/modules/core/core.md @@ -7,7 +7,8 @@ Core Module @defgroup module-core Core Module @brief Core module for Lagrange. -### Quick links +Quick links +----------- - [SurfaceMesh](@ref group-surfacemesh) - [Mesh](@ref lagrange::Mesh) [deprecated] diff --git a/modules/core/include/lagrange/Attribute.h b/modules/core/include/lagrange/Attribute.h index 0f7266d4..2ce1f7c6 100644 --- a/modules/core/include/lagrange/Attribute.h +++ b/modules/core/include/lagrange/Attribute.h @@ -297,7 +297,7 @@ class Attribute : public AttributeBase /// attribute. Note that only the number of element is allowed to change when wrapping an /// external buffer (the number of channels is fixed during the attribute construction). /// - /// @param[in] shared_buffer Pointer to an external buffer managed by a SharedSpan to be used + /// @param[in] buffer_ptr Pointer to an external buffer managed by a SharedSpan to be used /// as storage. This pointer exposes a view of the buffer managed by /// the owner. The buffer must have a capacity (determined by /// buffer_ptr.size()) that is large enough to store num elements x diff --git a/modules/core/include/lagrange/ExactPredicatesShewchuk.h b/modules/core/include/lagrange/ExactPredicatesShewchuk.h index 980b75cd..e9c447f4 100644 --- a/modules/core/include/lagrange/ExactPredicatesShewchuk.h +++ b/modules/core/include/lagrange/ExactPredicatesShewchuk.h @@ -32,19 +32,19 @@ class LA_CORE_API ExactPredicatesShewchuk : public ExactPredicates virtual short orient2D(const double p1[2], const double p2[2], const double p3[2]) const; /// - /// @copydoc ExactPredicates::orient2D + /// @copydoc ExactPredicates::orient3D /// virtual short orient3D(const double p1[3], const double p2[3], const double p3[3], const double p4[3]) const; /// - /// @copydoc ExactPredicates::orient2D + /// @copydoc ExactPredicates::incircle /// virtual short incircle(const double p1[2], const double p2[2], const double p3[2], const double p4[2]) const; /// - /// @copydoc ExactPredicates::orient2D + /// @copydoc ExactPredicates::insphere /// virtual short insphere( const double p1[3], diff --git a/modules/core/include/lagrange/SurfaceMesh.h b/modules/core/include/lagrange/SurfaceMesh.h index bbc5ac94..180faaab 100644 --- a/modules/core/include/lagrange/SurfaceMesh.h +++ b/modules/core/include/lagrange/SurfaceMesh.h @@ -1615,7 +1615,6 @@ class SurfaceMesh /// /// @param[in] name The name /// @param value Attribute value. - /// @param id Attribute id. /// void set_metadata(std::string_view name, std::string_view value); diff --git a/modules/core/include/lagrange/compute_facet_facet_adjacency.h b/modules/core/include/lagrange/compute_facet_facet_adjacency.h index 0d8c70eb..fb77f9b4 100644 --- a/modules/core/include/lagrange/compute_facet_facet_adjacency.h +++ b/modules/core/include/lagrange/compute_facet_facet_adjacency.h @@ -12,6 +12,7 @@ #pragma once #include +#include #include namespace lagrange { @@ -25,27 +26,32 @@ namespace lagrange { /// @{ /// -/// Compute facet-facet adjacency information based on shared edges. +/// Compute facet-facet adjacency information. /// -/// Two facets are considered adjacent if they share an edge. For non-manifold edges with 3 or more -/// incident facets, a complete clique is formed (every pair of facets around the edge is adjacent). +/// The connectivity type determines what constitutes adjacency: +/// - ConnectivityType::Edge: Two facets are adjacent if they share an edge. For non-manifold +/// edges with 3 or more incident facets, a complete clique is formed (every pair of facets around +/// the edge is adjacent). +/// - ConnectivityType::Vertex: Two facets are adjacent if they share at least one vertex. /// -/// @note If two facets share multiple edges, the neighboring facet will appear in the -/// adjacency list once for each time the shared edge is referenced by its incident -/// facets (i.e., neighbors are not deduplicated). +/// @note If two facets share multiple edges/vertices (depending on connectivity type), the +/// neighboring facet may appear multiple times in the adjacency list. /// -/// @note This function calls @c initialize_edges() if edges have not been initialized yet, -/// which mutates the mesh. +/// @note Both connectivity types may call @c initialize_edges() as a side effect if edges +/// have not been initialized yet. /// -/// @param mesh The input mesh (edges will be initialized if needed). +/// @param mesh The input mesh (edges may be initialized as a side effect). +/// @param connectivity_type The type of connectivity (edge or vertex). /// -/// @tparam Scalar Mesh scalar type. -/// @tparam Index Mesh index type. +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. /// /// @return The facet-facet adjacency list. /// template -AdjacencyList compute_facet_facet_adjacency(SurfaceMesh& mesh); +AdjacencyList compute_facet_facet_adjacency( + SurfaceMesh& mesh, + ConnectivityType connectivity_type = ConnectivityType::Edge); /// @} diff --git a/modules/core/include/lagrange/compute_pointcloud_pca.h b/modules/core/include/lagrange/compute_pointcloud_pca.h index 58c58b9a..06c06d7d 100644 --- a/modules/core/include/lagrange/compute_pointcloud_pca.h +++ b/modules/core/include/lagrange/compute_pointcloud_pca.h @@ -55,7 +55,7 @@ struct PointcloudPCAOutput * Finds the principal components for a pointcloud * * Assumes that the points are supplied in a matrix where each - * ``row'' is a point. + * "row" is a point. * * This is closely related to the inertia tensor, principal directions * and principal moments. But it is not exactly the same. diff --git a/modules/core/include/lagrange/compute_vertex_vertex_adjacency.h b/modules/core/include/lagrange/compute_vertex_vertex_adjacency.h index 28eaf2d5..ff442a16 100644 --- a/modules/core/include/lagrange/compute_vertex_vertex_adjacency.h +++ b/modules/core/include/lagrange/compute_vertex_vertex_adjacency.h @@ -11,10 +11,8 @@ */ #pragma once -#include -#include - #include +#include #include namespace lagrange { @@ -30,15 +28,26 @@ namespace lagrange { /** * Compute vertex-vertex adjacency information. * - * @tparam Scalar Mesh scalar type. - * @tparam Index Mesh index type. + * Two vertices are considered adjacent based on the connectivity type: + * - DualConnectivityType::Edge: Two vertices are adjacent if they are connected by a mesh edge + * (i.e., they are consecutive vertices in some facet). This is the default. + * - DualConnectivityType::Facet: Two vertices are adjacent if they belong to the same facet + * (includes diagonal connections within a polygon). + * + * The resulting adjacency list is deduplicated: each neighbor appears at most once per vertex. + * + * @tparam Scalar Mesh scalar type. + * @tparam Index Mesh index type. * - * @param mesh The input mesh. + * @param mesh The input mesh. + * @param connectivity_type Adjacency condition (default: Edge). * - * @return The vertex-vertex adjacency data and adjacency indices. + * @return The vertex-vertex adjacency list. */ template -AdjacencyList compute_vertex_vertex_adjacency(SurfaceMesh& mesh); +AdjacencyList compute_vertex_vertex_adjacency( + SurfaceMesh& mesh, + DualConnectivityType connectivity_type = DualConnectivityType::Edge); /// @} diff --git a/modules/core/include/lagrange/experimental/Array.h b/modules/core/include/lagrange/experimental/Array.h index 29900a1e..8f0c306b 100644 --- a/modules/core/include/lagrange/experimental/Array.h +++ b/modules/core/include/lagrange/experimental/Array.h @@ -152,6 +152,7 @@ class ArrayBase /** * Using index function for row mapping. * + * @param num_rows: Number of rows in the output array. * @param mapping_fn: An index mapping function: * input_row_index = mapping_fn(output_row_index)). */ @@ -161,6 +162,7 @@ class ArrayBase /** * This is the most generic version of row_slice method. * + * @param num_rows: Number of rows in the output array. * @param mapping_fn: A mapping function that maps each output row index to * a vector of (input_row_index, weight). */ diff --git a/modules/core/include/lagrange/get_unique_attribute_name.h b/modules/core/include/lagrange/get_unique_attribute_name.h index 63536f5a..0708a359 100644 --- a/modules/core/include/lagrange/get_unique_attribute_name.h +++ b/modules/core/include/lagrange/get_unique_attribute_name.h @@ -19,7 +19,7 @@ namespace lagrange { /// -/// @defgroup group-surfacemesh-utils Mesh utility functions +/// @defgroup group-surfacemesh-utils Mesh utilities /// @ingroup group-surfacemesh /// /// Various attribute and mesh processing utilities diff --git a/modules/core/include/lagrange/internal/clipped_triangle_circumcenter.h b/modules/core/include/lagrange/internal/clipped_triangle_circumcenter.h new file mode 100644 index 00000000..17e86614 --- /dev/null +++ b/modules/core/include/lagrange/internal/clipped_triangle_circumcenter.h @@ -0,0 +1,93 @@ +/* + * Copyright 2024 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 { + +/// +/// Compute the circumcenter of a triangle (p1, p2, p3), clipped to the triangle if it lies +/// outside. Barycentric coordinates of the returned point are written to lambda1, lambda2, +/// lambda3 (each in [0, 1], summing to 1). +/// +template +Eigen::Vector3 clipped_triangle_circumcenter( + const Eigen::Vector3& p1, + const Eigen::Vector3& p2, + const Eigen::Vector3& p3, + Scalar& lambda1, + Scalar& lambda2, + Scalar& lambda3) +{ + [[maybe_unused]] constexpr const Scalar epsilon = 1e-7f; + + using Vec3 = Eigen::Vector3; + + const Vec3 q2 = p2 - p1; + const Vec3 q3 = p3 - p1; + + Scalar l2 = q2.squaredNorm(); + Scalar l3 = q3.squaredNorm(); + + Scalar a12 = -2.0f * q2.dot(q2); + Scalar a13 = -2.0f * q3.dot(q2); + Scalar a22 = -2.0f * q2.dot(q3); + Scalar a23 = -2.0f * q3.dot(q3); + + Scalar c31 = (a23 * a12 - a22 * a13); + Scalar d = c31; + if (std::abs(d) < std::numeric_limits::denorm_min()) { + // Degenerate (collinear) triangle: the circumcenter is undefined. Fall back to the + // triangle barycenter. + lambda1 = lambda2 = lambda3 = Scalar(1) / Scalar(3); + return lambda1 * p1 + lambda2 * p2 + lambda3 * p3; + } + Scalar s = 1.0f / d; + lambda1 = s * ((a23 - a22) * l2 + (a12 - a13) * l3 + c31); + lambda2 = s * ((-a23) * l2 + (a13)*l3); + lambda3 = s * ((a22)*l2 + (-a12) * l3); + + if (lambda1 < 0) { + lambda1 = 0; + la_debug_assert(lambda2 >= 0); + la_debug_assert(lambda3 >= 0); + lambda2 = 0.5f; + lambda3 = 0.5f; + } + + if (lambda2 < 0) { + lambda2 = 0; + la_debug_assert(lambda1 >= 0); + la_debug_assert(lambda3 >= 0); + lambda1 = 0.5f; + lambda3 = 0.5f; + } + + if (lambda3 < 0) { + lambda3 = 0; + la_debug_assert(lambda1 >= 0); + la_debug_assert(lambda2 >= 0); + lambda1 = 0.5f; + lambda2 = 0.5f; + } + + la_debug_assert(std::fabs(lambda1 + lambda2 + lambda3 - 1) < epsilon); + return lambda1 * p1 + lambda2 * p2 + lambda3 * p3; +} + +} // namespace lagrange::internal diff --git a/modules/core/include/lagrange/internal/interpolate_attribute_row.h b/modules/core/include/lagrange/internal/interpolate_attribute_row.h new file mode 100644 index 00000000..76f4a1a0 --- /dev/null +++ b/modules/core/include/lagrange/internal/interpolate_attribute_row.h @@ -0,0 +1,62 @@ +/* + * 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::internal { + +/// +/// Linearly interpolate row `row_to` from rows `row_from_1` and `row_from_2` of an attribute +/// matrix: `data.row(row_to) = (1 - t) * data.row(row_from_1) + t * data.row(row_from_2)`. +/// Integer-valued attributes are interpolated in floating point and rounded. +/// +/// @param[in,out] data Attribute matrix. Row `row_to` is overwritten with the result. +/// @param[in] row_to Destination row index. +/// @param[in] row_from_1 Source row index for weight `(1 - t)`. +/// @param[in] row_from_2 Source row index for weight `t`. +/// @param[in] t Interpolation parameter in [0, 1]. +/// +/// @tparam Derived Eigen matrix expression type. +/// @tparam Scalar Floating-point type used for interpolation weights. +/// @tparam Index Integral row index type. +/// +template +void interpolate_attribute_row( + Eigen::MatrixBase& data, + Index row_to, + Index row_from_1, + Index row_from_2, + Scalar t) +{ + la_debug_assert(row_to < static_cast(data.rows())); + la_debug_assert(row_from_1 < static_cast(data.rows())); + la_debug_assert(row_from_2 < static_cast(data.rows())); + using ValueType = typename Derived::Scalar; + + if constexpr (std::is_integral_v) { + data.row(row_to) = (data.row(row_from_1).template cast() * (1 - t) + + data.row(row_from_2).template cast() * t) + .array() + .round() + .template cast() + .eval(); + } else { + data.row(row_to) = data.row(row_from_1) * (1 - t) + data.row(row_from_2) * t; + } +} + +} // namespace lagrange::internal diff --git a/modules/core/include/lagrange/internal/set_indexed_values.h b/modules/core/include/lagrange/internal/set_indexed_values.h new file mode 100644 index 00000000..9a5251bc --- /dev/null +++ b/modules/core/include/lagrange/internal/set_indexed_values.h @@ -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. + */ +#pragma once + +#include +#include +#include + +namespace lagrange::internal { + +/// +/// @addtogroup group-surfacemesh-attr-utils +/// @{ +/// + +/// +/// For each index matching the given predicate, appends a new value set to +/// `invalid()` and reassigns the index to point to it. +/// +/// @param[in,out] attr The indexed attribute to fix up. +/// @param[in] predicate Returns true for each index that must be remapped. +/// +/// @tparam ValueType Attribute value type. +/// @tparam Index Attribute index type. +/// @tparam Predicate Callable of signature `bool(Index)`. +/// +template +void set_predicated_indexed_values(IndexedAttribute& attr, Predicate&& predicate) +{ + auto indices = attr.indices().ref_all(); + const size_t num_indices = attr.indices().get_num_elements(); + + // Count the number of indices to remap. + size_t num_remap = 0; + for (size_t i = 0; i < num_indices; ++i) { + if (predicate(indices[i])) { + ++num_remap; + } + } + + if (num_remap == 0) return; + + // Append an invalid() value for each index to remap. + const size_t num_valid = attr.values().get_num_elements(); + + const ValueType old_default_value = attr.values().get_default_value(); + auto scope = make_scope_guard([&] { attr.values().set_default_value(old_default_value); }); + attr.values().set_default_value(invalid()); + attr.values().insert_elements(num_remap); + + // Assign each remapped index to its own new value. + Index next_value = static_cast(num_valid); + for (size_t i = 0; i < num_indices; ++i) { + if (predicate(indices[i])) { + indices[i] = next_value++; + } + } +} + +/// +/// For each element in the index buffer set to `invalid()`, appends a new +/// value set to `invalid()` and updates the index to point to it. +/// +/// @param[in,out] attr The indexed attribute to fix up. +/// +/// @tparam ValueType Attribute value type. +/// @tparam Index Attribute index type. +/// +template +void set_invalid_indexed_values(IndexedAttribute& attr) +{ + set_predicated_indexed_values(attr, [](Index index) { return index == invalid(); }); +} + +/// +/// For each element in the index buffer pointing outside the value buffer, appends a new +/// value set to `invalid()` and updates the index to point to it. +/// +/// @param[in,out] attr The indexed attribute to fix up. +/// +/// @tparam ValueType Attribute value type. +/// @tparam Index Attribute index type. +/// +template +void set_out_of_range_indexed_values(IndexedAttribute& attr) +{ + const size_t num_values = attr.values().get_num_elements(); + set_predicated_indexed_values(attr, [num_values](Index index) { + return static_cast(index) >= num_values; + }); +} + +/// @} + +} // namespace lagrange::internal diff --git a/modules/core/include/lagrange/internal/set_invalid_indexed_values.h b/modules/core/include/lagrange/internal/set_invalid_indexed_values.h deleted file mode 100644 index 0cda06f4..00000000 --- a/modules/core/include/lagrange/internal/set_invalid_indexed_values.h +++ /dev/null @@ -1,69 +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 - -namespace lagrange::internal { - -/// -/// @addtogroup group-surfacemesh-attr-utils -/// @{ -/// - -/// -/// For each element in the index buffer set to `invalid()`, appends a new -/// value set to invalid() and updates the index to point to it. -/// -/// @tparam ValueType Attribute value type. -/// @tparam Index Attribute index type. -/// -/// @param[in,out] attr The indexed attribute to fix up. -/// -template -void set_invalid_indexed_values(IndexedAttribute& attr) -{ - auto indices = attr.indices().ref_all(); - const size_t num_indices = attr.indices().get_num_elements(); - - // Count the number of invalid indices. - size_t num_invalid = 0; - for (size_t i = 0; i < num_indices; ++i) { - if (indices[i] == invalid()) { - ++num_invalid; - } - } - - if (num_invalid == 0) return; - - // Append invalid() values for each invalid index. - const size_t num_valid = attr.values().get_num_elements(); - - const ValueType old_default_value = attr.values().get_default_value(); - auto scope = make_scope_guard([&] { attr.values().set_default_value(old_default_value); }); - attr.values().set_default_value(invalid()); - attr.values().insert_elements(num_invalid); - - // Assign each invalid index to its own new value. - Index next_value = static_cast(num_valid); - for (size_t i = 0; i < num_indices; ++i) { - if (indices[i] == invalid()) { - indices[i] = next_value++; - } - } -} - -/// @} - -} // namespace lagrange::internal diff --git a/modules/core/include/lagrange/internal/skinning.h b/modules/core/include/lagrange/internal/skinning.h index 9534107e..673a7b48 100644 --- a/modules/core/include/lagrange/internal/skinning.h +++ b/modules/core/include/lagrange/internal/skinning.h @@ -36,7 +36,7 @@ namespace lagrange::internal { /** - * Performs linear blend skinning deformation on a mesh.. + * Performs linear blend skinning deformation on a mesh. * * @param[in,out] mesh vertices of this mesh will be modified * @param[in] original_vertices original positions of vertices @@ -87,7 +87,7 @@ void skinning_deform( } /** - * Performs linear blend skinning on a mesh, using weights information from the mesh attributes.. + * Performs linear blend skinning on a mesh, using weights information from the mesh attributes. * * @param[in,out] mesh vertices of this mesh will be modified * @param[in] original_vertices original positions of vertices diff --git a/modules/core/include/lagrange/isoline.h b/modules/core/include/lagrange/isoline.h index 7f21e130..f1dc1282 100644 --- a/modules/core/include/lagrange/isoline.h +++ b/modules/core/include/lagrange/isoline.h @@ -40,6 +40,11 @@ struct IsolineOptions /// Whether to keep the part below the isoline. Ignored for isoline extraction. bool keep_below = true; + + /// Whether to propagate input mesh attributes (vertex, facet, corner and indexed) to the output + /// mesh. Attributes are linearly interpolated within each parent facet. When set to false, the + /// output mesh retains only its vertex positions, which avoids the interpolation cost. + bool keep_attributes = true; }; /// @@ -78,5 +83,31 @@ SurfaceMesh extract_isoline( const SurfaceMesh& mesh, const IsolineOptions& options = {}); +/// +/// Insert the isoline of an implicit function into a mesh. Unlike trimming, the whole mesh is +/// retained; facets crossed by the isoline are split so that the isoline appears as a chain of +/// edges in the output. A triangle crossed in its interior is split into a triangle and a quad, so +/// the output is in general a mixed triangle/quad mesh. When the isoline passes exactly through an +/// existing vertex (or lies along an edge), the split degenerates: the triangle may instead be +/// split into two triangles, or left unchanged. Call triangulate_polygonal_facets() afterwards for +/// an all-triangle result. +/// +/// @note The input must be a triangle mesh. +/// +/// @note The `keep_below` option is ignored, since both sides of the isoline are kept. +/// +/// @param[in] mesh Input triangle mesh to insert the isoline into. +/// @param[in] options Isoline options. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return The input mesh with the isoline inserted as a chain of edges. +/// +template +SurfaceMesh insert_isoline( + const SurfaceMesh& mesh, + const IsolineOptions& options = {}); + /// @} } // namespace lagrange diff --git a/modules/core/include/lagrange/mesh_cleanup/remove_duplicate_facets.h b/modules/core/include/lagrange/mesh_cleanup/remove_duplicate_facets.h index 1609049b..bc340190 100644 --- a/modules/core/include/lagrange/mesh_cleanup/remove_duplicate_facets.h +++ b/modules/core/include/lagrange/mesh_cleanup/remove_duplicate_facets.h @@ -40,7 +40,7 @@ struct RemoveDuplicateFacetOptions /// @tparam Scalar Mesh scalar type /// @tparam Index Mesh index type /// -/// @paramp[in,out] mesh Input mesh +/// @param[in,out] mesh Input mesh /// @param[in] opts Options /// /// @note If `opts.consider_orientation` is false, facets with opposite orientations (e.g. (0, 1, 2) diff --git a/modules/core/include/lagrange/mesh_cleanup/split_obtuse_triangles.h b/modules/core/include/lagrange/mesh_cleanup/split_obtuse_triangles.h new file mode 100644 index 00000000..59b0ec4e --- /dev/null +++ b/modules/core/include/lagrange/mesh_cleanup/split_obtuse_triangles.h @@ -0,0 +1,64 @@ +/* + * 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 { + +/// +/// @addtogroup group-surfacemesh-cleanup +/// @{ +/// + +/// +/// Options for the `split_obtuse_triangles` function. +/// +struct SplitObtuseTrianglesOptions +{ + /// Maximum allowed interior angle in radians. Triangles with any interior angle strictly + /// greater than this threshold will be split. Default is `pi/2`, so any triangle with an + /// angle larger than 90 degrees is considered obtuse. + float max_angle = static_cast(lagrange::internal::pi_2); + + /// Maximum number of split passes. Each pass projects the obtuse vertex onto the opposite + /// edge and splits both incident triangles. Use `0` to iterate until convergence. + size_t max_iterations = 5; + + /// Optional facet attribute (`uint8_t`) restricting which facets are considered obtuse + /// candidates. If empty, every facet is checked. + std::string_view active_region_attribute = ""; +}; + +/// +/// Iteratively split obtuse triangles by splitting their longest edge at the projection of the +/// opposite (obtuse) vertex. After each pass, both triangles incident to a split edge are +/// re-tessellated. Vertex attributes are linearly interpolated along the split edge. +/// +/// @param[in,out] mesh Input triangle mesh, updated in place. +/// @param[in] options Optional settings. +/// +/// @return Total number of triangle splits performed across all iterations (a triangle re-split in +/// a later iteration is counted again). +/// +template +size_t split_obtuse_triangles( + SurfaceMesh& mesh, + SplitObtuseTrianglesOptions options = {}); + +/// @} + +} // namespace lagrange diff --git a/modules/core/include/lagrange/remap_vertices.h b/modules/core/include/lagrange/remap_vertices.h index 1dc68d64..6661a296 100644 --- a/modules/core/include/lagrange/remap_vertices.h +++ b/modules/core/include/lagrange/remap_vertices.h @@ -45,6 +45,7 @@ struct RemapVerticesOptions * @param[in,out] mesh The target mesh. * @param[in] forward_mapping Vertex mapping where vertex `i` will be remapped to * vertex `forward_mapping[i]`. + * @param[in] options Remapping options (vertex collision policy). * * @pre * * `forward_mapping` must be surjective. diff --git a/modules/core/include/lagrange/split_facets_by_material.h b/modules/core/include/lagrange/split_facets_by_material.h index 8668a74c..c2083cd3 100644 --- a/modules/core/include/lagrange/split_facets_by_material.h +++ b/modules/core/include/lagrange/split_facets_by_material.h @@ -35,7 +35,7 @@ namespace lagrange { /// materials. /// /// @tparam Scalar Mesh scalar type. -/// @param Index Mesh index type. +/// @tparam Index Mesh index type. /// template void split_facets_by_material( diff --git a/modules/core/include/lagrange/types/DualConnectivityType.h b/modules/core/include/lagrange/types/DualConnectivityType.h new file mode 100644 index 00000000..a7c6a75d --- /dev/null +++ b/modules/core/include/lagrange/types/DualConnectivityType.h @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +namespace lagrange { + +/// +/// This type defines the shared mesh element that determines adjacency when building a dual +/// graph for vertex-vertex adjacency. +/// +enum class DualConnectivityType { + Edge, ///< Two vertices are considered connected if they share a mesh edge. + Facet ///< Two vertices are considered connected if they belong to the same facet. +}; + +} // namespace lagrange diff --git a/modules/core/include/lagrange/utils/AdjacencyList.h b/modules/core/include/lagrange/utils/AdjacencyList.h index 6bea8718..a803c131 100644 --- a/modules/core/include/lagrange/utils/AdjacencyList.h +++ b/modules/core/include/lagrange/utils/AdjacencyList.h @@ -19,7 +19,7 @@ namespace lagrange { /// -/// @defgroup group-utils +/// @addtogroup group-utils /// /// @{ diff --git a/modules/core/include/lagrange/utils/BitField.h b/modules/core/include/lagrange/utils/BitField.h index 7d3721ee..932eb253 100644 --- a/modules/core/include/lagrange/utils/BitField.h +++ b/modules/core/include/lagrange/utils/BitField.h @@ -17,9 +17,7 @@ namespace lagrange { /// -/// @defgroup group-utils Utilites -/// @ingroup module-core -/// +/// @addtogroup group-utils /// @{ /// Bit field utility class. diff --git a/modules/core/include/lagrange/utils/DisjointSets.h b/modules/core/include/lagrange/utils/DisjointSets.h index 7a9f87bf..89bb593f 100644 --- a/modules/core/include/lagrange/utils/DisjointSets.h +++ b/modules/core/include/lagrange/utils/DisjointSets.h @@ -73,7 +73,7 @@ class DisjointSets * Merge the disjoint set containing entry `i` and the disjoint set containing entry `j`. * * @param[in] i Entry index i. - * @param[in] i Entry index j. + * @param[in] j Entry index j. * * @return The root entry index of the merged set. */ diff --git a/modules/core/include/lagrange/utils/SmallVector.h b/modules/core/include/lagrange/utils/SmallVector.h index de969475..dfc1886f 100644 --- a/modules/core/include/lagrange/utils/SmallVector.h +++ b/modules/core/include/lagrange/utils/SmallVector.h @@ -103,9 +103,7 @@ struct SmallBufferAllocator /// @endcond /// -/// @defgroup group-utils Utilities -/// @ingroup module-core -/// +/// @addtogroup group-utils /// @{ /// diff --git a/modules/core/include/lagrange/utils/StackSet.h b/modules/core/include/lagrange/utils/StackSet.h index cf867245..9bade5ee 100644 --- a/modules/core/include/lagrange/utils/StackSet.h +++ b/modules/core/include/lagrange/utils/StackSet.h @@ -20,9 +20,7 @@ namespace lagrange { /// -/// @defgroup group-utils Utilites -/// @ingroup module-core -/// +/// @addtogroup group-utils /// @{ /// diff --git a/modules/core/include/lagrange/utils/StackVector.h b/modules/core/include/lagrange/utils/StackVector.h index 32e34d86..84aa8e5d 100644 --- a/modules/core/include/lagrange/utils/StackVector.h +++ b/modules/core/include/lagrange/utils/StackVector.h @@ -20,9 +20,7 @@ namespace lagrange { /// -/// @defgroup group-utils Utilites -/// @ingroup module-core -/// +/// @addtogroup group-utils /// @{ /// diff --git a/modules/core/include/lagrange/utils/assert.h b/modules/core/include/lagrange/utils/assert.h index 25cd79b9..95ffa152 100644 --- a/modules/core/include/lagrange/utils/assert.h +++ b/modules/core/include/lagrange/utils/assert.h @@ -16,7 +16,7 @@ #include -/// @defgroup group-utils Utilites +/// @defgroup group-utils Utilities /// @ingroup module-core /// Utility functions. /// @{ @@ -56,7 +56,6 @@ /// la_debug_assert(x == 3, lagrange::format("Incorrect value of x: {}", x)); /// @endcode /// -/// @{ namespace lagrange { @@ -160,14 +159,17 @@ LA_CORE_API void trigger_breakpoint(); // ----------------------------------------------------------------------------- +/// @addtogroup group-utils-assert +/// @{ + /// /// Runtime assertion check. This check is executed for both Debug and Release configurations, and /// should be used, e.g., to check the validity of user-given inputs. /// /// @hideinitializer /// -/// @param condition Condition to check at runtime. -/// @param message Optional message argument. +/// The first argument is the condition to check at runtime, and the optional second argument is a +/// message describing the failure. /// /// @return Void expression. /// @@ -182,8 +184,8 @@ LA_CORE_API void trigger_breakpoint(); /// (e.g., a doubled linked list is malformed). /// @hideinitializer /// -/// @param condition Condition to check at runtime. -/// @param message Optional message argument. +/// The first argument is the condition to check at runtime, and the optional second argument is a +/// message describing the failure. /// /// @return Void expression. /// @@ -198,3 +200,5 @@ LA_CORE_API void trigger_breakpoint(); #endif // la_debug_assert /// @} + +/// @} diff --git a/modules/core/include/lagrange/utils/chain_edges.h b/modules/core/include/lagrange/utils/chain_edges.h index 37b980d2..e81cbfdd 100644 --- a/modules/core/include/lagrange/utils/chain_edges.h +++ b/modules/core/include/lagrange/utils/chain_edges.h @@ -18,9 +18,7 @@ namespace lagrange { /// -/// @defgroup group-utils Utilites -/// @ingroup module-core -/// +/// @addtogroup group-utils /// @{ /// /// A simple loop is defined as a set of connected edges whose starting and ending vertex is the diff --git a/modules/core/include/lagrange/utils/fmt/join.h b/modules/core/include/lagrange/utils/fmt/join.h index 3f742750..fd4fdd80 100644 --- a/modules/core/include/lagrange/utils/fmt/join.h +++ b/modules/core/include/lagrange/utils/fmt/join.h @@ -198,6 +198,8 @@ struct std::formatter, char> // `fmt::format`). #if !defined(SPDLOG_USE_STD_FORMAT) +/// @cond LA_INTERNAL_DOCS + template struct fmt::formatter, char> { @@ -241,4 +243,6 @@ struct fmt::formatter, char> } }; +/// @endcond + #endif // !defined(SPDLOG_USE_STD_FORMAT) diff --git a/modules/core/include/lagrange/utils/fmt_eigen.h b/modules/core/include/lagrange/utils/fmt_eigen.h index 60a4a869..a3144d7d 100644 --- a/modules/core/include/lagrange/utils/fmt_eigen.h +++ b/modules/core/include/lagrange/utils/fmt_eigen.h @@ -176,6 +176,7 @@ struct fmt::is_range< #include // clang-format on +/// @cond LA_INTERNAL_DOCS template struct fmt::is_range< Derived, @@ -183,6 +184,7 @@ struct fmt::is_range< : std::false_type { }; + /// @endcond #endif diff --git a/modules/core/include/lagrange/utils/fpe.h b/modules/core/include/lagrange/utils/fpe.h index 1c5435ce..b1d7c1a7 100644 --- a/modules/core/include/lagrange/utils/fpe.h +++ b/modules/core/include/lagrange/utils/fpe.h @@ -19,12 +19,12 @@ namespace lagrange { /// @{ /// -/// Enable floating-point exceptions (useful for debugging).. +/// Enable floating-point exceptions (useful for debugging). /// LA_CORE_API void enable_fpe(); /// -/// Disable previously-enabled fpe.. +/// Disable previously-enabled fpe. /// LA_CORE_API void disable_fpe(); diff --git a/modules/core/include/lagrange/utils/function_ref.h b/modules/core/include/lagrange/utils/function_ref.h index 693facdf..8222b128 100644 --- a/modules/core/include/lagrange/utils/function_ref.h +++ b/modules/core/include/lagrange/utils/function_ref.h @@ -25,12 +25,10 @@ namespace lagrange { -/// @defgroup group-utils-misc-functionref function_ref -/// @ingroup group-utils-misc -/// A lightweight non-owning reference to a callable. +/// @addtogroup group-utils-misc /// @{ -/// @ingroup group-utils-misc-functionref +/// @ingroup group-utils-misc /// /// A lightweight non-owning reference to a callable. /// @@ -47,7 +45,7 @@ template class function_ref; /// Specialization for function types. -/// @ingroup group-utils-misc-functionref +/// @ingroup group-utils-misc template class function_ref { diff --git a/modules/core/include/lagrange/utils/geometry3d.h b/modules/core/include/lagrange/utils/geometry3d.h index 21c4fe32..e65adbda 100644 --- a/modules/core/include/lagrange/utils/geometry3d.h +++ b/modules/core/include/lagrange/utils/geometry3d.h @@ -97,6 +97,7 @@ Scalar projected_angle_between( /// /// Returns the vector from v1 to v2 /// +/// @param[in] mesh the mesh providing the vertex positions. /// @param[in] v1 first vertex index (from). /// @param[in] v2 second vertex index (to) /// diff --git a/modules/core/include/lagrange/utils/point_on_segment.h b/modules/core/include/lagrange/utils/point_on_segment.h index ed768115..cbd131ab 100644 --- a/modules/core/include/lagrange/utils/point_on_segment.h +++ b/modules/core/include/lagrange/utils/point_on_segment.h @@ -19,10 +19,12 @@ namespace lagrange { namespace internal { /// @internal -bool LA_CORE_API point_on_segment_2d(Eigen::Vector2d p, Eigen::Vector2d a, Eigen::Vector2d b); +bool LA_CORE_API +point_on_segment_2d(const Eigen::Vector2d& p, const Eigen::Vector2d& a, const Eigen::Vector2d& b); /// @internal -bool LA_CORE_API point_on_segment_3d(Eigen::Vector3d p, Eigen::Vector3d a, Eigen::Vector3d b); +bool LA_CORE_API +point_on_segment_3d(const Eigen::Vector3d& p, const Eigen::Vector3d& a, const Eigen::Vector3d& b); } // namespace internal diff --git a/modules/core/include/lagrange/utils/range.h b/modules/core/include/lagrange/utils/range.h index 040fd923..90484402 100644 --- a/modules/core/include/lagrange/utils/range.h +++ b/modules/core/include/lagrange/utils/range.h @@ -229,3 +229,5 @@ internal::SparseRange range_sparse(T /*max*/, std::vector&& /*active*/) = /// @endcond } // namespace lagrange + +/// @} diff --git a/modules/core/include/lagrange/utils/strings.h b/modules/core/include/lagrange/utils/strings.h index 410d74d8..f902a34a 100644 --- a/modules/core/include/lagrange/utils/strings.h +++ b/modules/core/include/lagrange/utils/strings.h @@ -22,9 +22,7 @@ namespace lagrange { -/// @defgroup group-utils-misc Miscellaneous -/// @ingroup group-utils -/// Useful functions that don't have their place anywhere else. +/// @addtogroup group-utils-misc /// @{ /// diff --git a/modules/core/include/lagrange/utils/triangle_triangle_intersection.h b/modules/core/include/lagrange/utils/triangle_triangle_intersection.h index fa055ff1..bf52ab3f 100644 --- a/modules/core/include/lagrange/utils/triangle_triangle_intersection.h +++ b/modules/core/include/lagrange/utils/triangle_triangle_intersection.h @@ -17,7 +17,9 @@ namespace lagrange { /// -/// @addtogroup group-utils-geom +/// @defgroup group-utils-geom Geometry utilities +/// @ingroup group-utils +/// @brief Geometric predicates and primitive intersection tests. /// @{ /// diff --git a/modules/core/include/lagrange/utils/value_ptr.h b/modules/core/include/lagrange/utils/value_ptr.h index 4085f099..fbb7c1b3 100644 --- a/modules/core/include/lagrange/utils/value_ptr.h +++ b/modules/core/include/lagrange/utils/value_ptr.h @@ -31,9 +31,6 @@ #include -/// @addtogroup group-utils-misc -/// @{ - #ifndef LA_DECLSPEC_EMPTY_BASES #ifdef _MSC_VER #define LA_DECLSPEC_EMPTY_BASES __declspec(empty_bases) @@ -121,6 +118,8 @@ struct default_clone /// @endcond +/// +/// @ingroup group-utils-misc /// /// Smart pointer with value semantics. Copy/moving the pointer will copy/move the underlying /// object. This is useful to implement PIMPL idioms. @@ -218,6 +217,8 @@ class value_ptr ~value_ptr() = default; }; +/// +/// @ingroup group-utils-misc /// /// Helper function to create a value_ptr for a given type. /// @@ -234,6 +235,4 @@ value_ptr make_value_ptr(Args&&... args) return value_ptr(new T(std::forward(args)...)); } -/// @} - } // namespace lagrange diff --git a/modules/core/include/lagrange/weld_indexed_attribute.h b/modules/core/include/lagrange/weld_indexed_attribute.h index 044614b0..2f6f3dd5 100644 --- a/modules/core/include/lagrange/weld_indexed_attribute.h +++ b/modules/core/include/lagrange/weld_indexed_attribute.h @@ -41,7 +41,7 @@ struct WeldOptions /// between two corners is less than this value, the attributes are merged. If not set, this /// condition is not checked. /// - /// @note Angle is computed as @f$ arccos(v1 \cdot v2 / (\|v1\| * \|v2\|)) $f@, where v1 and v2 + /// @note Angle is computed as @f$ arccos(v1 \cdot v2 / (\|v1\| * \|v2\|)) @f$, where v1 and v2 /// are the corner attribute values. It is well defined for all dimensions. The angle check is /// robust against degeneracies (e.g. zero-length vectors). /// @@ -71,6 +71,7 @@ struct WeldOptions /// /// @param mesh The source mesh. /// @param attr_id The indexed attribute id. +/// @param options Welding options (e.g. vertices to exclude). /// template void weld_indexed_attribute( diff --git a/modules/core/python/include/lagrange/python/utils/StubType.h b/modules/core/python/include/lagrange/python/utils/StubType.h new file mode 100644 index 00000000..b680b2be --- /dev/null +++ b/modules/core/python/include/lagrange/python/utils/StubType.h @@ -0,0 +1,72 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include + +namespace lagrange::python { + +// Wraps a C++ type `T` but renders as `Hint::value` in generated stubs. Use this +// when nanobind's auto-generated stub for `T` is narrower than what the binding +// actually accepts/returns (e.g. an Eigen vector that also takes a numpy array). +// Conversion is delegated to `T`'s own caster, so runtime behavior is unchanged. +// +// `Hint` is a tag type with a `static constexpr char value[]`; declare reusable +// hints with the LA_STUB_HINT macro below. +// +// Related nanobind issues: +// https://github.com/wjakob/nanobind/issues/1155 +// https://github.com/wjakob/nanobind/issues/494 +// https://github.com/wjakob/nanobind/discussions/1243 +template +struct StubType +{ + T value; +}; + +// Declares a stub-hint tag named `name` rendering as the type string `str`. +#define LA_STUB_HINT(name, str) \ + struct name \ + { \ + static constexpr char value[] = str; \ + } + +// Common hint: accepts any array-like (list, tuple, numpy array, ...). +LA_STUB_HINT(ArrayLikeHint, "numpy.typing.ArrayLike"); + +} // namespace lagrange::python + +namespace nanobind::detail { + +template +struct type_caster> +{ + using Wrapper = lagrange::python::StubType; + using TCaster = make_caster; + + NB_TYPE_CASTER(Wrapper, const_name(Hint::value)) + + bool from_python(handle src, uint8_t flags, cleanup_list* cleanup) noexcept + { + TCaster caster; + if (!caster.from_python(src, flags, cleanup)) return false; + value.value = caster.operator cast_t(); + return true; + } + + static handle from_cpp(const Wrapper& w, rv_policy policy, cleanup_list* cleanup) noexcept + { + return TCaster::from_cpp(w.value, policy, cleanup); + } +}; + +} // namespace nanobind::detail diff --git a/modules/core/python/include/lagrange/python/utils/bind_safe_vector.h b/modules/core/python/include/lagrange/python/utils/bind_safe_vector.h index 4bc7cdf4..bd3ba5b7 100644 --- a/modules/core/python/include/lagrange/python/utils/bind_safe_vector.h +++ b/modules/core/python/include/lagrange/python/utils/bind_safe_vector.h @@ -15,6 +15,18 @@ #include +// `def_rw` extra that widens a `bind_safe_vector` member's setter stub. nanobind +// types the generated setter as the exact bound list type, which rejects a plain +// Python sequence (even though `bind_safe_vector` registers an `iterable` +// conversion, so it works at runtime). Pass as an extra to `def_rw`: +// +// .def_rw("materials", &T::materials, doc, +// LA_SAFE_VECTOR_SETTER("materials", "collections.abc.Sequence[int]")) +// +// `prop` must match the property name and `arg_type` is the widened argument type. +#define LA_SAFE_VECTOR_SETTER(prop, arg_type) \ + nanobind::for_setter(nanobind::sig("def " prop "(self, arg: " arg_type ", /) -> None")) + NAMESPACE_BEGIN(NB_NAMESPACE) template diff --git a/modules/core/python/scripts/meshstat.py b/modules/core/python/scripts/meshstat.py index 017c70e1..7aae0fe7 100755 --- a/modules/core/python/scripts/meshstat.py +++ b/modules/core/python/scripts/meshstat.py @@ -23,7 +23,7 @@ import platform import sys -import colorama # type: ignore +import colorama import lagrange.scripts.meshstat as meshstat diff --git a/modules/core/python/src/bind_mesh_cleanup.h b/modules/core/python/src/bind_mesh_cleanup.h index 091321a7..967cfa59 100644 --- a/modules/core/python/src/bind_mesh_cleanup.h +++ b/modules/core/python/src/bind_mesh_cleanup.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -199,6 +200,38 @@ E.g. quad (0,0,1,1) is degenerate, while (1,1,2,3) is not. If None, edge lengths are computed. )"); + m.def( + "split_obtuse_triangles", + [](MeshType& mesh, + float max_angle, + size_t max_iterations, + std::optional active_region_attribute) { + SplitObtuseTrianglesOptions opts; + opts.max_angle = max_angle; + opts.max_iterations = max_iterations; + if (active_region_attribute.has_value()) + opts.active_region_attribute = active_region_attribute.value(); + return split_obtuse_triangles(mesh, std::move(opts)); + }, + "mesh"_a, + nb::kw_only(), + "max_angle"_a = SplitObtuseTrianglesOptions().max_angle, + "max_iterations"_a = SplitObtuseTrianglesOptions().max_iterations, + "active_region_attribute"_a = nb::none(), + R"(Iteratively split obtuse triangles by splitting their longest edge at the +projection of the obtuse vertex. + +:param mesh: Input mesh (modified in place). +:param max_angle: Maximum allowed interior angle in radians. Triangles with any interior + angle strictly greater than this value are split. Default is pi/2. +:param max_iterations: Maximum number of split passes. Use 0 to iterate until convergence. +:param active_region_attribute: Optional facet attribute name (uint8_t) restricting which + facets are considered. If None, all facets are checked. + +:returns: Total number of triangle splits performed across all iterations (a triangle + re-split in a later iteration is counted again). +)"); + m.def( "remove_degenerate_facets", &remove_degenerate_facets, diff --git a/modules/core/python/src/bind_utilities.h b/modules/core/python/src/bind_utilities.h index 69736c2d..cbb44e7f 100644 --- a/modules/core/python/src/bind_utilities.h +++ b/modules/core/python/src/bind_utilities.h @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -73,6 +74,9 @@ namespace lagrange::python { +LA_STUB_HINT(IterableUsageHint, "collections.abc.Iterable[AttributeUsage]"); +LA_STUB_HINT(IterableElementHint, "collections.abc.Iterable[AttributeElement]"); + template void bind_utilities(nanobind::module_& m) { @@ -1626,14 +1630,16 @@ oriented. m.def( "transform_mesh", [](MeshType& mesh, - Eigen::Matrix affine_transform, + StubType, ArrayLikeHint> affine_transform, bool normalize_normals, bool normalize_tangents_bitangents, + bool reorient, bool in_place) -> std::optional { - Eigen::Transform M(affine_transform); + Eigen::Transform M(affine_transform.value); TransformOptions options; options.normalize_normals = normalize_normals; options.normalize_tangents_bitangents = normalize_tangents_bitangents; + options.reorient = reorient; std::optional result; if (in_place) { @@ -1645,8 +1651,10 @@ oriented. }, "mesh"_a, "affine_transform"_a, + nb::kw_only(), "normalize_normals"_a = TransformOptions().normalize_normals, "normalize_tangents_bitangents"_a = TransformOptions().normalize_tangents_bitangents, + "reorient"_a = TransformOptions().reorient, "in_place"_a = true, R"(Apply affine transformation to a mesh. @@ -1654,6 +1662,7 @@ oriented. :param affine_transform: Affine transformation matrix. :param normalize_normals: Whether to normalize normals. :param normalize_tangents_bitangents: Whether to normalize tangents and bitangents. +:param reorient: If the transform has a negative determinant, flip facets and reorient attributes (normals, tangents, bitangents). :param in_place: Whether to apply transformation in place. :returns: Transformed mesh if in_place is False.)"); @@ -1698,7 +1707,8 @@ oriented. [](const MeshType& mesh, std::variant attribute, double isovalue, - bool keep_below) { + bool keep_below, + bool keep_attributes) { IsolineOptions opt; if (std::holds_alternative(attribute)) { opt.attribute_id = std::get(attribute); @@ -1707,18 +1717,21 @@ oriented. } opt.isovalue = isovalue; opt.keep_below = keep_below; + opt.keep_attributes = keep_attributes; return trim_by_isoline(mesh, opt); }, "mesh"_a, "attribute"_a, "isovalue"_a = IsolineOptions().isovalue, "keep_below"_a = IsolineOptions().keep_below, + "keep_attributes"_a = IsolineOptions().keep_attributes, R"(Trim a triangle mesh by an isoline. :param mesh: Input triangle mesh. :param attribute: Attribute ID or name of scalar field (vertex or indexed). :param isovalue: Isovalue to trim with. :param keep_below: Whether to keep the part below the isoline. +:param keep_attributes: Whether to propagate input mesh attributes to the output mesh. :returns: Trimmed mesh.)"); @@ -1726,7 +1739,8 @@ oriented. "extract_isoline", [](const MeshType& mesh, std::variant attribute, - double isovalue) { + double isovalue, + bool keep_attributes) { IsolineOptions opt; if (std::holds_alternative(attribute)) { opt.attribute_id = std::get(attribute); @@ -1734,11 +1748,13 @@ oriented. opt.attribute_id = mesh.get_attribute_id(std::get(attribute)); } opt.isovalue = isovalue; + opt.keep_attributes = keep_attributes; return extract_isoline(mesh, opt); }, "mesh"_a, "attribute"_a, "isovalue"_a = IsolineOptions().isovalue, + "keep_attributes"_a = IsolineOptions().keep_attributes, R"(Extract the isoline of an implicit function defined on the mesh vertices/corners. The input mesh must be a triangle mesh. @@ -1746,17 +1762,55 @@ The input mesh must be a triangle mesh. :param mesh: Input triangle mesh to extract the isoline from. :param attribute: Attribute id or name of the scalar field to use. Can be a vertex or indexed attribute. :param isovalue: Isovalue to extract. +:param keep_attributes: Whether to propagate input mesh attributes to the output mesh. :return: A mesh whose facets is a collection of size 2 elements representing the extracted isoline.)"); + m.def( + "insert_isoline", + [](const MeshType& mesh, + std::variant attribute, + double isovalue, + bool keep_attributes) { + IsolineOptions opt; + if (std::holds_alternative(attribute)) { + opt.attribute_id = std::get(attribute); + } else { + opt.attribute_id = mesh.get_attribute_id(std::get(attribute)); + } + opt.isovalue = isovalue; + opt.keep_attributes = keep_attributes; + return insert_isoline(mesh, opt); + }, + "mesh"_a, + "attribute"_a, + "isovalue"_a = IsolineOptions().isovalue, + "keep_attributes"_a = IsolineOptions().keep_attributes, + R"(Insert the isoline of an implicit function into a triangle mesh. + +Unlike trimming, the whole mesh is retained; facets crossed by the isoline are split so that the +isoline appears as a chain of edges in the output. A triangle crossed in its interior is split into +a triangle and a quad, so the output is in general a mixed triangle/quad mesh. When the isoline +passes exactly through an existing vertex (or lies along an edge), the split degenerates: the +triangle may instead be split into two triangles, or left unchanged. + +:param mesh: Input triangle mesh to insert the isoline into. +:param attribute: Attribute id or name of the scalar field to use. Can be a vertex or indexed attribute. +:param isovalue: Isovalue to insert. +:param keep_attributes: Whether to propagate input mesh attributes to the output mesh. + +:return: The input mesh with the isoline inserted as a chain of edges.)"); + using AttributeNameOrId = AttributeFilter::AttributeNameOrId; m.def( "filter_attributes", [](MeshType& mesh, std::optional> included_attributes, std::optional> excluded_attributes, - std::optional> included_usages, - std::optional> included_element_types) { + StubType>, IterableUsageHint> + included_usages, + StubType>, IterableElementHint> + included_element_types) { AttributeFilter filter; if (included_attributes.has_value()) { filter.included_attributes = included_attributes.value(); @@ -1764,15 +1818,15 @@ The input mesh must be a triangle mesh. if (excluded_attributes.has_value()) { filter.excluded_attributes = excluded_attributes.value(); } - if (included_usages.has_value()) { + if (included_usages.value.has_value()) { filter.included_usages.clear_all(); - for (auto usage : included_usages.value()) { + for (auto usage : included_usages.value.value()) { filter.included_usages.set(usage); } } - if (included_element_types.has_value()) { + if (included_element_types.value.has_value()) { filter.included_element_types.clear_all(); - for (auto element_type : included_element_types.value()) { + for (auto element_type : included_element_types.value.value()) { filter.included_element_types.set(element_type); } } @@ -1924,11 +1978,11 @@ found after ``max_increment`` attempts. m.def( "compute_mesh_covariance", [](MeshType& mesh, - std::array center, + StubType, ArrayLikeHint> center, std::optional active_facets_attribute_name) -> std::array, 3> { MeshCovarianceOptions options; - options.center = center; + options.center = center.value; options.active_facets_attribute_name = active_facets_attribute_name; return compute_mesh_covariance(mesh, options); }, @@ -2007,26 +2061,26 @@ found after ``max_increment`` attempts. nb::sig( "def select_facets_by_normal_similarity(mesh: SurfaceMesh, " "seed_facet_id: int, " - "flood_error_limit: float | None = None, " - "flood_second_to_first_order_limit_ratio: float | None = None, " - "facet_normal_attribute_name: str | None = None, " - "is_facet_selectable_attribute_name: str | None = None, " - "output_attribute_name: str | None = None, " - "search_type: typing.Literal['BFS', 'DFS'] | None = None," - "num_smooth_iterations: int | None = None) -> int")); + "flood_error_limit: typing.Optional[float] = None, " + "flood_second_to_first_order_limit_ratio: typing.Optional[float] = None, " + "facet_normal_attribute_name: typing.Optional[str] = None, " + "is_facet_selectable_attribute_name: typing.Optional[str] = None, " + "output_attribute_name: typing.Optional[str] = None, " + "search_type: typing.Optional[typing.Literal['BFS', 'DFS']] = None," + "num_smooth_iterations: typing.Optional[int] = None) -> int")); m.def( "select_facets_in_frustum", [](MeshType& mesh, - std::array, 4> frustum_plane_points, - std::array, 4> frustum_plane_normals, + StubType, 4>, ArrayLikeHint> frustum_plane_points, + StubType, 4>, ArrayLikeHint> frustum_plane_normals, std::optional greedy, std::optional output_attribute_name) { // Set options in the C++ struct Frustum frustum; for (size_t i = 0; i < 4; ++i) { - frustum.planes[i].point = frustum_plane_points[i]; - frustum.planes[i].normal = frustum_plane_normals[i]; + frustum.planes[i].point = frustum_plane_points.value[i]; + frustum.planes[i].normal = frustum_plane_normals.value[i]; } FrustumSelectionOptions options; if (greedy.has_value()) options.greedy = greedy.value(); diff --git a/modules/core/python/tests/test_isoline.py b/modules/core/python/tests/test_isoline.py index 69d2b9d5..788018d3 100644 --- a/modules/core/python/tests/test_isoline.py +++ b/modules/core/python/tests/test_isoline.py @@ -34,3 +34,21 @@ def test_extract(self, single_triangle): assert trimmed.num_vertices == 2 assert trimmed.num_facets == 1 assert trimmed.vertex_per_facet == 2 + + def test_insert(self, single_triangle): + mesh = single_triangle + id = mesh.create_attribute("value", "Vertex", "Scalar", np.array([0, 0, 1], dtype=float)) + inserted = lagrange.insert_isoline(mesh, id, isovalue=0.5) + # Both sides are kept, so the triangle is split into a triangle and a quad. + assert inserted.num_facets == 2 + assert inserted.num_vertices == 5 + + def test_keep_attributes(self, single_triangle): + mesh = single_triangle + id = mesh.create_attribute("value", "Vertex", "Scalar", np.array([0, 0, 1], dtype=float)) + # By default attributes are propagated to the output. + kept = lagrange.trim_by_isoline(mesh, id, isovalue=0.5) + assert kept.has_attribute("value") + # When disabled, only vertex positions are retained. + stripped = lagrange.trim_by_isoline(mesh, id, isovalue=0.5, keep_attributes=False) + assert not stripped.has_attribute("value") diff --git a/modules/core/python/tests/test_meshstat_logic.py b/modules/core/python/tests/test_meshstat_logic.py index 4fb794f9..40a0041f 100644 --- a/modules/core/python/tests/test_meshstat_logic.py +++ b/modules/core/python/tests/test_meshstat_logic.py @@ -67,6 +67,7 @@ def test_cube_with_uv(self, cube_with_uv): assert basic["bbox_extent"] == [1.0, 1.0, 1.0] assert basic["max_extent"] == 1.0 assert basic["bbox_diagonal"] == pytest.approx(np.sqrt(3)) + assert basic["facet_counts"] == {"two_gons": 0, "triangles": 0, "quads": 6, "polygons": 0} def test_initializes_edges(self, cube_with_uv): """``num_edges`` must be reported correctly even when the caller has @@ -93,6 +94,23 @@ def test_empty_mesh(self, capsys): assert "empty mesh" in capsys.readouterr().out +class TestCollectExtendedInfo: + def test_euler_characteristic_closed_cube(self, cube): + cube.initialize_edges() + info: dict = {} + meshstat.collect_extended_info(cube, info) + # closed quad cube: V=8, E=12, F=6 → χ=2 + assert info["extended"]["euler_characteristic"] == 2 + + def test_euler_characteristic_in_json(self, cube_with_uv, tmp_path): + mesh_path = tmp_path / "cube.obj" + lagrange.io.save_mesh(str(mesh_path), cube_with_uv) + meshstat.main(["--export", "--extended", str(mesh_path)]) + info = json.loads(mesh_path.with_suffix(".json").read_text()) + assert "euler_characteristic" in info["extended"] + assert info["extended"]["euler_characteristic"] == 2 + + class TestCollectUVInfo: def test_triangulated_cube(self, cube_with_uv): mesh = cube_with_uv @@ -141,6 +159,16 @@ def test_no_uv_warns(self, cube, caplog): assert info["uv"] == {} assert any("No UV attributes" in rec.message for rec in caplog.records) + def test_no_temp_attributes_left(self, cube_with_uv): + mesh = cube_with_uv + mesh.initialize_edges() + lagrange.triangulate_polygonal_facets(mesh) + attr_names_before = {mesh.get_attribute_name(i) for i in mesh.get_matching_attribute_ids()} + info: dict = {"basic": {"max_extent": 1.0}} + meshstat.collect_uv_info(mesh, info, [lagrange.DistortionMetric.MIPS]) + attr_names_after = {mesh.get_attribute_name(i) for i in mesh.get_matching_attribute_ids()} + assert attr_names_after == attr_names_before + def test_idempotent(self, cube_with_uv): """Calling ``collect_uv_info`` twice on the same mesh must not crash: intermediate attributes should get unique names instead of colliding.""" diff --git a/modules/core/python/tests/test_split_obtuse_triangles.py b/modules/core/python/tests/test_split_obtuse_triangles.py new file mode 100644 index 00000000..c0378d27 --- /dev/null +++ b/modules/core/python/tests/test_split_obtuse_triangles.py @@ -0,0 +1,68 @@ +# +# 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 math + +import numpy as np + +import lagrange + + +def _max_interior_angle(mesh): + V = mesh.vertices + F = mesh.facets + max_angle = 0.0 + for tri in F: + p = V[tri] + for d in range(3): + v1 = p[d] - p[(d + 1) % 3] + v2 = p[d] - p[(d + 2) % 3] + n1 = v1 / np.linalg.norm(v1) + n2 = v2 / np.linalg.norm(v2) + angle = 2.0 * math.atan2(np.linalg.norm(n1 - n2), np.linalg.norm(n1 + n2)) + if angle > max_angle: + max_angle = angle + return max_angle + + +class TestSplitObtuseTriangles: + def test_noop_equilateral(self): + mesh = lagrange.SurfaceMesh() + mesh.add_vertices( + np.array([[0, 0, 0], [1, 0, 0], [0.5, math.sqrt(3) / 2, 0]], dtype=np.float64) + ) + mesh.add_triangles(np.array([[0, 1, 2]], dtype=np.uint32)) + + n = lagrange.split_obtuse_triangles(mesh) + assert n == 0 + assert mesh.num_vertices == 3 + assert mesh.num_facets == 1 + + def test_single_sliver(self): + mesh = lagrange.SurfaceMesh() + mesh.add_vertices(np.array([[0, 0, 0], [1, 0, 0], [0.5, 0.01, 0]], dtype=np.float64)) + mesh.add_triangles(np.array([[0, 1, 2]], dtype=np.uint32)) + + n = lagrange.split_obtuse_triangles(mesh, max_iterations=1) + assert n == 1 + assert mesh.num_vertices == 4 + assert mesh.num_facets == 2 + + def test_recursive_convergence(self): + mesh = lagrange.SurfaceMesh() + mesh.add_vertices( + np.array([[0, 0, 0], [10, 0, 0], [2, 5, 0], [5, -0.1, 0]], dtype=np.float64) + ) + mesh.add_triangles(np.array([[0, 1, 2], [1, 0, 3]], dtype=np.uint32)) + + n = lagrange.split_obtuse_triangles(mesh, max_angle=math.pi / 2, max_iterations=0) + assert n > 0 + assert _max_interior_angle(mesh) <= math.pi / 2 + 1e-5 diff --git a/modules/core/python/tests/test_surface_mesh.py b/modules/core/python/tests/test_surface_mesh.py index d79add63..f84825bf 100644 --- a/modules/core/python/tests/test_surface_mesh.py +++ b/modules/core/python/tests/test_surface_mesh.py @@ -398,9 +398,9 @@ def test_1_ring_callback(self, cube): fid = mesh.get_corner_facet(cid) assert fid in edge_one_ring - vertex_one_ring_edges = [] - mesh.foreach_edge_around_vertex(0, lambda eid: vertex_one_ring_edges.append(eid)) - vertex_one_ring_edges = set(vertex_one_ring_edges) + vertex_one_ring_edges_list = [] + mesh.foreach_edge_around_vertex(0, lambda eid: vertex_one_ring_edges_list.append(eid)) + vertex_one_ring_edges = set(vertex_one_ring_edges_list) assert len(vertex_one_ring_edges) == len(vertex_one_ring) for eid in vertex_one_ring_edges: diff --git a/modules/core/python/tests/test_transform_mesh.py b/modules/core/python/tests/test_transform_mesh.py index 539edb6a..c7b07a01 100644 --- a/modules/core/python/tests/test_transform_mesh.py +++ b/modules/core/python/tests/test_transform_mesh.py @@ -49,3 +49,37 @@ def test_rotation(self, single_triangle): expected_normals = orig_normals[:, [1, 2, 0]] normals = mesh.attribute(normal_attr_id).data assert np.allclose(normals, expected_normals) + + def test_reorient(self, single_triangle): + # Reflection along the x-axis: negative determinant. + M = np.diag([-1.0, 1.0, 1.0, 1.0]) + assert np.linalg.det(M[:3, :3]) < 0 + + # reorient=True flips facet winding and reorients the facet normal. + mesh = single_triangle + normal_attr_id = lagrange.compute_facet_normal(mesh) + orig_facets = mesh.facets.copy() + + lagrange.transform_mesh(mesh, M, reorient=True) + + # Facet winding is reversed (the triangle's vertex order is flipped). + assert np.all(mesh.facets[0] == orig_facets[0][::-1]) + + # The stored normal matches a freshly recomputed normal of the + # reflected mesh, i.e. it is consistent with the flipped winding. + stored_normals = mesh.attribute(normal_attr_id).data.copy() + recomputed_attr_id = lagrange.compute_facet_normal(mesh) + recomputed_normals = mesh.attribute(recomputed_attr_id).data + assert np.allclose(stored_normals, recomputed_normals) + + def test_no_reorient(self, single_triangle): + # Reflection along the x-axis: negative determinant. + M = np.diag([-1.0, 1.0, 1.0, 1.0]) + + # reorient=False (the default) leaves facet winding untouched. + mesh = single_triangle + orig_facets = mesh.facets.copy() + + lagrange.transform_mesh(mesh, M) + + assert np.all(mesh.facets == orig_facets) diff --git a/modules/core/src/Attribute.cpp b/modules/core/src/Attribute.cpp index 3dd7ef30..879a4b80 100644 --- a/modules/core/src/Attribute.cpp +++ b/modules/core/src/Attribute.cpp @@ -129,6 +129,7 @@ Attribute::Attribute(Attribute&& other) noexcept } } +/// @cond LA_INTERNAL_DOCS template Attribute& Attribute::operator=(Attribute&& other) noexcept { @@ -155,6 +156,7 @@ Attribute& Attribute::operator=(Attribute&& oth } return *this; } +/// @endcond template Attribute::Attribute(const Attribute& other) @@ -185,6 +187,7 @@ Attribute::Attribute(const Attribute& other) } } +/// @cond LA_INTERNAL_DOCS template Attribute& Attribute::operator=(const Attribute& other) { @@ -216,6 +219,7 @@ Attribute& Attribute::operator=(const Attribute } return *this; } +/// @endcond template template @@ -612,6 +616,8 @@ lagrange::span Attribute::ref_row(size_t element) // Protected methods //////////////////////////////////////////////////////////////////////////////// +/// @cond LA_INTERNAL_DOCS + template void Attribute::growth_check(size_t new_cap) { @@ -682,6 +688,8 @@ void Attribute::clear_views() m_num_elements = m_data.size() / get_num_channels(); } +/// @endcond + //////////////////////////////////////////////////////////////////////////////// // Explicit template instantiation //////////////////////////////////////////////////////////////////////////////// diff --git a/modules/core/src/IndexedAttribute.cpp b/modules/core/src/IndexedAttribute.cpp index d20667b3..e81d5ba8 100644 --- a/modules/core/src/IndexedAttribute.cpp +++ b/modules/core/src/IndexedAttribute.cpp @@ -43,6 +43,7 @@ IndexedAttribute::IndexedAttribute( , m_indices(std::move(other.m_indices)) {} +/// @cond LA_INTERNAL_DOCS template IndexedAttribute& IndexedAttribute::operator=( IndexedAttribute&& other) noexcept @@ -54,6 +55,7 @@ IndexedAttribute& IndexedAttribute::operator } return *this; } +/// @endcond template IndexedAttribute::IndexedAttribute( @@ -63,6 +65,7 @@ IndexedAttribute::IndexedAttribute( , m_indices(other.m_indices) {} +/// @cond LA_INTERNAL_DOCS template IndexedAttribute& IndexedAttribute::operator=( const IndexedAttribute& other) @@ -74,6 +77,7 @@ IndexedAttribute& IndexedAttribute::operator } return *this; } +/// @endcond //////////////////////////////////////////////////////////////////////////////// // Explicit template instantiation diff --git a/modules/core/src/compute_facet_facet_adjacency.cpp b/modules/core/src/compute_facet_facet_adjacency.cpp index 5a95bf66..7114fb72 100644 --- a/modules/core/src/compute_facet_facet_adjacency.cpp +++ b/modules/core/src/compute_facet_facet_adjacency.cpp @@ -11,6 +11,8 @@ */ #include #include +#include +#include // clang-format off #include @@ -23,12 +25,12 @@ namespace lagrange { +namespace { + template -AdjacencyList compute_facet_facet_adjacency(SurfaceMesh& mesh) +AdjacencyList compute_facet_facet_adjacency_edge(SurfaceMesh& mesh) { - if (!mesh.has_edges()) { - mesh.initialize_edges(); - } + mesh.initialize_edges(); const Index num_facets = mesh.get_num_facets(); @@ -55,9 +57,71 @@ AdjacencyList compute_facet_facet_adjacency(SurfaceMesh& m return AdjacencyList(std::move(adjacency_data), std::move(adjacency_index)); } +template +AdjacencyList compute_facet_facet_adjacency_vertex(SurfaceMesh& mesh) +{ + mesh.initialize_edges(); + + const Index num_facets = mesh.get_num_facets(); + + using ValueArray = typename AdjacencyList::ValueArray; + using IndexArray = typename AdjacencyList::IndexArray; + + // Count neighbors per facet by iterating through all vertices of each facet + IndexArray adjacency_index(num_facets + 1, 0); + tbb::parallel_for(Index(0), num_facets, [&](Index f) { + // For each vertex in facet f, count how many other facets share that vertex + for (Index c = mesh.get_facet_corner_begin(f); c != mesh.get_facet_corner_end(f); ++c) { + Index v = mesh.get_corner_vertex(c); + mesh.foreach_corner_around_vertex(v, [&](Index c2) { + Index f2 = mesh.get_corner_facet(c2); + if (f2 != f) { + adjacency_index[f + 1]++; + } + }); + } + }); + + // Prefix sum to get offsets. + std::partial_sum(adjacency_index.begin(), adjacency_index.end(), adjacency_index.begin()); + + // Fill adjacency data. pos is a local offset from adjacency_index[f]; adjacency_index is not + // modified, so no shift-back is needed. + ValueArray adjacency_data(adjacency_index.back()); + tbb::parallel_for(Index(0), num_facets, [&](Index f) { + size_t pos = adjacency_index[f]; + for (Index c = mesh.get_facet_corner_begin(f); c != mesh.get_facet_corner_end(f); ++c) { + Index v = mesh.get_corner_vertex(c); + mesh.foreach_corner_around_vertex(v, [&](Index c2) { + Index f2 = mesh.get_corner_facet(c2); + if (f2 != f) { + adjacency_data[pos++] = f2; + } + }); + } + }); + + return AdjacencyList(std::move(adjacency_data), std::move(adjacency_index)); +} + +} // namespace + +template +AdjacencyList compute_facet_facet_adjacency( + SurfaceMesh& mesh, + ConnectivityType connectivity_type) +{ + switch (connectivity_type) { + case ConnectivityType::Edge: return compute_facet_facet_adjacency_edge(mesh); + case ConnectivityType::Vertex: return compute_facet_facet_adjacency_vertex(mesh); + default: la_runtime_assert(false, "Unsupported ConnectivityType for facet-facet adjacency"); + } +} + #define LA_X_compute_facet_facet_adjacency(_, Scalar, Index) \ template LA_CORE_API AdjacencyList compute_facet_facet_adjacency( \ - SurfaceMesh&); + SurfaceMesh&, \ + ConnectivityType); LA_SURFACE_MESH_X(compute_facet_facet_adjacency, 0) } // namespace lagrange diff --git a/modules/core/src/compute_vertex_vertex_adjacency.cpp b/modules/core/src/compute_vertex_vertex_adjacency.cpp index 32b5460a..a2db4086 100644 --- a/modules/core/src/compute_vertex_vertex_adjacency.cpp +++ b/modules/core/src/compute_vertex_vertex_adjacency.cpp @@ -27,8 +27,51 @@ namespace lagrange { +namespace { + +// Dedup and condense raw adjacency arrays into a CSR AdjacencyList. +// On entry: adjacency_index[v] = one-past-end of v's raw (possibly duplicate) data. +// On exit: standard CSR AdjacencyList (deduplicated, no invalid sentinels). +template +AdjacencyList dedup_and_condense( + typename AdjacencyList::ValueArray adjacency_data, + typename AdjacencyList::IndexArray adjacency_index) +{ + const Index num_vertices = static_cast(adjacency_index.size()) - 1; + + tbb::parallel_for(Index(0), num_vertices, [&](Index vi) { + auto itr_begin = std::next(adjacency_data.begin(), vi == 0 ? 0 : adjacency_index[vi - 1]); + auto itr_end = std::next(adjacency_data.begin(), adjacency_index[vi]); + std::sort(itr_begin, itr_end); + auto new_itr_end = std::unique(itr_begin, itr_end); + std::fill(new_itr_end, itr_end, invalid()); + }); + + // Condense adjacency data. + size_t count = 0; + size_t start_idx = 0; + for (auto vi : range(num_vertices)) { + size_t end_idx = adjacency_index[vi]; + for (size_t i = start_idx; i < end_idx; i++) { + if (adjacency_data[i] != invalid()) { + adjacency_data[count++] = adjacency_data[i]; + } else { + break; + } + } + adjacency_index[vi] = count; + start_idx = end_idx; + } + adjacency_data.resize(count); + adjacency_data.shrink_to_fit(); + std::rotate(adjacency_index.rbegin(), adjacency_index.rbegin() + 1, adjacency_index.rend()); + adjacency_index.front() = 0; + + return AdjacencyList(std::move(adjacency_data), std::move(adjacency_index)); +} + template -AdjacencyList compute_vertex_vertex_adjacency(SurfaceMesh& mesh) +AdjacencyList compute_vertex_vertex_adjacency_edge(SurfaceMesh& mesh) { const auto num_vertices = mesh.get_num_vertices(); const auto num_facets = mesh.get_num_facets(); @@ -39,7 +82,7 @@ AdjacencyList compute_vertex_vertex_adjacency(SurfaceMesh& ValueArray adjacency_data; IndexArray adjacency_index(num_vertices + 1, 0); - // Estimate max size. + // Count directed edge pairs per vertex (2 per undirected edge). for (auto fi : range(num_facets)) { const Index c_begin = mesh.get_facet_corner_begin(fi); const Index c_end = mesh.get_facet_corner_end(fi); @@ -55,11 +98,9 @@ AdjacencyList compute_vertex_vertex_adjacency(SurfaceMesh& std::rotate(adjacency_index.rbegin(), adjacency_index.rbegin() + 1, adjacency_index.rend()); std::partial_sum(adjacency_index.begin(), adjacency_index.end(), adjacency_index.begin()); - const Index total_size = static_cast(adjacency_index.back()); - adjacency_data.resize(total_size); + adjacency_data.resize(adjacency_index.back()); - // Gather adjacency data with duplicates. Note: We could do this loop in parallel by using a - // vector of atomic counters for `adjacency_index` + // Gather adjacency data with duplicates. 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); @@ -68,48 +109,76 @@ AdjacencyList compute_vertex_vertex_adjacency(SurfaceMesh& Index c_next = (ci + 1 == c_end) ? c_begin : ci + 1; Index v_curr = mesh.get_corner_vertex(ci); Index v_next = mesh.get_corner_vertex(c_next); - adjacency_data[adjacency_index[v_curr]++] = v_next; adjacency_data[adjacency_index[v_next]++] = v_curr; } - }; + } - // Remove duplicate data. - tbb::parallel_for(Index(0), num_vertices, [&](Index vi) { - auto itr_begin = std::next(adjacency_data.begin(), vi == 0 ? 0 : adjacency_index[vi - 1]); - auto itr_end = std::next(adjacency_data.begin(), adjacency_index[vi]); - std::sort(itr_begin, itr_end); - auto new_itr_end = std::unique(itr_begin, itr_end); - std::fill(new_itr_end, itr_end, invalid()); - }); + return dedup_and_condense(std::move(adjacency_data), std::move(adjacency_index)); +} - // Condense adjacency data. - size_t count = 0; - size_t start_idx = 0; - for (auto vi : range(num_vertices)) { - size_t end_idx = adjacency_index[vi]; - for (size_t i = start_idx; i < end_idx; i++) { - if (adjacency_data[i] != invalid()) { - adjacency_data[count++] = adjacency_data[i]; - } else { - break; - } +template +AdjacencyList compute_vertex_vertex_adjacency_facet(SurfaceMesh& mesh) +{ + const auto num_vertices = mesh.get_num_vertices(); + const auto num_facets = mesh.get_num_facets(); + + using ValueArray = typename AdjacencyList::ValueArray; + using IndexArray = typename AdjacencyList::IndexArray; + + ValueArray adjacency_data; + IndexArray adjacency_index(num_vertices + 1, 0); + + // Each vertex in a facet of size n is adjacent to the other n-1 vertices. + for (auto fi : range(num_facets)) { + const Index c_begin = mesh.get_facet_corner_begin(fi); + const Index c_end = mesh.get_facet_corner_end(fi); + const Index n = c_end - c_begin; + for (Index ci = c_begin; ci < c_end; ci++) { + adjacency_index[mesh.get_corner_vertex(ci)] += (n - 1); } - adjacency_index[vi] = count; - start_idx = end_idx; } - adjacency_data.resize(count); - adjacency_data.shrink_to_fit(); + std::rotate(adjacency_index.rbegin(), adjacency_index.rbegin() + 1, adjacency_index.rend()); - adjacency_index.front() = 0; + std::partial_sum(adjacency_index.begin(), adjacency_index.end(), adjacency_index.begin()); + adjacency_data.resize(adjacency_index.back()); + + // Gather all within-facet pairs (both directions). + 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); - AdjacencyList adjacency_list(std::move(adjacency_data), std::move(adjacency_index)); - return adjacency_list; + for (Index ci = c_begin; ci < c_end; ci++) { + Index v_i = mesh.get_corner_vertex(ci); + for (Index cj = c_begin; cj < c_end; cj++) { + if (ci == cj) continue; + adjacency_data[adjacency_index[v_i]++] = mesh.get_corner_vertex(cj); + } + } + } + + return dedup_and_condense(std::move(adjacency_data), std::move(adjacency_index)); +} + +} // namespace + +template +AdjacencyList compute_vertex_vertex_adjacency( + SurfaceMesh& mesh, + DualConnectivityType connectivity_type) +{ + switch (connectivity_type) { + case DualConnectivityType::Edge: return compute_vertex_vertex_adjacency_edge(mesh); + case DualConnectivityType::Facet: return compute_vertex_vertex_adjacency_facet(mesh); + default: + la_runtime_assert(false, "Unsupported DualConnectivityType for vertex-vertex adjacency"); + } } #define LA_X_compute_vertex_vertex_adjacency(_, Scalar, Index) \ template LA_CORE_API AdjacencyList compute_vertex_vertex_adjacency( \ - SurfaceMesh&); + SurfaceMesh&, \ + DualConnectivityType); LA_SURFACE_MESH_X(compute_vertex_vertex_adjacency, 0) } // namespace lagrange diff --git a/modules/core/src/internal/map_attributes.cpp b/modules/core/src/internal/map_attributes.cpp index 06be57c7..0fca5ee7 100644 --- a/modules/core/src/internal/map_attributes.cpp +++ b/modules/core/src/internal/map_attributes.cpp @@ -23,6 +23,26 @@ namespace lagrange::internal { +namespace { + +// TODO: could be replaced by making SurfaceMesh::get_num_elements_internal public +template +Index get_num_elements(const SurfaceMesh& mesh) +{ + if constexpr (element == Vertex) { + return mesh.get_num_vertices(); + } else if constexpr (element == Facet) { + return mesh.get_num_facets(); + } else if constexpr (element == Corner) { + return mesh.get_num_corners(); + } else if constexpr (element == Edge) { + return mesh.get_num_edges(); + } else { + throw Error(format("Unsupported attribute element type {}", static_cast(element))); + } +} +} // namespace + template void map_attributes( const SurfaceMesh& source_mesh, @@ -36,7 +56,7 @@ void map_attributes( la_runtime_assert( mapping_offsets.empty() || - static_cast(mapping_offsets.size()) == target_mesh.get_num_vertices() + 1); + mapping_offsets.size() == static_cast(get_num_elements(target_mesh) + 1)); auto map_attribute_no_offset = [&](std::string_view name, auto&& attr) { using AttributeType = std::decay_t; @@ -187,30 +207,30 @@ void map_attributes( details::Access::Read>(source_mesh, map_attribute, options.selected_attributes); } -#define LA_X_map_attributes(_, Scalar, Index) \ - template void map_attributes( \ - const SurfaceMesh&, \ - SurfaceMesh&, \ - span, \ - span, \ - const MapAttributesOptions&); \ - template void map_attributes( \ - const SurfaceMesh&, \ - SurfaceMesh&, \ - span, \ - span, \ - const MapAttributesOptions&); \ - template void map_attributes( \ - const SurfaceMesh&, \ - SurfaceMesh&, \ - span, \ - span, \ - const MapAttributesOptions&); \ - template void map_attributes( \ - const SurfaceMesh&, \ - SurfaceMesh&, \ - span, \ - span, \ +#define LA_X_map_attributes(_, Scalar, Index) \ + template LA_CORE_API void map_attributes( \ + const SurfaceMesh&, \ + SurfaceMesh&, \ + span, \ + span, \ + const MapAttributesOptions&); \ + template LA_CORE_API void map_attributes( \ + const SurfaceMesh&, \ + SurfaceMesh&, \ + span, \ + span, \ + const MapAttributesOptions&); \ + template LA_CORE_API void map_attributes( \ + const SurfaceMesh&, \ + SurfaceMesh&, \ + span, \ + span, \ + const MapAttributesOptions&); \ + template LA_CORE_API void map_attributes( \ + const SurfaceMesh&, \ + SurfaceMesh&, \ + span, \ + span, \ const MapAttributesOptions&); LA_SURFACE_MESH_X(map_attributes, 0) diff --git a/modules/core/src/isoline.cpp b/modules/core/src/isoline.cpp index f35a71e4..a8ca91c6 100644 --- a/modules/core/src/isoline.cpp +++ b/modules/core/src/isoline.cpp @@ -26,6 +26,9 @@ #include #include +#include +#include + namespace lagrange { namespace { @@ -36,37 +39,98 @@ enum class Sign { Outside, }; +enum class IsolineMode { + Trim, ///< Keep only the region on the `keep_below` side of the isoline. + Extract, ///< Output the isoline itself as a collection of size-2 edge facets. + Insert, ///< Keep the whole mesh, inserting the isoline as a chain of interior edges. +}; + +// Provenance of a corner in the trimmed mesh, expressed in terms of corners of the parent facet. +// The corner value is obtained by linearly interpolating along the parent edge: +// value = value(c0) * (1 - t) + value(c1) * t +// For a corner that survives trimming (i.e. an original mesh vertex), we set c0 == c1 and t == 0 so +// the original value is preserved exactly. +template +struct CornerSource +{ + Index c0; + Index c1; + double t; +}; + +// Linearly interpolate rows `a` and `b` of `src` by parameter `t`, writing the result into row +// `dst_row` of `dst`. Interpolation is done in double precision; integer destination attributes are +// truncated on the final cast, matching the rest of the isoline interpolation paths. +template +void interpolate_row( + Eigen::MatrixBase& dst, + Eigen::Index dst_row, + const Eigen::MatrixBase& src, + Eigen::Index a, + Eigen::Index b, + double t) +{ + using ValueType = typename DstDerived::Scalar; + dst.row(dst_row) = + (src.row(a).template cast() * (1.0 - t) + src.row(b).template cast() * t) + .template cast(); +} + template struct LocalDataT { std::vector corners_with_crossing; std::vector> new_facets; + std::vector new_facet_to_old_facet; + std::vector, 4>> new_facet_corner_sources; }; +// Read-only view of the scalar field sampled on `mesh`, plus the geometric queries the isoline +// passes need. Bundling these into one object (instead of a web of capturing lambdas) lets each +// pass +// be a standalone function. Holds references/spans into `mesh`, so it must not outlive it. template -SurfaceMesh isoline_internal( - SurfaceMesh mesh, - const IsolineOptions& options, - bool isolines_only, - const Attribute& values_, - const Attribute* indices_ = nullptr) +struct IsolineField { - span values = values_.get_all(); - span indices = indices_ ? indices_->get_all() : span(); - - SurfaceMesh result = mesh; - mesh.initialize_edges(); - result.clear_facets(); - - const Index dimension = mesh.get_dimension(); - const auto positions = mesh.get_vertex_to_position().get_all(); - const auto c2v = mesh.get_corner_to_vertex().get_all(); + const SurfaceMesh& mesh; + const IsolineOptions& options; + span values; + span indices; + span c2v; + span positions; + Index dimension; + size_t num_channels; + + IsolineField( + const SurfaceMesh& mesh_, + const IsolineOptions& options_, + const Attribute& values_, + const Attribute* indices_) + : mesh(mesh_) + , options(options_) + , values(values_.get_all()) + , indices(indices_ ? indices_->get_all() : span()) + , c2v(mesh_.get_corner_to_vertex().get_all()) + , positions(mesh_.get_vertex_to_position().get_all()) + , dimension(mesh_.get_dimension()) + , num_channels(values_.get_num_channels()) + {} + + // Scalar field value at `corner` (looked up via the vertex or the indexed attribute). + double eval_corner(Index corner) const + { + const Index row = indices.empty() ? c2v[corner] : indices[corner]; + return values[row * num_channels + options.channel_index]; + } - auto is_corner_smooth = [&](Index ci, Index cj) { + // Whether corners `ci` and `cj` carry the same isovalue index (the field is continuous there). + bool is_corner_smooth(Index ci, Index cj) const + { return indices.empty() || indices[ci] == indices[cj]; - }; + } - auto is_edge_smooth = [&](Index ci, Index cj) { + bool is_edge_smooth(Index ci, Index cj) const + { Index ci2 = mesh.get_next_corner_around_facet(ci); Index cj2 = mesh.get_next_corner_around_facet(cj); if (c2v[ci] == c2v[cj]) { @@ -79,18 +143,11 @@ SurfaceMesh isoline_internal( la_debug_assert(c2v[ci] == c2v[cj2]); return is_corner_smooth(ci, cj2) && is_corner_smooth(ci2, cj); } - }; - - const size_t num_channels = values_.get_num_channels(); - auto eval_corner = [&](Index corner) { - if (indices.empty()) { - return values[c2v[corner] * num_channels + options.channel_index]; - } else { - return values[indices[corner] * num_channels + options.channel_index]; - } - }; + } - auto has_crossing = [&](Index ci) { + // Whether the edge leaving `ci` is crossed by the isoline in its interior (not at a vertex). + bool has_crossing(Index ci) const + { Index cj = mesh.get_next_corner_around_facet(ci); double xi = eval_corner(ci) - options.isovalue; double xj = eval_corner(cj) - options.isovalue; @@ -98,9 +155,11 @@ SurfaceMesh isoline_internal( return false; // crossing is on vertex } return std::signbit(xi) != std::signbit(xj); - }; + } - auto corner_sign = [&](Index ci) { + // Classify `ci` relative to the isoline (kept side, on the line, or outside). + Sign corner_sign(Index ci) const + { double xi = eval_corner(ci) - options.isovalue; if (std::fpclassify(xi) == FP_ZERO) { return Sign::Zero; @@ -110,37 +169,48 @@ SurfaceMesh isoline_internal( } else { return Sign::Outside; } - }; + } - auto interpolate_position = [&](Index ci, span pos) { + // Interpolation parameter of the isocrossing along the edge starting at corner `ci`. + double crossing_param(Index ci) const + { Index cj = mesh.get_next_corner_around_facet(ci); double xi = eval_corner(ci); double xj = eval_corner(cj); - Scalar t = static_cast((options.isovalue - xi) / (xj - xi)); + return (options.isovalue - xi) / (xj - xi); + } + + // Position of the isocrossing along the edge starting at corner `ci`. + void interpolate_position(Index ci, span pos) const + { + Index cj = mesh.get_next_corner_around_facet(ci); + Scalar t = static_cast(crossing_param(ci)); for (Index d = 0; d < dimension; ++d) { pos[d] = positions[c2v[ci] * dimension + d] * (1 - t) + positions[c2v[cj] * dimension + d] * t; } - }; - - // New vertex ids for edge isocrossing are associated to the "provoking" corner of the edge. We - // may have shared ids due to corners from different facets sharing the same indices for the - // isovalue attribute. So we use union-find to "join" provoking corners that share the same - // isovalue indices. - std::vector repr_to_new_vertex(mesh.get_num_corners(), invalid()); - DisjointSets repr_corner(mesh.get_num_corners()); - - using LocalData = LocalDataT; - tbb::enumerable_thread_specific data; + } +}; - // First pass to assign new vertex ids to corners with isocrossing - const Index num_vertices = mesh.get_num_vertices(); +// Pass 1: assign a new output-vertex id to each edge isocrossing. Provoking corners that share the +// same isovalue index are merged via union-find, so a crossing shared by adjacent facets becomes a +// single vertex. Returns the map from new vertex (offset by `num_vertices`) to its provoking +// corner, and fills `repr_to_new_vertex` keyed by representative corner. +template +tbb::concurrent_vector assign_crossing_vertices( + const IsolineField& field, + Index num_vertices, + DisjointSets& repr_corner, + std::vector& repr_to_new_vertex, + tbb::enumerable_thread_specific>& data) +{ + const auto& mesh = field.mesh; tbb::concurrent_vector new_vertex_to_provoking_corner; tbb::parallel_for(Index(0), mesh.get_num_edges(), [&](Index e) { auto& corners_with_crossing = data.local().corners_with_crossing; corners_with_crossing.clear(); mesh.foreach_corner_around_edge(e, [&](Index c) { - if (has_crossing(c)) { + if (field.has_crossing(c)) { corners_with_crossing.push_back(c); } }); @@ -148,7 +218,7 @@ SurfaceMesh isoline_internal( Index ci = corners_with_crossing[i]; for (size_t j = i + 1; j < corners_with_crossing.size(); j++) { Index cj = corners_with_crossing[j]; - if (is_edge_smooth(ci, cj)) { + if (field.is_edge_smooth(ci, cj)) { repr_corner.merge(ci, cj); } } @@ -161,19 +231,176 @@ SurfaceMesh isoline_internal( } } }); + return new_vertex_to_provoking_corner; +} - // Second pass to compute new vertex positions - result.add_vertices( - static_cast(new_vertex_to_provoking_corner.size()), - [&](Index v, span pos) { - interpolate_position(new_vertex_to_provoking_corner[v], pos); - }); +// Pass 3: for each parent facet, walk its boundary and emit the sub-facet(s) it contributes to the +// output, recording per-corner provenance for later attribute interpolation. The emitted polygons +// depend on `mode`: Extract yields the isoline segments, Trim the kept side, Insert both sides. The +// results accumulate into the thread-local `data` buffers. +template +void build_subfacets( + const IsolineField& field, + IsolineMode mode, + bool keep_attributes, + DisjointSets& repr_corner, + const std::vector& repr_to_new_vertex, + tbb::enumerable_thread_specific>& data) +{ + const auto& mesh = field.mesh; + const auto& c2v = field.c2v; + tbb::parallel_for(Index(0), mesh.get_num_facets(), [&](Index f) { + const Index c0 = mesh.get_facet_corner_begin(f); + const Index cc[3] = {c0, c0 + 1, c0 + 2}; + const Sign sign[3] = { + field.corner_sign(cc[0]), + field.corner_sign(cc[1]), + field.corner_sign(cc[2])}; + // New vertex inserted on the edge leaving each corner (invalid if the edge isn't crossed). + const Index vv[3] = { + repr_to_new_vertex[repr_corner.find(cc[0])], + repr_to_new_vertex[repr_corner.find(cc[1])], + repr_to_new_vertex[repr_corner.find(cc[2])]}; + + auto& new_facets = data.local().new_facets; + auto& new_facet_to_old_facet = data.local().new_facet_to_old_facet; + auto& new_facet_corner_sources = data.local().new_facet_corner_sources; + + auto emit = [&](const StackVector& poly, + const StackVector, 4>& poly_src) { + new_facets.push_back(poly); + if (keep_attributes) { + new_facet_corner_sources.push_back(poly_src); + new_facet_to_old_facet.push_back(f); + } + }; + + // Walk the facet boundary as (corner, crossing, corner, crossing, ...), keeping the corners + // for which `keep_corner(sign)` holds plus every isocrossing vertex. Walking in corner + // order preserves the parent facet's orientation. The provenance of each output corner is + // recorded so corner/indexed attributes can be interpolated within the parent facet. + auto build_polygon = [&](auto&& keep_corner, + StackVector& poly, + StackVector, 4>& poly_src) { + for (int k = 0; k < 3; ++k) { + if (keep_corner(sign[k])) { + poly.push_back(c2v[cc[k]]); + if (keep_attributes) poly_src.push_back({cc[k], cc[k], 0.0}); + } + if (vv[k] != invalid()) { + poly.push_back(vv[k]); + if (keep_attributes) { + poly_src.push_back( + {cc[k], + mesh.get_next_corner_around_facet(cc[k]), + field.crossing_param(cc[k])}); + } + } + } + }; + + if (mode == IsolineMode::Extract) { + // Collect the isoline points on this facet (zero-valued corners and edge crossings) and + // emit the segment(s) joining them. + StackVector points; + StackVector, 4> points_src; + build_polygon([](Sign s) { return s == Sign::Zero; }, points, points_src); + la_debug_assert(points.size() < 4); + if (points.size() == 2) { + emit(points, points_src); + } else if (points.size() == 3) { + // Degenerate facet lying entirely on the isoline: emit its three edges. + emit({points[0], points[1]}, {points_src[0], points_src[1]}); + emit({points[1], points[2]}, {points_src[1], points_src[2]}); + emit({points[2], points[0]}, {points_src[2], points_src[0]}); + } + // size 0 or 1: the isoline only touches this facet at a single point, nothing to + // record. + return; + } + + // Trim/Insert: emit the polygon on the kept side of the isoline (a triangle or a quad). + StackVector inside; + StackVector, 4> inside_src; + build_polygon([](Sign s) { return s != Sign::Outside; }, inside, inside_src); + if (inside.size() >= 3) { + la_debug_assert(inside.size() == 3 || inside.size() == 4); + emit(inside, inside_src); + } + + // For insertion we also emit the polygon on the other side, sharing the isocrossing + // vertices, so the whole mesh is kept with the isoline as a chain of interior edges. We + // skip it when no corner is strictly outside, otherwise a facet lying on the isoline would + // be emitted twice. + if (mode == IsolineMode::Insert && + (sign[0] == Sign::Outside || sign[1] == Sign::Outside || sign[2] == Sign::Outside)) { + StackVector outside; + StackVector, 4> outside_src; + build_polygon([](Sign s) { return s != Sign::Inside; }, outside, outside_src); + if (outside.size() >= 3) { + la_debug_assert(outside.size() == 3 || outside.size() == 4); + emit(outside, outside_src); + } + } + }); +} + +// Append all thread-local sub-facets to `result`. When propagating attributes, also concatenate the +// per-facet parent map and per-corner provenance in the same order the facets are added, so their +// indexing matches the result mesh. +template +void gather_subfacets( + tbb::enumerable_thread_specific>& data, + bool keep_attributes, + SurfaceMesh& result, + std::vector& new_facet_to_old_facet, + std::vector>& corner_sources) +{ + if (keep_attributes) { + size_t total_facets = 0; + for (const auto& local_data : data) { + total_facets += local_data.new_facets.size(); + } + new_facet_to_old_facet.reserve(total_facets); + corner_sources.reserve(total_facets * 4); // at most 4 corners per (quad) sub-facet + } + for (const auto& local_data : data) { + const auto& new_facets = local_data.new_facets; + result.add_hybrid( + static_cast(new_facets.size()), + [&](Index f) { return static_cast(new_facets[f].size()); }, + [&](Index f, span t) { + std::copy_n(new_facets[f].begin(), t.size(), t.begin()); + }); + if (keep_attributes) { + new_facet_to_old_facet.insert( + new_facet_to_old_facet.end(), + local_data.new_facet_to_old_facet.begin(), + local_data.new_facet_to_old_facet.end()); + for (const auto& corners : local_data.new_facet_corner_sources) { + corner_sources.insert(corner_sources.end(), corners.begin(), corners.end()); + } + } + } +} - // Interpolate vertex attributes for new vertices - // TODO: - // - Per-facet attributes (copy to new facets) - // - Indexed attributes (interpolate) - // - Corner attributes (interpolate) +// Propagate vertex, facet, corner, and indexed attributes from the parent mesh onto the sub-facets +// produced by the connectivity pass. Facet attributes are inherited from the parent facet; corner +// and indexed attributes are linearly interpolated within the parent facet using the recorded +// corner provenance. `new_facet_to_old_facet` maps each result facet to its parent facet, and +// `corner_sources` maps each result corner to its provenance; both are indexed in result order. +template +void propagate_attributes( + const IsolineField& field, + SurfaceMesh& result, + const tbb::concurrent_vector& new_vertex_to_provoking_corner, + Index num_vertices, + const std::vector& new_facet_to_old_facet, + const std::vector>& corner_sources) +{ + const auto& mesh = field.mesh; + + // Interpolate vertex attributes for the new isocrossing vertices. par_foreach_named_attribute_read(mesh, [&](auto name, auto&& old_attr) { if (mesh.attr_name_is_reserved(name)) { return; @@ -186,80 +413,142 @@ SurfaceMesh isoline_internal( for (Index v = num_vertices, i = 0; v < result.get_num_vertices(); ++v, ++i) { Index ci = new_vertex_to_provoking_corner[i]; Index cj = mesh.get_next_corner_around_facet(ci); - double xi = eval_corner(ci); - double xj = eval_corner(cj); - double t = (options.isovalue - xi) / (xj - xi); - new_values.row(v) = (old_values.row(c2v[ci]).template cast() * (1.0 - t) + - old_values.row(c2v[cj]).template cast() * t) - .template cast(); + interpolate_row( + new_values, + v, + old_values, + field.c2v[ci], + field.c2v[cj], + field.crossing_param(ci)); } }); - // Third pass to compute the connectivity - tbb::parallel_for(Index(0), mesh.get_num_facets(), [&](Index f) { - const Index c0 = mesh.get_facet_corner_begin(f); - const Index c1 = c0 + 1; - const Index c2 = c0 + 2; - auto s0 = corner_sign(c0); - auto s1 = corner_sign(c1); - auto s2 = corner_sign(c2); - auto v01 = repr_to_new_vertex[repr_corner.find(c0)]; - auto v12 = repr_to_new_vertex[repr_corner.find(c1)]; - auto v20 = repr_to_new_vertex[repr_corner.find(c2)]; - auto& new_facets = data.local().new_facets; - StackVector facet; - if (isolines_only ? s0 == Sign::Zero : s0 != Sign::Outside) { - facet.push_back(c2v[c0]); - } - if (v01 != invalid()) { - facet.push_back(v01); - } - if (isolines_only ? s1 == Sign::Zero : s1 != Sign::Outside) { - facet.push_back(c2v[c1]); + // Copy facet attributes from each parent facet to the sub-facets it produced. + par_foreach_named_attribute_read(mesh, [&](auto name, auto&& old_attr) { + if (mesh.attr_name_is_reserved(name)) { + return; } - if (v12 != invalid()) { - facet.push_back(v12); + using AttributeType = std::decay_t; + using ValueType = typename AttributeType::ValueType; + auto old_values = matrix_view(old_attr); + auto& new_attr = result.template ref_attribute(name); + auto new_values = matrix_ref(new_attr); + for (Index f = 0; f < result.get_num_facets(); ++f) { + new_values.row(f) = old_values.row(new_facet_to_old_facet[f]); } - if (isolines_only ? s2 == Sign::Zero : s2 != Sign::Outside) { - facet.push_back(c2v[c2]); + }); + + // Interpolate corner attributes within each parent facet. + par_foreach_named_attribute_read(mesh, [&](auto name, auto&& old_attr) { + if (mesh.attr_name_is_reserved(name)) { + return; } - if (v20 != invalid()) { - facet.push_back(v20); + using AttributeType = std::decay_t; + using ValueType = typename AttributeType::ValueType; + auto old_values = matrix_view(old_attr); + auto& new_attr = result.template ref_attribute(name); + auto new_values = matrix_ref(new_attr); + for (Index c = 0; c < result.get_num_corners(); ++c) { + const auto& src = corner_sources[c]; + interpolate_row(new_values, c, old_values, src.c0, src.c1, src.t); } - if (facet.empty()) { + }); + + // Interpolate indexed attributes within each parent facet. Surviving corners keep their + // original value index (preserving sharing), while each isocrossing corner gets a freshly + // interpolated value appended to the value buffer. + par_foreach_named_attribute_read(mesh, [&](auto name, auto&& old_attr) { + if (mesh.attr_name_is_reserved(name)) { return; } - if (isolines_only) { - la_debug_assert(facet.size() < 4); - if (facet.size() == 1) { - // Only one isocrossing, at a vertex, no edge to record - return; - } else if (facet.size() == 2) { - // Two isocrossings, either at a vertex or an edge - new_facets.push_back(facet); + using AttributeType = std::decay_t; + using ValueType = typename AttributeType::ValueType; + auto old_values = matrix_view(old_attr.values()); + auto old_indices = old_attr.indices().get_all(); + + auto& new_attr = result.template ref_indexed_attribute(name); + const size_t old_num_values = new_attr.values().get_num_elements(); + size_t num_new_values = 0; + for (const auto& src : corner_sources) { + if (src.c0 != src.c1) ++num_new_values; + } + new_attr.values().resize_elements(old_num_values + num_new_values); + auto new_values = matrix_ref(new_attr.values()); + auto new_indices = new_attr.indices().ref_all(); + size_t next_value = old_num_values; + for (Index c = 0; c < result.get_num_corners(); ++c) { + const auto& src = corner_sources[c]; + if (src.c0 == src.c1) { + new_indices[c] = old_indices[src.c0]; } else { - // Three isocrossings, either at a vertex or an edge - new_facets.push_back({facet[0], facet[1]}); - new_facets.push_back({facet[1], facet[2]}); - new_facets.push_back({facet[2], facet[0]}); + interpolate_row( + new_values, + static_cast(next_value), + old_values, + old_indices[src.c0], + old_indices[src.c1], + src.t); + new_indices[c] = static_cast(next_value); + ++next_value; } - // Cannot have 4 isocrossing in a triangle - } else { - // Trimming produces either a triangle or a quad - la_debug_assert(facet.size() == 3 || facet.size() == 4); - data.local().new_facets.push_back(facet); } }); +} - // Gather new facets and add them to the result - for (const auto& local_data : data) { - const auto& new_facets = local_data.new_facets; - result.add_hybrid( - static_cast(new_facets.size()), - [&](Index f) { return static_cast(new_facets[f].size()); }, - [&](Index f, span t) { - std::copy_n(new_facets[f].begin(), t.size(), t.begin()); - }); +template +SurfaceMesh isoline_internal( + SurfaceMesh mesh, + const IsolineOptions& options, + IsolineMode mode, + const Attribute& values_, + const Attribute* indices_ = nullptr) +{ + SurfaceMesh result = mesh; + const bool keep_attributes = options.keep_attributes; + if (!keep_attributes) { + result = SurfaceMesh::stripped_copy(mesh); + } + mesh.initialize_edges(); + result.clear_facets(); + + const IsolineField field(mesh, options, values_, indices_); + + // New vertex ids for edge isocrossing are associated to the "provoking" corner of the edge. We + // may have shared ids due to corners from different facets sharing the same indices for the + // isovalue attribute. So we use union-find to "join" provoking corners that share the same + // isovalue indices. + const Index num_vertices = mesh.get_num_vertices(); + std::vector repr_to_new_vertex(mesh.get_num_corners(), invalid()); + DisjointSets repr_corner(mesh.get_num_corners()); + tbb::enumerable_thread_specific> data; + + // Pass 1: assign new output-vertex ids to the edge isocrossings. + auto new_vertex_to_provoking_corner = + assign_crossing_vertices(field, num_vertices, repr_corner, repr_to_new_vertex, data); + + // Pass 2: compute the positions of the new vertices. + result.add_vertices( + static_cast(new_vertex_to_provoking_corner.size()), + [&](Index v, span pos) { + field.interpolate_position(new_vertex_to_provoking_corner[v], pos); + }); + + // Pass 3: compute the connectivity of the output facets. + build_subfacets(field, mode, keep_attributes, repr_corner, repr_to_new_vertex, data); + + // Gather the new facets (and, when keeping attributes, the provenance maps) into the result. + std::vector new_facet_to_old_facet; + std::vector> corner_sources; + gather_subfacets(data, keep_attributes, result, new_facet_to_old_facet, corner_sources); + + if (keep_attributes) { + propagate_attributes( + field, + result, + new_vertex_to_provoking_corner, + num_vertices, + new_facet_to_old_facet, + corner_sources); } // Finally, remove isolated vertices caused by removed facets. @@ -272,11 +561,11 @@ template SurfaceMesh isoline_internal( const SurfaceMesh& mesh, const IsolineOptions& options, - bool isolines_only) + IsolineMode mode) { la_runtime_assert( mesh.is_triangle_mesh(), - "Isoline extraction/trimming only works for triangle meshes"); + "Isoline extraction/trimming/insertion only works for triangle meshes"); SurfaceMesh result; @@ -293,25 +582,20 @@ SurfaceMesh isoline_internal( } if constexpr (AttributeType::IsIndexed) { if constexpr (std::is_same_v) { - result = - isoline_internal(mesh, options, isolines_only, attr.values(), &attr.indices()); + result = isoline_internal(mesh, options, mode, attr.values(), &attr.indices()); } else { result = isoline_internal( mesh, options, - isolines_only, + mode, Attribute::cast_copy(attr.values()), &attr.indices()); } } else { if constexpr (std::is_same_v) { - result = isoline_internal(mesh, options, isolines_only, attr); + result = isoline_internal(mesh, options, mode, attr); } else { - result = isoline_internal( - mesh, - options, - isolines_only, - Attribute::cast_copy(attr)); + result = isoline_internal(mesh, options, mode, Attribute::cast_copy(attr)); } } }); @@ -326,7 +610,7 @@ SurfaceMesh trim_by_isoline( const SurfaceMesh& mesh, const IsolineOptions& options) { - return isoline_internal(mesh, options, false); + return isoline_internal(mesh, options, IsolineMode::Trim); } template @@ -334,7 +618,15 @@ SurfaceMesh extract_isoline( const SurfaceMesh& mesh, const IsolineOptions& options) { - return isoline_internal(mesh, options, true); + return isoline_internal(mesh, options, IsolineMode::Extract); +} + +template +SurfaceMesh insert_isoline( + const SurfaceMesh& mesh, + const IsolineOptions& options) +{ + return isoline_internal(mesh, options, IsolineMode::Insert); } #define LA_X_isoline(_, Scalar, Index) \ @@ -342,6 +634,9 @@ SurfaceMesh extract_isoline( const SurfaceMesh& mesh, \ const IsolineOptions& options); \ template LA_CORE_API SurfaceMesh extract_isoline( \ + const SurfaceMesh& mesh, \ + const IsolineOptions& options); \ + template LA_CORE_API SurfaceMesh insert_isoline( \ const SurfaceMesh& mesh, \ const IsolineOptions& options); LA_SURFACE_MESH_X(isoline, 0) diff --git a/modules/core/src/mesh_cleanup/split_long_edges.cpp b/modules/core/src/mesh_cleanup/split_long_edges.cpp index 27751568..b507d7d1 100644 --- a/modules/core/src/mesh_cleanup/split_long_edges.cpp +++ b/modules/core/src/mesh_cleanup/split_long_edges.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -32,35 +33,6 @@ namespace lagrange { -namespace { - -template -void interpolate_row( - Eigen::MatrixBase& data, - Index row_to, - Index row_from_1, - Index row_from_2, - Scalar t) -{ - la_debug_assert(row_to < static_cast(data.rows())); - la_debug_assert(row_from_1 < static_cast(data.rows())); - la_debug_assert(row_from_2 < static_cast(data.rows())); - using ValueType = typename Derived::Scalar; - - if constexpr (std::is_integral_v) { - data.row(row_to) = (data.row(row_from_1).template cast() * (1 - t) + - data.row(row_from_2).template cast() * t) - .array() - .round() - .template cast() - .eval(); - } else { - data.row(row_to) = data.row(row_from_1) * (1 - t) + data.row(row_from_2) * t; - } -} - -} // namespace - template void split_long_edges(SurfaceMesh& mesh, SplitLongEdgesOptions options) { @@ -160,7 +132,7 @@ void split_long_edges(SurfaceMesh& mesh, SplitLongEdgesOptions op Index v0, v1; Scalar t; std::tie(v0, v1, t) = additional_vertex_sources[i]; - interpolate_row(data, num_input_vertices + i, v0, v1, t); + internal::interpolate_attribute_row(data, num_input_vertices + i, v0, v1, t); } }); diff --git a/modules/core/src/mesh_cleanup/split_obtuse_triangles.cpp b/modules/core/src/mesh_cleanup/split_obtuse_triangles.cpp new file mode 100644 index 00000000..ed55dd37 --- /dev/null +++ b/modules/core/src/mesh_cleanup/split_obtuse_triangles.cpp @@ -0,0 +1,261 @@ +/* + * 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 + +// clang-format off +#include +#include +#include +// clang-format on + +#include +#include +#include +#include + +namespace lagrange { + +template +size_t split_obtuse_triangles(SurfaceMesh& mesh, SplitObtuseTrianglesOptions options) +{ + la_runtime_assert(mesh.is_triangle_mesh(), "Input mesh is not a triangle mesh."); + + constexpr std::string_view internal_active_region_attribute_name = + "@__active_region_internal_obtuse__"; + + const Scalar max_angle = static_cast(options.max_angle); + la_runtime_assert( + std::isfinite(max_angle) && max_angle > Scalar(0), + "max_angle must be finite and positive."); + if (options.max_iterations == 0) { + constexpr Scalar pi_3 = static_cast(lagrange::internal::pi / 3.0); + la_runtime_assert( + max_angle > pi_3, + "max_angle <= pi/3 can never be satisfied; use a finite max_iterations to avoid an " + "infinite loop."); + } + + const size_t max_iter = + (options.max_iterations == 0) ? std::numeric_limits::max() : options.max_iterations; + + size_t total_split = 0; + + // Buffers reused across iterations to avoid per-iteration allocations. + struct EdgeSplit + { + Index v0 = invalid(); + Index v1 = invalid(); + Scalar t = Scalar(0.5); + Scalar angle = Scalar(0); + bool marked = false; + }; + Eigen::Matrix angles; + std::vector edge_splits; + std::vector new_coords; + std::vector> vertex_sources; + std::vector edge_to_new_vertex; + + bool converged = false; + for (size_t iter = 0; iter < max_iter; ++iter) { + logger().debug("[split_obtuse_triangles] iteration {}", iter); + mesh.initialize_edges(); + + AttributeId internal_active_attr_id = invalid(); + const uint8_t* active_data = nullptr; + if (!options.active_region_attribute.empty()) { + la_runtime_assert( + mesh.has_attribute(options.active_region_attribute), + "active_region_attribute not found in mesh."); + la_runtime_assert( + mesh.get_attribute_base(options.active_region_attribute).get_element_type() == + AttributeElement::Facet, + "active_region_attribute must be a facet attribute."); + la_runtime_assert( + mesh.get_attribute_base(options.active_region_attribute).get_num_channels() == 1, + "active_region_attribute must be a scalar (single-channel) attribute."); + internal_active_attr_id = cast_attribute( + mesh, + options.active_region_attribute, + internal_active_region_attribute_name); + active_data = attribute_vector_view(mesh, internal_active_attr_id).data(); + } + + // Ensure the temporary internal attribute is removed when leaving the iteration, + // even if the body throws. + auto active_region_cleanup = make_scope_guard([&] { + if (internal_active_attr_id != invalid()) { + mesh.delete_attribute(internal_active_region_attribute_name); + } + }); + + auto is_facet_active = [&](Index fid) { + if (active_data == nullptr) return true; + return static_cast(active_data[fid]); + }; + + // Compute interior angles: angles(f, d) = angle at corner d of facet f. + internal::internal_angles(vertex_view(mesh), facet_view(mesh), angles); + + const Index num_edges = mesh.get_num_edges(); + const Index num_facets = mesh.get_num_facets(); + const Index dim = mesh.get_dimension(); + const Index num_input_vertices = mesh.get_num_vertices(); + + edge_splits.assign(num_edges, EdgeSplit{}); + + auto vertices = vertex_view(mesh); + auto facets = facet_view(mesh); + + for (Index f = 0; f < num_facets; ++f) { + if (!is_facet_active(f)) continue; + + // Find local corner with the largest interior angle. + Index d_star = 0; + for (Index d = 1; d < 3; ++d) { + if (angles(f, d) > angles(f, d_star)) d_star = d; + } + const Scalar max_a = angles(f, d_star); + if (!(max_a > max_angle)) continue; + + // Edge opposite the obtuse corner: between corners (d_star+1) and (d_star+2), + // i.e. local edge index (d_star + 1) % 3. + const Index local_edge = (d_star + 1) % 3; + const Index eid = mesh.get_edge(f, local_edge); + + if (edge_splits[eid].marked && edge_splits[eid].angle >= max_a) continue; + + const Index v_obtuse = facets(f, d_star); + const Index v_a = facets(f, (d_star + 1) % 3); + const Index v_b = facets(f, (d_star + 2) % 3); + + auto a = vertices.row(v_a); + auto b = vertices.row(v_b); + auto p = vertices.row(v_obtuse); + const auto ba = (b - a).eval(); + const Scalar l2 = ba.squaredNorm(); + if (l2 <= Scalar(0)) continue; // Degenerate edge. + + // Skip collinear/degenerate facets: if the obtuse vertex lies on the opposite + // edge, splitting cannot reduce the angle and would loop forever under + // unbounded iteration. + // Compute t first, then the perpendicular residual — avoids catastrophic + // cancellation between two large squared norms. + const auto pa = (p - a).eval(); + const Scalar t_raw = pa.dot(ba) / l2; + const Scalar dist_sq = (pa - t_raw * ba).squaredNorm(); + if (dist_sq <= std::numeric_limits::epsilon() * l2) continue; + + constexpr Scalar clamp_eps = std::numeric_limits::epsilon() * Scalar(16); + const Scalar t = !std::isfinite(t_raw) ? Scalar(0.5) + : t_raw < clamp_eps ? clamp_eps + : t_raw > Scalar(1) - clamp_eps ? Scalar(1) - clamp_eps + : t_raw; + + edge_splits[eid].v0 = v_a; + edge_splits[eid].v1 = v_b; + edge_splits[eid].t = t; + edge_splits[eid].angle = max_a; + edge_splits[eid].marked = true; + } + + // Count marked edges. + Index num_marked = 0; + for (const auto& es : edge_splits) { + if (es.marked) ++num_marked; + } + if (num_marked == 0) { + converged = true; + break; + } + + // Allocate new vertices at split positions. + new_coords.clear(); + new_coords.reserve(static_cast(num_marked) * dim); + vertex_sources.clear(); + vertex_sources.reserve(num_marked); + + edge_to_new_vertex.assign(num_edges, invalid()); + Index next_vertex_id = num_input_vertices; + for (Index e = 0; e < num_edges; ++e) { + if (!edge_splits[e].marked) continue; + const Scalar t = edge_splits[e].t; + const Index v0 = edge_splits[e].v0; + const Index v1 = edge_splits[e].v1; + auto p = ((Scalar(1) - t) * vertices.row(v0) + t * vertices.row(v1)).eval(); + new_coords.insert(new_coords.end(), p.data(), p.data() + dim); + vertex_sources.emplace_back(v0, v1, t); + edge_to_new_vertex[e] = next_vertex_id++; + } + + mesh.add_vertices(num_marked, {new_coords.data(), new_coords.size()}); + + // Interpolate vertex attributes for newly added vertices. + par_foreach_named_attribute_write( + mesh, + [&](std::string_view name, auto&& attr) { + using ValueType = typename std::decay_t::ValueType; + if (mesh.attr_name_is_reserved(name)) return; + auto data = matrix_ref(attr); + static_assert( + std::is_same_v::Scalar>); + for (size_t i = 0; i < vertex_sources.size(); ++i) { + const auto [v0, v1, t] = vertex_sources[i]; + internal::interpolate_attribute_row( + data, + num_input_vertices + static_cast(i), + v0, + v1, + t); + } + }); + + auto facets_to_remove = internal::split_edges( + mesh, + function_ref(Index)>([&](Index eid) -> span { + if (!edge_splits[eid].marked) return span(); + return span(&edge_to_new_vertex[eid], 1); + }), + function_ref([](Index) { return true; })); + + total_split += facets_to_remove.size(); + mesh.remove_facets(facets_to_remove); + } + + if (!converged && options.max_iterations > 0) { + logger().warn( + "[split_obtuse_triangles] did not converge after {} iterations.", + options.max_iterations); + } + + return total_split; +} + +#define LA_X_split_obtuse_triangles(_, Scalar, Index) \ + template LA_CORE_API size_t split_obtuse_triangles( \ + SurfaceMesh&, \ + SplitObtuseTrianglesOptions); +LA_SURFACE_MESH_X(split_obtuse_triangles, 0) + +} // namespace lagrange diff --git a/modules/core/src/mesh_cleanup/unflip_uv_triangles.cpp b/modules/core/src/mesh_cleanup/unflip_uv_triangles.cpp index fe3cef9e..3b7969ef 100644 --- a/modules/core/src/mesh_cleanup/unflip_uv_triangles.cpp +++ b/modules/core/src/mesh_cleanup/unflip_uv_triangles.cpp @@ -59,7 +59,7 @@ void unflip_uv_triangles(SurfaceMesh& mesh, const UnflipUVOptions }; std::vector additional_uv_values; - auto update_uv = [&](Index fid, Index lv, Eigen::Matrix new_uv) { + auto update_uv = [&](Index fid, Index lv, const Eigen::Matrix& new_uv) { additional_uv_values.insert(additional_uv_values.end(), new_uv.data(), new_uv.data() + 2); Index old_id = uv_indices(fid, lv); uv_indices(fid, lv) = static_cast(uv_values_attr.get_num_elements()) + diff --git a/modules/core/src/orientation.cpp b/modules/core/src/orientation.cpp index 8f025057..1fab3468 100644 --- a/modules/core/src/orientation.cpp +++ b/modules/core/src/orientation.cpp @@ -64,7 +64,7 @@ bool is_oriented(const SurfaceMesh& mesh) return tbb::parallel_reduce( tbb::blocked_range(0, mesh.get_num_edges()), - true, ///< initial value of the result. + true, // initial value of the result. [&](const tbb::blocked_range& r, bool oriented) -> bool { if (!oriented) return false; diff --git a/modules/core/src/reorder_mesh.cpp b/modules/core/src/reorder_mesh.cpp index 68684ab7..51882f96 100644 --- a/modules/core/src/reorder_mesh.cpp +++ b/modules/core/src/reorder_mesh.cpp @@ -165,15 +165,9 @@ std::vector spatial_ordering_points( } // namespace -/// -/// Reorder mesh vertices using Morton encoding. -/// -/// @todo Reorder mesh facets as well. -/// -/// @param[in,out] mesh Mesh to reorder. -/// -/// @tparam MeshType Mesh type. -/// +// Reorder mesh vertices using Morton encoding. +// +// TODO: Reorder mesh facets as well. template void reorder_mesh(SurfaceMesh& mesh, ReorderingMethod method) { diff --git a/modules/core/src/topology.cpp b/modules/core/src/topology.cpp index f439f6ae..8218cf61 100644 --- a/modules/core/src/topology.cpp +++ b/modules/core/src/topology.cpp @@ -107,7 +107,7 @@ bool is_vertex_manifold(const SurfaceMesh& mesh) return tbb::parallel_reduce( tbb::blocked_range(0, num_vertices), - true, ///< initial value of the result. + true, // initial value of the result. [&](const tbb::blocked_range& r, bool manifold) -> bool { if (!manifold) return false; diff --git a/modules/core/src/utils/point_on_segment.cpp b/modules/core/src/utils/point_on_segment.cpp index 1cca2b4f..ad8bba26 100644 --- a/modules/core/src/utils/point_on_segment.cpp +++ b/modules/core/src/utils/point_on_segment.cpp @@ -17,24 +17,31 @@ namespace lagrange { namespace internal { -bool point_on_segment_2d(Eigen::Vector2d p, Eigen::Vector2d a, Eigen::Vector2d b) +bool point_on_segment_2d( + const Eigen::Vector2d& p, + const Eigen::Vector2d& a, + const Eigen::Vector2d& b) { ExactPredicatesShewchuk pred; auto res = pred.orient2D(p.data(), a.data(), b.data()); if (res != 0) { return false; } - if (a.x() > b.x()) { - std::swap(a.x(), b.x()); + double ax = a.x(), ay = a.y(); + double bx = b.x(), by = b.y(); + if (ax > bx) { + std::swap(ax, bx); } - if (a.y() > b.y()) { - std::swap(a.y(), b.y()); + if (ay > by) { + std::swap(ay, by); } - auto ret = (a.x() <= p.x() && p.x() <= b.x() && a.y() <= p.y() && p.y() <= b.y()); - return ret; + return (ax <= p.x() && p.x() <= bx && ay <= p.y() && p.y() <= by); } -bool point_on_segment_3d(Eigen::Vector3d p, Eigen::Vector3d a, Eigen::Vector3d b) +bool point_on_segment_3d( + const Eigen::Vector3d& p, + const Eigen::Vector3d& a, + const Eigen::Vector3d& b) { for (int d = 0; d < 3; ++d) { Eigen::Vector2d p2d(p(d), p((d + 1) % 3)); diff --git a/modules/core/tests/fmt/user_fmt_formatter.h b/modules/core/tests/fmt/user_fmt_formatter.h index a2083ef9..1c5ab397 100644 --- a/modules/core/tests/fmt/user_fmt_formatter.h +++ b/modules/core/tests/fmt/user_fmt_formatter.h @@ -13,6 +13,7 @@ #pragma message("Using user-provided fmt::formatter<> for Eigen types") +/// @cond LA_INTERNAL_DOCS template struct fmt::formatter, T>::value, char>> : fmt::nested_formatter @@ -30,3 +31,4 @@ struct fmt::formatter, T }); } }; +/// @endcond diff --git a/modules/core/tests/mesh_cleanup/test_split_obtuse_triangles.cpp b/modules/core/tests/mesh_cleanup/test_split_obtuse_triangles.cpp new file mode 100644 index 00000000..d82c512c --- /dev/null +++ b/modules/core/tests/mesh_cleanup/test_split_obtuse_triangles.cpp @@ -0,0 +1,239 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +template +Scalar max_interior_angle(const lagrange::SurfaceMesh& mesh) +{ + Eigen::Matrix angles; + lagrange::internal::internal_angles( + lagrange::vertex_view(mesh), + lagrange::facet_view(mesh), + angles); + return angles.maxCoeff(); +} + +} // namespace + +TEST_CASE("split_obtuse_triangles", "[surface][cleanup]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + const Scalar pi = static_cast(lagrange::internal::pi); + constexpr Scalar eps = 1e-5; + + SECTION("No obtuse triangles -> no-op") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, std::sqrt(Scalar(3)) / 2, 0}); + mesh.add_triangle(0, 1, 2); + + const auto num_v = mesh.get_num_vertices(); + const auto num_f = mesh.get_num_facets(); + + SplitObtuseTrianglesOptions opts; + size_t n = split_obtuse_triangles(mesh, opts); + REQUIRE(n == 0); + REQUIRE(mesh.get_num_vertices() == num_v); + REQUIRE(mesh.get_num_facets() == num_f); + } + + SECTION("Single obtuse sliver") + { + SurfaceMesh mesh; + // Very flat triangle: the apex at the top is obtuse (> 90 deg). + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 0.01, 0}); + mesh.add_triangle(0, 1, 2); + + SplitObtuseTrianglesOptions opts; + opts.max_iterations = 1; + size_t n = split_obtuse_triangles(mesh, opts); + REQUIRE(n == 1); + REQUIRE(mesh.get_num_vertices() == 4); + REQUIRE(mesh.get_num_facets() == 2); + REQUIRE_THAT(compute_mesh_area(mesh), Catch::Matchers::WithinAbs(0.005, 1e-9)); + } + + SECTION("Recursive convergence") + { + // T1 = (0,1,2) is initially acute, but splitting the shared edge to remove the highly + // obtuse T2 introduces a new obtuse triangle inside T1's tessellation that requires + // additional iterations to clean up. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({10, 0, 0}); + mesh.add_vertex({2, 5, 0}); + mesh.add_vertex({5, -0.1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 0, 3); + + const Scalar total_area = compute_mesh_area(mesh); + + SplitObtuseTrianglesOptions opts; + opts.max_iterations = 0; // iterate to convergence + opts.max_angle = static_cast(pi / 2); + size_t n = split_obtuse_triangles(mesh, opts); + REQUIRE(n > 0); + + const Scalar max_a = max_interior_angle(mesh); + REQUIRE(max_a <= static_cast(opts.max_angle) + eps); + + REQUIRE_THAT(compute_mesh_area(mesh), Catch::Matchers::WithinAbs(total_area, 1e-9)); + } + + SECTION("Shared obtuse edge across two triangles") + { + SurfaceMesh mesh; + // Both triangles share edge (0,1). The vertices at (0.5, +/- 0.05) make the apex obtuse on + // both sides, so the shared edge is the obtuse edge for both. + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 0.05, 0}); + mesh.add_vertex({0.5, -0.05, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 0, 3); + + SplitObtuseTrianglesOptions opts; + opts.max_iterations = 1; + size_t n = split_obtuse_triangles(mesh, opts); + // Both triangles split via a single new vertex on the shared edge. + REQUIRE(n == 2); + REQUIRE(mesh.get_num_vertices() == 5); + REQUIRE(mesh.get_num_facets() == 4); + REQUIRE_THAT(compute_mesh_area(mesh), Catch::Matchers::WithinAbs(0.05, 1e-9)); + } + + SECTION("Active region mask") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 0.01, 0}); + mesh.add_vertex({2, 0, 0}); + mesh.add_vertex({1.5, 0.01, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 3, 4); + + // Only facet 0 is active. + uint8_t active_buffer[2] = {1, 0}; + mesh.create_attribute( + "active", + AttributeElement::Facet, + AttributeUsage::Scalar, + 1, + active_buffer); + + SplitObtuseTrianglesOptions opts; + opts.max_iterations = 1; + opts.active_region_attribute = "active"; + size_t n = split_obtuse_triangles(mesh, opts); + + // The inactive obtuse facet remains intact; only the active one is split. + REQUIRE(n == 1); + REQUIRE(mesh.get_num_facets() == 3); + REQUIRE(mesh.has_attribute("active")); + } + + SECTION("Vertex attribute interpolation") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 0.01, 0}); + mesh.add_triangle(0, 1, 2); + + // Linear vertex attribute aligned with x-coordinate. + Scalar values[3] = {0.0, 10.0, 5.0}; + mesh.create_attribute( + "scalar_attr", + AttributeElement::Vertex, + AttributeUsage::Scalar, + 1, + values); + + SplitObtuseTrianglesOptions opts; + opts.max_iterations = 1; + size_t n = split_obtuse_triangles(mesh, opts); + REQUIRE(n == 1); + REQUIRE(mesh.get_num_vertices() == 4); + + auto attr_view = attribute_vector_view(mesh, "scalar_attr"); + const Scalar new_val = attr_view[3]; + // The new vertex is the projection of v2=(0.5, 0.01, 0) onto edge v0->v1 -> (0.5, 0, 0). + // Linear interpolation of {0, 10} at t=0.5 -> 5. + REQUIRE_THAT(new_val, Catch::Matchers::WithinAbs(5.0, 1e-9)); + } + + SECTION("Collinear degenerate stack terminates") + { + // Stack of triangles whose three vertices are exactly collinear. The "obtuse" vertex + // lies on the opposite edge, so naive splitting would never make progress. The + // algorithm must detect this and terminate even with unbounded iterations. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.25, 0, 0}); + mesh.add_vertex({0.5, 0, 0}); + mesh.add_vertex({0.75, 0, 0}); + // Each triangle has 3 collinear vertices on the x-axis. + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 1, 3); + mesh.add_triangle(0, 1, 4); + + const auto num_v = mesh.get_num_vertices(); + const auto num_f = mesh.get_num_facets(); + + SplitObtuseTrianglesOptions opts; + opts.max_iterations = 0; // iterate to convergence; must not hang + size_t n = split_obtuse_triangles(mesh, opts); + REQUIRE(n == 0); + REQUIRE(mesh.get_num_vertices() == num_v); + REQUIRE(mesh.get_num_facets() == num_f); + } + + SECTION("max_iterations early stop") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({10, 0, 0}); + mesh.add_vertex({2, 5, 0}); + mesh.add_vertex({5, -0.1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 0, 3); + + SplitObtuseTrianglesOptions opts; + opts.max_iterations = 1; + opts.max_angle = static_cast(pi / 2); + size_t n = split_obtuse_triangles(mesh, opts); + REQUIRE(n > 0); + // One iteration leaves residual obtuse triangles. + REQUIRE(max_interior_angle(mesh) > pi / 2); + } +} diff --git a/modules/core/tests/test_clipped_triangle_circumcenter.cpp b/modules/core/tests/test_clipped_triangle_circumcenter.cpp new file mode 100644 index 00000000..31e35d68 --- /dev/null +++ b/modules/core/tests/test_clipped_triangle_circumcenter.cpp @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include + +#include + +TEST_CASE( + "clipped_triangle_circumcenter: acute triangle returns interior circumcenter", + "[core][clipped_circumcenter]") +{ + using Scalar = double; + const Eigen::Vector3 p1(0, 0, 0); + const Eigen::Vector3 p2(1, 0, 0); + const Eigen::Vector3 p3(0.5, 1, 0); + + Scalar l1, l2, l3; + auto c = lagrange::internal::clipped_triangle_circumcenter(p1, p2, p3, l1, l2, l3); + + REQUIRE(std::isfinite(c.x())); + REQUIRE(std::isfinite(c.y())); + REQUIRE(std::isfinite(c.z())); + REQUIRE(l1 >= 0); + REQUIRE(l2 >= 0); + REQUIRE(l3 >= 0); + REQUIRE(std::abs(l1 + l2 + l3 - 1) < 1e-6); + // Expected circumcenter of this isoceles triangle: (0.5, 0.375, 0). + REQUIRE(std::abs(c.x() - 0.5) < 1e-6); + REQUIRE(std::abs(c.y() - 0.375) < 1e-6); +} + +TEST_CASE( + "clipped_triangle_circumcenter: degenerate (collinear) triangle falls back to barycenter", + "[core][clipped_circumcenter]") +{ + using Scalar = double; + // All three points on the x-axis: degenerate, |d| < denorm_min. + const Eigen::Vector3 p1(0, 0, 0); + const Eigen::Vector3 p2(1, 0, 0); + const Eigen::Vector3 p3(2, 0, 0); + + Scalar l1, l2, l3; + auto c = lagrange::internal::clipped_triangle_circumcenter(p1, p2, p3, l1, l2, l3); + + REQUIRE(std::isfinite(c.x())); + REQUIRE(std::isfinite(c.y())); + REQUIRE(std::isfinite(c.z())); + // Fallback: uniform barycentric coordinates. + REQUIRE(std::abs(l1 - 1.0 / 3.0) < 1e-12); + REQUIRE(std::abs(l2 - 1.0 / 3.0) < 1e-12); + REQUIRE(std::abs(l3 - 1.0 / 3.0) < 1e-12); + // Barycenter of (0,0,0), (1,0,0), (2,0,0) is (1, 0, 0). + REQUIRE(std::abs(c.x() - 1.0) < 1e-12); + REQUIRE(std::abs(c.y()) < 1e-12); + REQUIRE(std::abs(c.z()) < 1e-12); +} + +TEST_CASE( + "clipped_triangle_circumcenter: coincident points fall back to barycenter", + "[core][clipped_circumcenter]") +{ + using Scalar = double; + // Three coincident points: |d| = 0. + const Eigen::Vector3 p(2, -1, 3); + + Scalar l1, l2, l3; + auto c = lagrange::internal::clipped_triangle_circumcenter(p, p, p, l1, l2, l3); + + REQUIRE(std::isfinite(c.x())); + REQUIRE(std::isfinite(c.y())); + REQUIRE(std::isfinite(c.z())); + REQUIRE(std::abs(l1 - 1.0 / 3.0) < 1e-12); + REQUIRE(std::abs(l2 - 1.0 / 3.0) < 1e-12); + REQUIRE(std::abs(l3 - 1.0 / 3.0) < 1e-12); + REQUIRE((c - p).norm() < 1e-12); +} diff --git a/modules/core/tests/test_compute_facet_facet_adjacency.cpp b/modules/core/tests/test_compute_facet_facet_adjacency.cpp index a2388f2d..a3df8fa3 100644 --- a/modules/core/tests/test_compute_facet_facet_adjacency.cpp +++ b/modules/core/tests/test_compute_facet_facet_adjacency.cpp @@ -206,3 +206,129 @@ TEST_CASE("compute_facet_facet_adjacency", "[surface][adjacency]") REQUIRE(n0[0] == 1); } } + +TEST_CASE("compute_facet_facet_adjacency - vertex connectivity", "[surface][adjacency]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SECTION("single triangle") + { + 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); + + auto adj = compute_facet_facet_adjacency(mesh, ConnectivityType::Vertex); + REQUIRE(adj.get_num_entries() == 1); + REQUIRE(adj.get_neighbors(0).size() == 0); + } + + SECTION("two triangles sharing a vertex") + { + // 2 4 + // |\ /| + // | 0 | + // |/ \| + // 1 3 + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({0, -1, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({1, -1, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_triangle(0, 1, 2); // Triangle 0 + mesh.add_triangle(0, 3, 4); // Triangle 1 + + auto adj = compute_facet_facet_adjacency(mesh, ConnectivityType::Vertex); + auto n0 = adj.get_neighbors(0); + auto n1 = adj.get_neighbors(1); + + // Each triangle shares vertex 0, so they should be adjacent + REQUIRE(n0.size() > 0); + REQUIRE(n1.size() > 0); + + // Check that 1 is in neighbors of 0 + std::set s0(n0.begin(), n0.end()); + REQUIRE(s0.count(1) > 0); + + // Check that 0 is in neighbors of 1 + std::set s1(n1.begin(), n1.end()); + REQUIRE(s1.count(0) > 0); + } + + SECTION("two triangles sharing an edge - vertex connectivity") + { + // 2 + // / \. + // 0--1 + // \ / + // 3 + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 1, 0}); + mesh.add_vertex({0.5, -1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 0, 3); + + auto adj = compute_facet_facet_adjacency(mesh, ConnectivityType::Vertex); + auto n0 = adj.get_neighbors(0); + auto n1 = adj.get_neighbors(1); + + // With vertex connectivity, they share vertices 0 and 1 (2 times) + REQUIRE(n0.size() == 2); // One for each shared vertex + REQUIRE(n1.size() == 2); + + // All neighbors should be the other facet + for (Index neighbor : n0) { + REQUIRE(neighbor == 1); + } + for (Index neighbor : n1) { + REQUIRE(neighbor == 0); + } + } + + SECTION("compare edge vs vertex connectivity") + { + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0.5, 1, 0}); + mesh.add_vertex({0.5, -1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 0, 3); + + auto adj_edge = compute_facet_facet_adjacency(mesh, ConnectivityType::Edge); + auto adj_vertex = compute_facet_facet_adjacency(mesh, ConnectivityType::Vertex); + + // Edge connectivity: each facet has 1 neighbor (shared edge) + REQUIRE(adj_edge.get_neighbors(0).size() == 1); + REQUIRE(adj_edge.get_neighbors(1).size() == 1); + + // Vertex connectivity: each facet has 2 neighbors (shared vertices) + REQUIRE(adj_vertex.get_neighbors(0).size() == 2); + REQUIRE(adj_vertex.get_neighbors(1).size() == 2); + } + + SECTION("ball.obj symmetry") + { + auto mesh = lagrange::testing::load_surface_mesh("open/core/ball.obj"); + const Index num_facets = mesh.get_num_facets(); + + auto adj = compute_facet_facet_adjacency(mesh, ConnectivityType::Vertex); + REQUIRE(adj.get_num_entries() == num_facets); + + // Symmetry: if f is adjacent to g, g must be adjacent to f. + for (Index f = 0; f < num_facets; ++f) { + auto neighbors = adj.get_neighbors(f); + for (Index g : neighbors) { + auto g_neighbors = adj.get_neighbors(g); + std::set g_set(g_neighbors.begin(), g_neighbors.end()); + CHECK(g_set.count(f) > 0); + } + } + } +} diff --git a/modules/core/tests/test_compute_vertex_vertex_adjacency.cpp b/modules/core/tests/test_compute_vertex_vertex_adjacency.cpp index 1989cd74..71340735 100644 --- a/modules/core/tests/test_compute_vertex_vertex_adjacency.cpp +++ b/modules/core/tests/test_compute_vertex_vertex_adjacency.cpp @@ -15,6 +15,8 @@ #include #include +#include + #ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS #include #include @@ -99,6 +101,213 @@ TEST_CASE("compute_vertex_vertex_adjacency", "[surface][adjacency][utilities]") } } +TEST_CASE("compute_vertex_vertex_adjacency - facet connectivity", "[surface][adjacency][utilities]") +{ + using namespace lagrange; + using Scalar = double; + using Index = uint32_t; + + SECTION("single triangle - facet same as edge") + { + // Triangle: all 3 pairs are both edges and facet-pairs, so results should match. + 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); + + auto adj_edge = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Edge); + auto adj_facet = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Facet); + + REQUIRE(adj_edge.get_num_entries() == 3); + REQUIRE(adj_facet.get_num_entries() == 3); + for (Index v = 0; v < 3; ++v) { + auto ne = adj_edge.get_neighbors(v); + auto nf = adj_facet.get_neighbors(v); + REQUIRE(ne.size() == 2); + REQUIRE(nf.size() == 2); + std::set se(ne.begin(), ne.end()), sf(nf.begin(), nf.end()); + CHECK(se == sf); + } + } + + SECTION("single quad - facet adds diagonals") + { + // Quad {0,1,3,2}: edges are (0,1),(1,3),(3,2),(2,0). + // Facet connectivity also includes diagonals (0,3) and (1,2). + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); // v0 + mesh.add_vertex({1, 0, 0}); // v1 + mesh.add_vertex({0, 1, 0}); // v2 + mesh.add_vertex({1, 1, 0}); // v3 + mesh.add_quad(0, 1, 3, 2); + + auto adj_edge = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Edge); + auto adj_facet = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Facet); + + // Edge: each corner vertex has 2 edge neighbors. + for (Index v = 0; v < 4; ++v) { + REQUIRE(adj_edge.get_neighbors(v).size() == 2); + } + // Facet: each vertex is adjacent to all 3 others (including diagonal). + for (Index v = 0; v < 4; ++v) { + REQUIRE(adj_facet.get_neighbors(v).size() == 3); + } + } + + SECTION("two disconnected triangles") + { + // f0: {0,1,2}, f1: {3,4,5} — no shared vertices. + SurfaceMesh 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_vertex({2, 1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(3, 4, 5); + + auto adj = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Facet); + REQUIRE(adj.get_num_entries() == 6); + // Each vertex adjacent to the 2 others in its own triangle. + for (Index v = 0; v < 6; ++v) { + REQUIRE(adj.get_neighbors(v).size() == 2); + } + } + + SECTION("degenerate facet with duplicated vertices") + { + // Triangle [0, 1, 0]: vertex 0 appears twice. + // v0 is allocated 2*(n-1)=4 slots but after dedup has fewer unique neighbors. + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_polygon({Index(0), Index(1), Index(0)}); + + auto adj = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Facet); + + const auto v0_nbrs = adj.get_neighbors(Index(0)); + const auto v1_nbrs = adj.get_neighbors(Index(1)); + // v0 and v1 must be mutual neighbors despite the duplicated vertex. + REQUIRE(std::find(v0_nbrs.begin(), v0_nbrs.end(), Index(1)) != v0_nbrs.end()); + REQUIRE(std::find(v1_nbrs.begin(), v1_nbrs.end(), Index(0)) != v1_nbrs.end()); + } + + SECTION("adjacent facets including a degenerate facet with duplicated vertex") + { + // f0=[0,1,2] and f1=[1,3,2] share edge (1,2). + // f2=[2,1,2] is degenerate: vertex 2 appears twice, so each appearance + // is allocated n-1=2 slots but after dedup has fewer unique neighbors. + // Expected non-self neighbors: + // v0: {1,2} (only in f0) + // v1: {0,2,3} (f0 + f1 + f2) + // v2: {0,1,3} (f0 + f1 + f2, self-loop from f2 removed) + // v3: {1,2} (only in f1) + SurfaceMesh mesh; + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({-1, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({3, 1, 0}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 3, 2); + mesh.add_polygon({Index(2), Index(1), Index(2)}); + + auto non_self_neighbors = [](const AdjacencyList& adj, Index v) { + auto nbrs = adj.get_neighbors(v); + std::set s(nbrs.begin(), nbrs.end()); + s.erase(v); + return s; + }; + + for (auto ct : {DualConnectivityType::Edge, DualConnectivityType::Facet}) { + auto adj = compute_vertex_vertex_adjacency(mesh, ct); + CHECK(non_self_neighbors(adj, 0) == std::set({1, 2})); + CHECK(non_self_neighbors(adj, 1) == std::set({0, 2, 3})); + CHECK(non_self_neighbors(adj, 2) == std::set({0, 1, 3})); + CHECK(non_self_neighbors(adj, 3) == std::set({1, 2})); + } + } + + SECTION("hybrid polygon mesh with adjacency and duplicated vertex") + { + // 5 vertices: quad q=[0,1,4,3], triangle t=[1,2,4], degenerate d=[4,1,4]. + // q and t share edge (1,4); d is degenerate (v4 appears twice) on the same edge. + // + // 3---4 + // | * |* + // | q | t + // |* |* + // 0---1---2 + // + // Edge connectivity — only boundary edges, no quad diagonals: + // v0: {1,3} v1: {0,2,4} v2: {1,4} + // v3: {0,4} v4: {1,2,3} + // + // Facet connectivity — quad adds diagonals (0,4) and (1,3): + // v0: {1,3,4} v1: {0,2,3,4} v2: {1,4} + // v3: {0,1,4} v4: {0,1,2,3} + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); // v0 + mesh.add_vertex({1, 0, 0}); // v1 + mesh.add_vertex({2, 0, 0}); // v2 + mesh.add_vertex({0, 1, 0}); // v3 + mesh.add_vertex({1, 1, 0}); // v4 + mesh.add_quad(0, 1, 4, 3); + mesh.add_triangle(1, 2, 4); + mesh.add_polygon({Index(4), Index(1), Index(4)}); // degenerate: v4 appears twice + + auto non_self_neighbors = [](const AdjacencyList& adj, Index v) { + auto nbrs = adj.get_neighbors(v); + std::set s(nbrs.begin(), nbrs.end()); + s.erase(v); + return s; + }; + + { + auto adj = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Edge); + CHECK(non_self_neighbors(adj, 0) == std::set({1, 3})); + CHECK(non_self_neighbors(adj, 1) == std::set({0, 2, 4})); + CHECK(non_self_neighbors(adj, 2) == std::set({1, 4})); + CHECK(non_self_neighbors(adj, 3) == std::set({0, 4})); + CHECK(non_self_neighbors(adj, 4) == std::set({1, 2, 3})); + } + { + auto adj = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Facet); + CHECK(non_self_neighbors(adj, 0) == std::set({1, 3, 4})); + CHECK(non_self_neighbors(adj, 1) == std::set({0, 2, 3, 4})); + CHECK(non_self_neighbors(adj, 2) == std::set({1, 4})); + CHECK(non_self_neighbors(adj, 3) == std::set({0, 1, 4})); + CHECK(non_self_neighbors(adj, 4) == std::set({0, 1, 2, 3})); + } + } + + SECTION("facet connectivity is superset of edge connectivity") + { + // For any mesh, every edge neighbor is also a facet neighbor. + 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_vertex({2, 0, 0}); + mesh.add_quad(0, 1, 3, 2); + mesh.add_triangle(1, 4, 3); + + auto adj_edge = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Edge); + auto adj_facet = compute_vertex_vertex_adjacency(mesh, DualConnectivityType::Facet); + + for (Index v = 0; v < mesh.get_num_vertices(); ++v) { + auto ne = adj_edge.get_neighbors(v); + auto nf = adj_facet.get_neighbors(v); + std::set sf(nf.begin(), nf.end()); + for (Index u : ne) { + CHECK(sf.count(u) == 1); + } + } + } +} + TEST_CASE( "compute_vertex_vertex_adjacency benchmark", "[surface][adjacency][utilities][!benchmark]") diff --git a/modules/core/tests/test_isoline.cpp b/modules/core/tests/test_isoline.cpp index 7e244bf3..b7b0a495 100644 --- a/modules/core/tests/test_isoline.cpp +++ b/modules/core/tests/test_isoline.cpp @@ -12,6 +12,7 @@ #include +#include #include #include #include @@ -349,6 +350,270 @@ TEST_CASE("trim_by_isoline color interpolation", "[isoline]") REQUIRE(colors_trimmed == expected_colors); } +TEST_CASE("trim_by_isoline facet attribute", "[isoline]") +{ + using Scalar = float; + using Index = uint32_t; + + // Two triangles forming a unit square, each carrying a distinct per-facet value. + lagrange::SurfaceMesh mesh(2); + mesh.add_vertex({0, 0}); + mesh.add_vertex({1, 0}); + mesh.add_vertex({1, 1}); + mesh.add_vertex({0, 1}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + + mesh.create_attribute( + "facet_id", + lagrange::AttributeElement::Facet, + lagrange::AttributeUsage::Scalar); + auto facet_id = lagrange::attribute_vector_ref(mesh, "facet_id"); + facet_id(0) = 10; + facet_id(1) = 20; + + // Trim by the y coordinate at 0.5. Both triangles are cut, so every sub-facet must inherit the + // facet attribute of the triangle it was carved out of. + lagrange::IsolineOptions options; + options.attribute_id = mesh.attr_id_vertex_to_position(); + options.channel_index = 1; + options.isovalue = 0.5; + + lagrange::SurfaceMesh trimmed; + tbb::task_arena arena(1); + arena.execute([&] { trimmed = trim_by_isoline(mesh, options); }); + + REQUIRE(trimmed.get_num_facets() == 2); + auto trimmed_id = lagrange::attribute_vector_view(trimmed, "facet_id"); + CHECK(trimmed_id(0) == 10); + CHECK(trimmed_id(1) == 20); +} + +TEST_CASE("trim_by_isoline corner and indexed attribute", "[isoline]") +{ + using Scalar = float; + using Index = uint32_t; + + // A single triangle whose corners c0, c1, c2 are vertices v0, v1, v2. + lagrange::SurfaceMesh mesh(2); + mesh.add_vertex({0, 0}); + mesh.add_vertex({1, 0}); + mesh.add_vertex({0, 1}); + mesh.add_triangle(0, 1, 2); + + // Corner attribute (one scalar per corner). + mesh.create_attribute( + "corner_value", + lagrange::AttributeElement::Corner, + lagrange::AttributeUsage::Scalar); + auto corner_value = lagrange::attribute_vector_ref(mesh, "corner_value"); + corner_value << 1, 2, 4; + + // Indexed attribute: distinct value per corner. + auto idx_id = mesh.create_attribute( + "indexed_value", + lagrange::AttributeElement::Indexed, + lagrange::AttributeUsage::Scalar, + 1); + { + auto& iattr = mesh.ref_indexed_attribute(idx_id); + iattr.values().resize_elements(3); + auto values = iattr.values().ref_all(); + values[0] = 10; + values[1] = 20; + values[2] = 40; + auto indices = iattr.indices().ref_all(); + indices[0] = 0; + indices[1] = 1; + indices[2] = 2; + } + + // Trim by the y coordinate at 0.5. Vertices v0 and v1 survive (y == 0); the isoline crosses + // edge c1->c2 and edge c2->c0, each at parameter t = 0.5. The result is a single quad with + // corners [v0, v1, cross(c1,c2), cross(c2,c0)]. + lagrange::IsolineOptions options; + options.attribute_id = mesh.attr_id_vertex_to_position(); + options.channel_index = 1; + options.isovalue = 0.5; + + lagrange::SurfaceMesh trimmed; + tbb::task_arena arena(1); + arena.execute([&] { trimmed = trim_by_isoline(mesh, options); }); + + REQUIRE(trimmed.get_num_facets() == 1); + REQUIRE(trimmed.get_num_corners() == 4); + + // Corner attribute: surviving corners keep their value, crossings are linearly interpolated. + auto out_corner = lagrange::attribute_vector_view(trimmed, "corner_value"); + CHECK(out_corner(0) == 1.0); // v0 + CHECK(out_corner(1) == 2.0); // v1 + CHECK(out_corner(2) == 3.0); // (2 + 4) / 2 + CHECK(out_corner(3) == 2.5); // (4 + 1) / 2 + + // Indexed attribute: same interpolation rule, with surviving corners keeping their value index. + const auto& out_iattr = trimmed.get_indexed_attribute("indexed_value"); + auto out_values = out_iattr.values().get_all(); + auto out_indices = out_iattr.indices().get_all(); + auto value_at = [&](Index c) { return out_values[out_indices[c]]; }; + CHECK(value_at(0) == 10.0); // v0 + CHECK(value_at(1) == 20.0); // v1 + CHECK(value_at(2) == 30.0); // (20 + 40) / 2 + CHECK(value_at(3) == 25.0); // (40 + 10) / 2 +} + +TEST_CASE("trim_by_isoline keep_attributes flag", "[isoline]") +{ + using Scalar = float; + using Index = uint32_t; + + lagrange::SurfaceMesh mesh(2); + mesh.add_vertex({0, 0}); + mesh.add_vertex({1, 0}); + mesh.add_vertex({0, 1}); + mesh.add_triangle(0, 1, 2); + + mesh.create_attribute( + "vertex_value", + lagrange::AttributeElement::Vertex, + lagrange::AttributeUsage::Scalar); + mesh.create_attribute( + "facet_value", + lagrange::AttributeElement::Facet, + lagrange::AttributeUsage::Scalar); + mesh.create_attribute( + "corner_value", + lagrange::AttributeElement::Corner, + lagrange::AttributeUsage::Scalar); + auto idx_id = mesh.create_attribute( + "indexed_value", + lagrange::AttributeElement::Indexed, + lagrange::AttributeUsage::Scalar, + 1); + { + auto& iattr = mesh.ref_indexed_attribute(idx_id); + iattr.values().resize_elements(3); + auto indices = iattr.indices().ref_all(); + indices[0] = 0; + indices[1] = 1; + indices[2] = 2; + } + + lagrange::IsolineOptions options; + options.attribute_id = mesh.attr_id_vertex_to_position(); + options.channel_index = 1; + options.isovalue = 0.5; + + // By default, attributes of every element type are propagated. + auto kept = trim_by_isoline(mesh, options); + CHECK(kept.has_attribute("vertex_value")); + CHECK(kept.has_attribute("facet_value")); + CHECK(kept.has_attribute("corner_value")); + CHECK(kept.has_attribute("indexed_value")); + + // Disabling the flag drops all user attributes while keeping the same geometry. + options.keep_attributes = false; + auto dropped = trim_by_isoline(mesh, options); + CHECK_FALSE(dropped.has_attribute("vertex_value")); + CHECK_FALSE(dropped.has_attribute("facet_value")); + CHECK_FALSE(dropped.has_attribute("corner_value")); + CHECK_FALSE(dropped.has_attribute("indexed_value")); + + CHECK(dropped.get_num_vertices() == kept.get_num_vertices()); + CHECK(dropped.get_num_facets() == kept.get_num_facets()); + CHECK(dropped.get_num_corners() == kept.get_num_corners()); +} + +TEST_CASE("insert_isoline basic", "[isoline]") +{ + using Scalar = float; + using Index = uint32_t; + + lagrange::SurfaceMesh mesh(2); + mesh.add_vertex({0, 0}); + mesh.add_vertex({1, 0}); + mesh.add_vertex({0, 1}); + mesh.add_triangle(0, 1, 2); + + // Facet attribute to verify both sub-facets inherit the parent value. + mesh.create_attribute( + "facet_id", + lagrange::AttributeElement::Facet, + lagrange::AttributeUsage::Scalar); + lagrange::attribute_vector_ref(mesh, "facet_id")(0) = 7; + + lagrange::IsolineOptions options; + options.attribute_id = mesh.attr_id_vertex_to_position(); + options.channel_index = 1; + options.isovalue = 0.5; + + auto out = insert_isoline(mesh, options); + + // The crossed triangle is split into a triangle + a quad, both retained. + REQUIRE(out.get_num_facets() == 2); + REQUIRE(out.get_num_vertices() == 5); // 3 original + 2 isocrossings + + // Both sub-facets keep the parent facet attribute. + auto out_id = lagrange::attribute_vector_view(out, "facet_id"); + CHECK(out_id(0) == 7); + CHECK(out_id(1) == 7); + + // The isoline is inserted as a single interior (non-boundary) edge whose endpoints both lie on + // the isovalue line (y == 0.5). + out.initialize_edges(); + auto positions = vertex_view(out); + Index num_interior = 0; + for (auto e : lagrange::range(out.get_num_edges())) { + if (!out.is_boundary_edge(e)) { + ++num_interior; + auto v = out.get_edge_vertices(e); + CHECK(positions(v[0], 1) == Scalar(0.5)); + CHECK(positions(v[1], 1) == Scalar(0.5)); + } + } + CHECK(num_interior == 1); +} + +TEST_CASE("insert_isoline keeps both sides", "[isoline]") +{ + using Scalar = float; + using Index = uint32_t; + + auto mesh = create_grid(15, 11, 2, 0.2f); + auto field_id = mesh.create_attribute( + "field", + lagrange::AttributeElement::Vertex, + lagrange::AttributeUsage::Scalar); + auto field = lagrange::attribute_vector_ref(mesh, "field"); + auto vertices = vertex_view(mesh); + for (auto i : lagrange::range(field.size())) { + field[i] = vertices(i, 0) - 0.5123; // isoline at x = 0.5123, off the grid lines + } + + lagrange::IsolineOptions options; + options.attribute_id = field_id; + options.isovalue = 0.0; + + options.keep_below = true; + auto below = trim_by_isoline(mesh, options); + options.keep_below = false; + auto above = trim_by_isoline(mesh, options); + auto inserted = insert_isoline(mesh, options); + + // Insert keeps both sides: its facet count equals the two trimmed halves combined. + CHECK(inserted.get_num_facets() == below.get_num_facets() + above.get_num_facets()); + + // Every original vertex is retained, plus the isocrossing vertices. + CHECK(inserted.get_num_vertices() > mesh.get_num_vertices()); + + // The propagated field is continuous: the newly created vertices all lie on the isoline, so + // their interpolated field value must equal the isovalue. + REQUIRE(inserted.has_attribute("field")); + auto out_field = lagrange::attribute_vector_view(inserted, "field"); + for (auto i : lagrange::range(mesh.get_num_vertices(), inserted.get_num_vertices())) { + CHECK_THAT(out_field[i], Catch::Matchers::WithinAbs(options.isovalue, 1e-6)); + } +} + TEST_CASE("extract_isoline basic", "[isoline]") { using Scalar = float; diff --git a/modules/core/tests/test_set_indexed_values.cpp b/modules/core/tests/test_set_indexed_values.cpp new file mode 100644 index 00000000..bd53475c --- /dev/null +++ b/modules/core/tests/test_set_indexed_values.cpp @@ -0,0 +1,272 @@ +/* + * 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("set_invalid_indexed_values", "[core][attribute][indexed]") +{ + using namespace lagrange; + using Scalar = float; + using Index = uint32_t; + + SECTION("no invalid indices") + { + SurfaceMesh mesh(2); + mesh.add_vertex({0, 0}); + mesh.add_vertex({1, 0}); + mesh.add_vertex({0, 1}); + mesh.add_triangle(0, 1, 2); + + std::array uv_values{0, 0, 1, 0, 0, 1}; + std::array uv_indices{0, 1, 2}; + auto id = mesh.create_attribute( + "uv", + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + uv_values, + uv_indices); + + auto& attr = mesh.ref_indexed_attribute(id); + internal::set_invalid_indexed_values(attr); + + // Nothing should change. + REQUIRE(attr.values().get_num_elements() == 3); + REQUIRE(attr.indices().get(0) == 0); + REQUIRE(attr.indices().get(1) == 1); + REQUIRE(attr.indices().get(2) == 2); + } + + SECTION("some invalid indices") + { + SurfaceMesh mesh(2); + mesh.add_vertex({0, 0}); + mesh.add_vertex({1, 0}); + mesh.add_vertex({0, 1}); + mesh.add_vertex({1, 1}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(2, 1, 3); + + std::array uv_values{0, 0, 1, 0}; + std::array + uv_indices{0, 1, invalid(), invalid(), 1, invalid()}; + auto id = mesh.create_attribute( + "uv", + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + uv_values, + uv_indices); + + auto& attr = mesh.ref_indexed_attribute(id); + internal::set_invalid_indexed_values(attr); + + // 3 invalid indices should create 3 new values (indices 2, 3, 4). + REQUIRE(attr.values().get_num_elements() == 5); + + // Original valid indices unchanged. + CHECK(attr.indices().get(0) == 0); + CHECK(attr.indices().get(1) == 1); + CHECK(attr.indices().get(4) == 1); + + // Each invalid index gets a unique new value index. + Index i2 = attr.indices().get(2); + Index i3 = attr.indices().get(3); + Index i5 = attr.indices().get(5); + CHECK(i2 != invalid()); + CHECK(i3 != invalid()); + CHECK(i5 != invalid()); + CHECK(i2 >= 2); + CHECK(i3 >= 2); + CHECK(i5 >= 2); + // All distinct. + CHECK(i2 != i3); + CHECK(i2 != i5); + CHECK(i3 != i5); + + // New values should be set to invalid(). + for (Index idx : {i2, i3, i5}) { + CHECK(attr.values().get(idx, 0) == invalid()); + CHECK(attr.values().get(idx, 1) == invalid()); + } + } + + SECTION("all invalid indices") + { + SurfaceMesh mesh(2); + mesh.add_vertex({0, 0}); + mesh.add_vertex({1, 0}); + mesh.add_vertex({0, 1}); + mesh.add_triangle(0, 1, 2); + + std::array uv_values{0, 0}; + std::array uv_indices{invalid(), invalid(), invalid()}; + auto id = mesh.create_attribute( + "uv", + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + uv_values, + uv_indices); + + auto& attr = mesh.ref_indexed_attribute(id); + internal::set_invalid_indexed_values(attr); + + // Original 1 value + 3 new. + REQUIRE(attr.values().get_num_elements() == 4); + // Each gets a unique index. + CHECK(attr.indices().get(0) == 1); + CHECK(attr.indices().get(1) == 2); + CHECK(attr.indices().get(2) == 3); + } +} + +namespace { + +// Build a single-triangle mesh carrying a 2-channel indexed UV attribute with the given values and +// indices, returning a reference to the indexed attribute (kept alive by the out-param mesh). +template +lagrange::IndexedAttribute& make_indexed_uv( + lagrange::SurfaceMesh& mesh, + lagrange::span values, + lagrange::span indices) +{ + using namespace lagrange; + mesh.add_vertex({0, 0}); + mesh.add_vertex({1, 0}); + mesh.add_vertex({0, 1}); + mesh.add_triangle(0, 1, 2); + auto id = mesh.template create_attribute( + "uv", + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + values, + indices); + return mesh.template ref_indexed_attribute(id); +} + +} // namespace + +TEST_CASE("set_out_of_range_indexed_values", "[core][attribute][indexed]") +{ + using namespace lagrange; + using Scalar = float; + using Index = uint32_t; + + SECTION("no out-of-range indices") + { + SurfaceMesh mesh(2); + std::array values{0, 0, 1, 0, 0, 1}; + std::array indices{0, 1, 2}; + auto& attr = make_indexed_uv(mesh, values, indices); + internal::set_out_of_range_indexed_values(attr); + + // Nothing should change. + REQUIRE(attr.values().get_num_elements() == 3); + CHECK(attr.indices().get(0) == 0); + CHECK(attr.indices().get(1) == 1); + CHECK(attr.indices().get(2) == 2); + } + + SECTION("some out-of-range indices") + { + SurfaceMesh mesh(2); + // 2 valid values, but indices 5 and 7 point past the value buffer. + std::array values{0, 0, 1, 0}; + std::array indices{0, 5, 7}; + auto& attr = make_indexed_uv(mesh, values, indices); + internal::set_out_of_range_indexed_values(attr); + + // 2 out-of-range indices append 2 new values. + REQUIRE(attr.values().get_num_elements() == 4); + + // In-range index untouched. + CHECK(attr.indices().get(0) == 0); + + // Each out-of-range index now points to a fresh, distinct, in-range value. + Index i1 = attr.indices().get(1); + Index i2 = attr.indices().get(2); + CHECK(i1 >= 2); + CHECK(i2 >= 2); + CHECK(i1 < attr.values().get_num_elements()); + CHECK(i2 < attr.values().get_num_elements()); + CHECK(i1 != i2); + + // The appended values are set to invalid(). + for (Index idx : {i1, i2}) { + CHECK(attr.values().get(idx, 0) == invalid()); + CHECK(attr.values().get(idx, 1) == invalid()); + } + } + + SECTION("invalid() sentinel counts as out-of-range") + { + // The USD importer relies on this: a -1 hole sentinel, once cast to the unsigned Index + // type, becomes a huge value that must be treated as out-of-range. + SurfaceMesh mesh(2); + std::array values{0, 0, 1, 0, 0, 1}; + std::array indices{0, 1, invalid()}; + auto& attr = make_indexed_uv(mesh, values, indices); + internal::set_out_of_range_indexed_values(attr); + + REQUIRE(attr.values().get_num_elements() == 4); + CHECK(attr.indices().get(0) == 0); + CHECK(attr.indices().get(1) == 1); + Index i2 = attr.indices().get(2); + CHECK(i2 == 3); + CHECK(attr.values().get(i2, 0) == invalid()); + CHECK(attr.values().get(i2, 1) == invalid()); + } +} + +TEST_CASE("set_predicated_indexed_values", "[core][attribute][indexed]") +{ + using namespace lagrange; + using Scalar = float; + using Index = uint32_t; + + SECTION("custom predicate remaps matching indices only") + { + SurfaceMesh mesh(2); + std::array values{0, 0, 1, 0, 0, 1}; + std::array indices{0, 1, 2}; + auto& attr = make_indexed_uv(mesh, values, indices); + + // Remap only the index equal to 1. + internal::set_predicated_indexed_values(attr, [](Index index) { return index == 1; }); + + REQUIRE(attr.values().get_num_elements() == 4); + CHECK(attr.indices().get(0) == 0); + CHECK(attr.indices().get(2) == 2); + Index remapped = attr.indices().get(1); + CHECK(remapped == 3); + CHECK(attr.values().get(remapped, 0) == invalid()); + CHECK(attr.values().get(remapped, 1) == invalid()); + } + + SECTION("predicate matching nothing is a no-op") + { + SurfaceMesh mesh(2); + std::array values{0, 0, 1, 0, 0, 1}; + std::array indices{0, 1, 2}; + auto& attr = make_indexed_uv(mesh, values, indices); + + internal::set_predicated_indexed_values(attr, [](Index) { return false; }); + + REQUIRE(attr.values().get_num_elements() == 3); + CHECK(attr.indices().get(0) == 0); + CHECK(attr.indices().get(1) == 1); + CHECK(attr.indices().get(2) == 2); + } +} diff --git a/modules/core/tests/test_set_invalid_indexed_values.cpp b/modules/core/tests/test_set_invalid_indexed_values.cpp deleted file mode 100644 index b502aedd..00000000 --- a/modules/core/tests/test_set_invalid_indexed_values.cpp +++ /dev/null @@ -1,132 +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. - */ -#include - -#include - -TEST_CASE("set_invalid_indexed_values", "[core][attribute][indexed]") -{ - using namespace lagrange; - using Scalar = float; - using Index = uint32_t; - - SECTION("no invalid indices") - { - SurfaceMesh mesh(2); - mesh.add_vertex({0, 0}); - mesh.add_vertex({1, 0}); - mesh.add_vertex({0, 1}); - mesh.add_triangle(0, 1, 2); - - std::array uv_values{0, 0, 1, 0, 0, 1}; - std::array uv_indices{0, 1, 2}; - auto id = mesh.create_attribute( - "uv", - AttributeElement::Indexed, - AttributeUsage::UV, - 2, - uv_values, - uv_indices); - - auto& attr = mesh.ref_indexed_attribute(id); - internal::set_invalid_indexed_values(attr); - - // Nothing should change. - REQUIRE(attr.values().get_num_elements() == 3); - REQUIRE(attr.indices().get(0) == 0); - REQUIRE(attr.indices().get(1) == 1); - REQUIRE(attr.indices().get(2) == 2); - } - - SECTION("some invalid indices") - { - SurfaceMesh mesh(2); - mesh.add_vertex({0, 0}); - mesh.add_vertex({1, 0}); - mesh.add_vertex({0, 1}); - mesh.add_vertex({1, 1}); - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(2, 1, 3); - - std::array uv_values{0, 0, 1, 0}; - std::array - uv_indices{0, 1, invalid(), invalid(), 1, invalid()}; - auto id = mesh.create_attribute( - "uv", - AttributeElement::Indexed, - AttributeUsage::UV, - 2, - uv_values, - uv_indices); - - auto& attr = mesh.ref_indexed_attribute(id); - internal::set_invalid_indexed_values(attr); - - // 3 invalid indices should create 3 new values (indices 2, 3, 4). - REQUIRE(attr.values().get_num_elements() == 5); - - // Original valid indices unchanged. - CHECK(attr.indices().get(0) == 0); - CHECK(attr.indices().get(1) == 1); - CHECK(attr.indices().get(4) == 1); - - // Each invalid index gets a unique new value index. - Index i2 = attr.indices().get(2); - Index i3 = attr.indices().get(3); - Index i5 = attr.indices().get(5); - CHECK(i2 != invalid()); - CHECK(i3 != invalid()); - CHECK(i5 != invalid()); - CHECK(i2 >= 2); - CHECK(i3 >= 2); - CHECK(i5 >= 2); - // All distinct. - CHECK(i2 != i3); - CHECK(i2 != i5); - CHECK(i3 != i5); - - // New values should be set to invalid(). - for (Index idx : {i2, i3, i5}) { - CHECK(attr.values().get(idx, 0) == invalid()); - CHECK(attr.values().get(idx, 1) == invalid()); - } - } - - SECTION("all invalid indices") - { - SurfaceMesh mesh(2); - mesh.add_vertex({0, 0}); - mesh.add_vertex({1, 0}); - mesh.add_vertex({0, 1}); - mesh.add_triangle(0, 1, 2); - - std::array uv_values{0, 0}; - std::array uv_indices{invalid(), invalid(), invalid()}; - auto id = mesh.create_attribute( - "uv", - AttributeElement::Indexed, - AttributeUsage::UV, - 2, - uv_values, - uv_indices); - - auto& attr = mesh.ref_indexed_attribute(id); - internal::set_invalid_indexed_values(attr); - - // Original 1 value + 3 new. - REQUIRE(attr.values().get_num_elements() == 4); - // Each gets a unique index. - CHECK(attr.indices().get(0) == 1); - CHECK(attr.indices().get(1) == 2); - CHECK(attr.indices().get(2) == 3); - } -} diff --git a/modules/geodesic/geodesic.md b/modules/geodesic/geodesic.md new file mode 100644 index 00000000..8b04cdff --- /dev/null +++ b/modules/geodesic/geodesic.md @@ -0,0 +1,10 @@ +Geodesic Module +============ + +@namespace lagrange::geodesic + +@defgroup module-geodesic Geodesic Module +@brief Geodesic distance computation on meshes. + +Multiple geodesic engines (DGPC, heat method, MMP) for computing geodesic +distances over surface meshes. diff --git a/modules/geodesic/src/GeodesicEngineHeat.cpp b/modules/geodesic/src/GeodesicEngineHeat.cpp index 9010ad00..2ef825fd 100644 --- a/modules/geodesic/src/GeodesicEngineHeat.cpp +++ b/modules/geodesic/src/GeodesicEngineHeat.cpp @@ -52,6 +52,7 @@ GeodesicEngineHeat::GeodesicEngineHeat(Mesh& mesh) m_impl->m_solver.emplace(*m_impl->m_gc_geom); } +/// @cond LA_INTERNAL_DOCS template GeodesicEngineHeat::~GeodesicEngineHeat() = default; template @@ -60,6 +61,7 @@ GeodesicEngineHeat::GeodesicEngineHeat(GeodesicEngineHeat GeodesicEngineHeat& GeodesicEngineHeat::operator=( GeodesicEngineHeat&&) = default; +/// @endcond template SingleSourceGeodesicResult GeodesicEngineHeat::single_source_geodesic( diff --git a/modules/geodesic/src/GeodesicEngineMMP.cpp b/modules/geodesic/src/GeodesicEngineMMP.cpp index 83790513..ef0a9e32 100644 --- a/modules/geodesic/src/GeodesicEngineMMP.cpp +++ b/modules/geodesic/src/GeodesicEngineMMP.cpp @@ -54,6 +54,7 @@ GeodesicEngineMMP::GeodesicEngineMMP(Mesh& mesh) m_impl->m_solver.emplace(*m_impl->m_gc_mesh, *m_impl->m_gc_geom); } +/// @cond LA_INTERNAL_DOCS template GeodesicEngineMMP::~GeodesicEngineMMP() = default; template @@ -61,6 +62,7 @@ GeodesicEngineMMP::GeodesicEngineMMP(GeodesicEngineMMP GeodesicEngineMMP& GeodesicEngineMMP::operator=( GeodesicEngineMMP&&) = default; +/// @endcond template SingleSourceGeodesicResult GeodesicEngineMMP::single_source_geodesic( diff --git a/modules/image/image.md b/modules/image/image.md index 98271719..84486e4d 100644 --- a/modules/image/image.md +++ b/modules/image/image.md @@ -7,4 +7,9 @@ Image Module @defgroup module-image Image Module @brief Basic image data structure. -### Quick links +Quick links +----------- + +- [ImageView](@ref lagrange::image::ImageView) +- [ImageStorage](@ref lagrange::image::ImageStorage) +- [RawInputImage](@ref lagrange::image::RawInputImage) diff --git a/modules/image/python/include/lagrange/python/image_utils.h b/modules/image/python/include/lagrange/python/image_utils.h index 60c99dde..6221081f 100644 --- a/modules/image/python/include/lagrange/python/image_utils.h +++ b/modules/image/python/include/lagrange/python/image_utils.h @@ -17,6 +17,8 @@ #include #include +#include + namespace lagrange::python { namespace nb = nanobind; @@ -71,23 +73,26 @@ void copy_tensor_to_image_view( } template -nb::object image_array_to_tensor(const image::experimental::Array3D& image_) +Tensor image_array_to_tensor(image::experimental::Array3D&& image_) { - auto image = const_cast&>(image_); - auto tensor = Tensor( - static_cast(image.data()), + // Move the array onto the heap and hand ownership to a capsule, so the tensor + // keeps its backing storage alive even after the source `Array3D` is gone. + using Array = image::experimental::Array3D; + auto* image = new Array(std::move(image_)); + nb::capsule owner(image, [](void* p) noexcept { delete static_cast(p); }); + return Tensor( + static_cast(image->data()), { - image.extent(1), - image.extent(0), - image.extent(2), + image->extent(1), + image->extent(0), + image->extent(2), }, - nb::handle(), + owner, { - static_cast(image.stride(1)), - static_cast(image.stride(0)), - static_cast(image.stride(2)), + static_cast(image->stride(1)), + static_cast(image->stride(0)), + static_cast(image->stride(2)), }); - return tensor.cast(); } } // namespace lagrange::python diff --git a/modules/image/python/src/image.cpp b/modules/image/python/src/image.cpp index 6d462f31..c0ab05e0 100644 --- a/modules/image/python/src/image.cpp +++ b/modules/image/python/src/image.cpp @@ -74,7 +74,7 @@ void populate_image_module(nb::module_& m) const auto cells = image::experimental::split_grid(tensor_to_image_view(grid), options); nb::object owner = nb::cast(grid); - std::vector tensors; + std::vector> tensors; tensors.reserve(cells.size()); for (const auto& cell : cells) { const size_t shape[3] = {cell.extent(1), cell.extent(0), cell.extent(2)}; @@ -83,13 +83,7 @@ void populate_image_module(nb::module_& m) static_cast(cell.stride(0)), static_cast(cell.stride(2)), }; - nb::ndarray tensor( - cell.data_handle(), - 3, - shape, - owner, - strides); - tensors.emplace_back(nb::cast(tensor)); + tensors.emplace_back(cell.data_handle(), 3, shape, owner, strides); } return tensors; }, diff --git a/modules/image/tests/test_image_sampling.cpp b/modules/image/tests/test_image_sampling.cpp index ccf384cc..d1915616 100644 --- a/modules/image/tests/test_image_sampling.cpp +++ b/modules/image/tests/test_image_sampling.cpp @@ -17,6 +17,8 @@ #include #include +/// @cond LA_INTERNAL_DOCS + namespace { template void is_same(const MatrixA& A, const MatrixB& B) @@ -102,3 +104,5 @@ TEST_CASE("Sample borders (density)", "[image]" LA_CORP_FLAG) is_same(samples, gt_samples); } + +/// @endcond diff --git a/modules/image_io/image_io.md b/modules/image_io/image_io.md new file mode 100644 index 00000000..fed3acc4 --- /dev/null +++ b/modules/image_io/image_io.md @@ -0,0 +1,8 @@ +Image I/O Module +============ + +@defgroup module-image_io Image I/O Module +@brief Read and write image files. + +Load and save images in common formats (including EXR), convert between image +representations, and export to SVG. diff --git a/modules/io/include/lagrange/io/load_simple_scene_fbx.h b/modules/io/include/lagrange/io/load_simple_scene_fbx.h index a73db429..1d853ef8 100644 --- a/modules/io/include/lagrange/io/load_simple_scene_fbx.h +++ b/modules/io/include/lagrange/io/load_simple_scene_fbx.h @@ -22,7 +22,7 @@ namespace lagrange::io { /** * Load a simple scene from fbx. * - * @param[in] filename input file + * @param[in] input_stream input stream data * @param[in] options * * @return scene diff --git a/modules/io/include/lagrange/io/load_simple_scene_gltf.h b/modules/io/include/lagrange/io/load_simple_scene_gltf.h index cfd46d9b..8d824318 100644 --- a/modules/io/include/lagrange/io/load_simple_scene_gltf.h +++ b/modules/io/include/lagrange/io/load_simple_scene_gltf.h @@ -20,7 +20,7 @@ namespace lagrange::io { /** - * Load a simple scene with gltf.. + * Load a simple scene with gltf. * * @param[in] input_stream input stream data * @param[in] options @@ -31,7 +31,7 @@ template SceneType load_simple_scene_gltf(std::istream& input_stream, const LoadOptions& options = {}); /** - * Load a simple scene with gltf.. + * Load a simple scene with gltf. * * @param[in] filename input file * @param[in] options diff --git a/modules/io/io.md b/modules/io/io.md index 2183da22..f621c2c1 100644 --- a/modules/io/io.md +++ b/modules/io/io.md @@ -7,10 +7,12 @@ I/O Module @defgroup module-io IO Module @brief Mesh input/output. -### Quick links +Quick links +----------- - [load_mesh](@ref lagrange::io::load_mesh) - - [load_mesh_ext](@ref lagrange::io::load_mesh_ext) (for .obj) - - [load_mesh_ply](@ref lagrange::io::load_mesh_ply) + - [load_mesh_obj](@ref lagrange::io::load_mesh_obj) + - [load_mesh_ply](@ref lagrange::io::load_mesh_ply) - [save_mesh](@ref lagrange::io::save_mesh) - - [save_mesh_ply](@ref lagrange::io::save_mesh_ply) + - [save_mesh_obj](@ref lagrange::io::save_mesh_obj) + - [save_mesh_ply](@ref lagrange::io::save_mesh_ply) diff --git a/modules/io/src/load_fbx.cpp b/modules/io/src/load_fbx.cpp index 3677353b..9c8d7685 100644 --- a/modules/io/src/load_fbx.cpp +++ b/modules/io/src/load_fbx.cpp @@ -32,7 +32,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -295,7 +297,15 @@ UfbxScene load_ufbx(const fs::path& filename) ufbx_error error{}; - return ufbx_load_file(filename_s.c_str(), &opts, &error); + ufbx_scene* scene = ufbx_load_file(filename_s.c_str(), &opts, &error); + if (!scene) { + throw Error( + lagrange::format( + "Failed to load FBX file '{}': {}", + filename.string(), + error.description.data)); + } + return UfbxScene(scene); } UfbxScene load_ufbx(std::istream& input_stream) @@ -306,7 +316,12 @@ UfbxScene load_ufbx(std::istream& input_stream) ufbx_load_opts opts = create_load_opts(); ufbx_error error{}; - return ufbx_load_memory(data.data(), data.size(), &opts, &error); + ufbx_scene* scene = ufbx_load_memory(data.data(), data.size(), &opts, &error); + if (!scene) { + throw Error( + lagrange::format("Failed to load FBX data from stream: {}", error.description.data)); + } + return UfbxScene(scene); } template diff --git a/modules/io/src/load_obj.cpp b/modules/io/src/load_obj.cpp index 88fb067e..7c94ae4f 100644 --- a/modules/io/src/load_obj.cpp +++ b/modules/io/src/load_obj.cpp @@ -25,7 +25,7 @@ #include #include #include -#include +#include #include #include #include diff --git a/modules/io/src/save_gltf.cpp b/modules/io/src/save_gltf.cpp index 0051ff31..a4e127f4 100644 --- a/modules/io/src/save_gltf.cpp +++ b/modules/io/src/save_gltf.cpp @@ -118,6 +118,34 @@ std::vector to_vec4(const Eigen::Vector4f& v) return {v(0), v(1), v(2), v(3)}; } +// glTF mimeType from an image uri's extension, for embedded images. +std::string mime_type_from_uri(const fs::path& uri, bool quiet) +{ + const std::string ext = to_lower(uri.extension().string()); + if (ext == ".jpg" || ext == ".jpeg") return "image/jpeg"; + if (ext == ".png") return "image/png"; + if (!quiet) { + logger().warn( + "Unrecognized image extension '{}', defaulting to 'image/png' for embedded image '{}'. " + "Consider changing the extension to .jpg or .png for better compatibility.", + ext, + uri.string()); + } + return "image/png"; +} + +// Whether images will actually be embedded by the writer (see save_gltf below). +bool effective_embed_images(const SaveOptions& options) +{ +#if LAGRANGE_TARGET_COMPILER(EMSCRIPTEN) + // External image writes may silently fail on the Emscripten virtual filesystem; always embed. + (void)options; + return true; +#else + return options.embed_images; +#endif +} + void save_gltf(const fs::path& filename, const tinygltf::Model& model, const SaveOptions& options) { fs::path parent_dir = filename.parent_path(); @@ -148,14 +176,7 @@ void save_gltf(const fs::path& filename, const tinygltf::Model& model, const Sav constexpr bool embed_buffers = true; constexpr bool pretty_print = true; -#if LAGRANGE_TARGET_COMPILER(EMSCRIPTEN) - // On Emscripten, writing external image files via tinygltf may silently fail on the virtual - // filesystem, producing a .glb/.gltf with unencoded raw pixel data that STB cannot decode on reload. - // Force embedding images when saving as binary .glb/.gltf to ensure a self-contained file. - bool embed_images = true; -#else - bool embed_images = options.embed_images; -#endif + const bool embed_images = effective_embed_images(options); bool success = loader.WriteGltfSceneToFile( &model, @@ -717,7 +738,8 @@ LA_SIMPLE_SCENE_X(save_simple_scene_gltf, 0); template tinygltf::Model lagrange_scene_to_gltf_model( const scene::Scene& lscene, - const SaveOptions& options) + const SaveOptions& options, + bool embed_images) { // https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html @@ -853,10 +875,15 @@ tinygltf::Model lagrange_scene_to_gltf_model( image.bits = static_cast(lbuffer.get_bits_per_element()); std::copy(lbuffer.data.begin(), lbuffer.data.end(), std::back_inserter(image.image)); - if (!limage.uri.empty()) { + // When embedding, tinygltf base64-encodes the pixels into a `data:` + // URI and picks the encoder from `mimeType`, so set the mimeType and + // drop the source uri (otherwise the writer can fall back to it and + // emit a dangling external reference). Carry the uri over only when + // NOT embedding. `embed_images` is the writer's effective value. + if (!limage.uri.empty() && !embed_images) { image.uri = limage.uri.string(); } else { - image.mimeType = "image/png"; + image.mimeType = mime_type_from_uri(limage.uri, options.quiet); } if (!limage.extensions.empty()) { @@ -1049,7 +1076,10 @@ void save_scene_gltf( const scene::Scene& lscene, const SaveOptions& options) { - auto model = lagrange_scene_to_gltf_model(lscene, options); + auto model = lagrange_scene_to_gltf_model( + lscene, + options, + effective_embed_images(options)); save_gltf(filename, model, options); } template @@ -1058,7 +1088,10 @@ void save_scene_gltf( const scene::Scene& lscene, const SaveOptions& options) { - auto model = lagrange_scene_to_gltf_model(lscene, options); + // Writing to a stream always embeds images (tinygltf cannot emit external + // image files to a stream), so `uri` must always be dropped here. + auto model = + lagrange_scene_to_gltf_model(lscene, options, /*embed_images=*/true); save_gltf(output_stream, model, options); } diff --git a/modules/io/tests/test_fbx.cpp b/modules/io/tests/test_fbx.cpp index 8e55f0cc..5d8346fd 100644 --- a/modules/io/tests/test_fbx.cpp +++ b/modules/io/tests/test_fbx.cpp @@ -32,6 +32,9 @@ TEST_CASE("load_fbx", "[io][fbx]" LA_CORP_FLAG) options); REQUIRE(vertex_view(mesh) == vertex_view(expected)); REQUIRE(facet_view(mesh) == facet_view(expected)); + + REQUIRE_THROWS(lagrange::io::load_mesh_fbx("file_not_exist.fbx")); + REQUIRE_THROWS(lagrange::io::load_scene_fbx("file_not_exist.fbx")); } TEST_CASE("load_fbx_and_save", "[io][fbx]" LA_CORP_FLAG) diff --git a/modules/io/tests/test_save_scene.cpp b/modules/io/tests/test_save_scene.cpp index d37b2658..655c9104 100644 --- a/modules/io/tests/test_save_scene.cpp +++ b/modules/io/tests/test_save_scene.cpp @@ -259,6 +259,59 @@ TEST_CASE("save_scene with float images", "[io]") } +TEST_CASE("save_scene embedded image does not leak external uri", "[io]") +{ + // A uri-only image (no decoded pixels) must not leak its uri into an + // embedded output, or the "self-contained" .glb references a missing sibling. + using SceneType = scene::Scene32f; + + scene::ImageExperimental image; + image.name = "uv_grid"; + image.uri = "uv_grid_opengl.jpg"; + // Valid element_type so save doesn't reject the image; data stays empty + // (uri-only), which is what made the writer fall back to the external uri. + image.image.element_type = make_attribute_value_type(); + + SceneType scene; + scene.add(SurfaceMesh32f()); + scene.add(image); + + auto leaks_uri = [](const std::string& bytes) { + return bytes.find("uv_grid_opengl.jpg") != std::string::npos; + }; + + SECTION("file (.glb) export - embed_images=true") + { + io::SaveOptions options; + options.export_materials = true; + options.embed_images = true; + + fs::path glb_path = testing::get_test_output_path("test_save_scene/embedded_image.glb"); + fs::path sidecar = glb_path.parent_path() / "uv_grid_opengl.jpg"; + fs::remove(sidecar); // clear any stale sidecar from a previous run + REQUIRE_NOTHROW(io::save_scene(glb_path, scene, options)); + + REQUIRE_FALSE(fs::exists(sidecar)); // no external sibling texture written + std::ifstream f(glb_path, std::ios::binary); + std::string bytes((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + REQUIRE_FALSE(leaks_uri(bytes)); + } + + SECTION("stream export - always embeds") + { + // Stream output always embeds, so the uri must be dropped even with the + // default embed_images=false. + io::SaveOptions options; + options.export_materials = true; + REQUIRE_FALSE(options.embed_images); + + std::stringstream output; + REQUIRE_NOTHROW(io::save_scene(output, scene, io::FileFormat::Gltf, options)); + REQUIRE_FALSE(leaks_uri(output.str())); + } +} + + TEST_CASE("save_scene with new root node", "[io]") { using Scene = scene::Scene32f; diff --git a/modules/main.md b/modules/main.md index ed9aaf70..cd839cd4 100644 --- a/modules/main.md +++ b/modules/main.md @@ -3,7 +3,15 @@ Lagrange Reference Documentation {#mainpage} Reference documentation for Lagrange. -### Quick links +Quick links +----------- -- [SurfaceMesh](@ref group-surfacemesh) -- [Mesh](@ref lagrange::Mesh) [deprecated] +- [SurfaceMesh](@ref group-surfacemesh) — generic surface mesh data structure + - [Mesh utilities](@ref group-surfacemesh-utils) — mesh processing utilities +- Mesh I/O + - [load_mesh](@ref lagrange::io::load_mesh) + - [save_mesh](@ref lagrange::io::save_mesh) +- Scene representation + - [Scene](@ref lagrange::scene::Scene) + - [SimpleScene](@ref lagrange::scene::SimpleScene) +- [Core module](@ref module-core) — see the Modules page for the full list diff --git a/modules/packing/packing.md b/modules/packing/packing.md new file mode 100644 index 00000000..23e53416 --- /dev/null +++ b/modules/packing/packing.md @@ -0,0 +1,9 @@ +Packing Module +============ + +@namespace lagrange::packing + +@defgroup module-packing Packing Module +@brief Rectangle and UV chart packing. + +Compute rectangle packings and repack UV charts into a texture atlas. diff --git a/modules/packing/python/tests/test_repack_uv_charts.py b/modules/packing/python/tests/test_repack_uv_charts.py index b5cae2a9..dd8974cc 100644 --- a/modules/packing/python/tests/test_repack_uv_charts.py +++ b/modules/packing/python/tests/test_repack_uv_charts.py @@ -176,4 +176,4 @@ def test_arguments_after_mesh_are_keyword_only(self, make_mesh_with_indexed_uv): uv_indices=[[0, 1, 2]], ) with pytest.raises(TypeError): - lagrange.packing.repack_uv_charts(mesh, "uv") + lagrange.packing.repack_uv_charts(mesh, "uv") # ty: ignore[too-many-positional-arguments] diff --git a/modules/partitioning/partitioning.md b/modules/partitioning/partitioning.md index c18b441d..b6b6ca09 100644 --- a/modules/partitioning/partitioning.md +++ b/modules/partitioning/partitioning.md @@ -7,6 +7,7 @@ Partitioning Module @defgroup module-partitioning Partitioning Module @brief Mesh partitioning using METIS. -### Quick links +Quick links +----------- -- [partition_mesh_vertices ](@ref lagrange::partitioning::partition_mesh_vertices ) +- [partition_mesh_vertices](@ref lagrange::partitioning::partition_mesh_vertices) diff --git a/modules/poisson/include/lagrange/poisson/AttributeEvaluator.h b/modules/poisson/include/lagrange/poisson/AttributeEvaluator.h index 952ee799..11891030 100644 --- a/modules/poisson/include/lagrange/poisson/AttributeEvaluator.h +++ b/modules/poisson/include/lagrange/poisson/AttributeEvaluator.h @@ -20,6 +20,11 @@ namespace lagrange::poisson { +/// +/// @addtogroup module-poisson +/// @{ +/// + /// /// Option struct for Poisson surface reconstruction. /// diff --git a/modules/poisson/include/lagrange/poisson/mesh_from_oriented_points.h b/modules/poisson/include/lagrange/poisson/mesh_from_oriented_points.h index 9ac14c9e..ead0731e 100644 --- a/modules/poisson/include/lagrange/poisson/mesh_from_oriented_points.h +++ b/modules/poisson/include/lagrange/poisson/mesh_from_oriented_points.h @@ -18,6 +18,11 @@ namespace lagrange::poisson { +/// +/// @addtogroup module-poisson +/// @{ +/// + /// /// Option struct for Poisson surface reconstruction. /// diff --git a/modules/poisson/poisson.md b/modules/poisson/poisson.md new file mode 100644 index 00000000..25c4cd29 --- /dev/null +++ b/modules/poisson/poisson.md @@ -0,0 +1,9 @@ +Poisson Module +============ + +@namespace lagrange::poisson + +@defgroup module-poisson Poisson Module +@brief Poisson surface reconstruction. + +Reconstruct a mesh from oriented points using Poisson surface reconstruction. diff --git a/modules/polyddg/include/lagrange/polyddg/compute_principal_curvatures.h b/modules/polyddg/include/lagrange/polyddg/compute_principal_curvatures.h index 3373a594..4efa7411 100644 --- a/modules/polyddg/include/lagrange/polyddg/compute_principal_curvatures.h +++ b/modules/polyddg/include/lagrange/polyddg/compute_principal_curvatures.h @@ -69,8 +69,8 @@ struct PrincipalCurvaturesResult /// @param[in,out] mesh Input surface mesh. Output attributes are added or overwritten. /// @param[in] ops Precomputed differential operators for the mesh. /// @param[in] options Attribute name options. Defaults produce attributes named -/// @kappa_min, @kappa_max, @principal_direction_min, -/// @principal_direction_max. +/// `@kappa_min`, `@kappa_max`, `@principal_direction_min`, +/// `@principal_direction_max`. /// /// @return Attribute IDs of the four output attributes. /// diff --git a/modules/polyddg/src/compute_smooth_direction_field.cpp b/modules/polyddg/src/compute_smooth_direction_field.cpp index da36c33e..ff654af4 100644 --- a/modules/polyddg/src/compute_smooth_direction_field.cpp +++ b/modules/polyddg/src/compute_smooth_direction_field.cpp @@ -177,9 +177,8 @@ AttributeId compute_smooth_direction_field_on_facets( // n-fold Levi-Civita transport: f0's frame → f1's frame, via shared vertex v0. // Eager-evaluate to a concrete matrix: levi_civita_nrosy returns by value, so the // product expression would otherwise hold references to destroyed temporaries. - const Eigen::Matrix R01 = - ops.levi_civita_nrosy(f1, lv0_in_f1, n) * - ops.levi_civita_nrosy(f0, lv0_in_f0, n).transpose(); + const Eigen::Matrix R01 = ops.levi_civita_nrosy(f1, lv0_in_f1, n) * + ops.levi_civita_nrosy(f0, lv0_in_f0, n).transpose(); // Weight = primal edge length. auto [v0, v1] = mesh.get_edge_vertices(eid); diff --git a/modules/polyscope/examples/mesh_viewer.cpp b/modules/polyscope/examples/mesh_viewer.cpp index 4a5ec09b..f2b6c89e 100644 --- a/modules/polyscope/examples/mesh_viewer.cpp +++ b/modules/polyscope/examples/mesh_viewer.cpp @@ -28,6 +28,8 @@ #include // clang-format on +#include + #include using Scalar = double; diff --git a/modules/polyscope/src/register_attributes.h b/modules/polyscope/src/register_attributes.h index b05616f1..1e1c2d83 100644 --- a/modules/polyscope/src/register_attributes.h +++ b/modules/polyscope/src/register_attributes.h @@ -199,6 +199,16 @@ auto register_attribute( break; case lagrange::AttributeElement::Edge: if constexpr (IsMesh) { + if (ps_struct->edgePerm.empty()) { + // Polyscope's edge ordering wasn't set (e.g. mesh isn't triangular), so we cannot + // register edge-valued data. Emit a specific warning here so the user isn't misled + // by the generic "unsupported attribute" message in the caller. + lagrange::logger().warn( + "Skipping edge attribute '{}': polyscope edge ordering is unavailable " + "(edges are only ordered for triangle meshes).", + name); + break; + } if (attr.get_usage() == Usage::Scalar) { lagrange::logger().info("Registering scalar edge attribute: {}", name); return ps_struct->addEdgeScalarQuantity(name, vector_view(attr), scalar_data_type); diff --git a/modules/polyscope/src/register_edge_network.cpp b/modules/polyscope/src/register_edge_network.cpp index d89a5b24..842adcb1 100644 --- a/modules/polyscope/src/register_edge_network.cpp +++ b/modules/polyscope/src/register_edge_network.cpp @@ -21,6 +21,8 @@ namespace lagrange::polyscope { +/// @cond LA_INTERNAL_DOCS + template ::polyscope::CurveNetwork* register_edge_network( std::string_view name, @@ -70,4 +72,6 @@ LA_SURFACE_MESH_X(register_edge_network, 0) const lagrange::Attribute& attr); LA_ATTRIBUTE_X(register_attribute, 0) +/// @endcond + } // namespace lagrange::polyscope diff --git a/modules/polyscope/src/register_mesh.cpp b/modules/polyscope/src/register_mesh.cpp index 5e818e1c..fefd8921 100644 --- a/modules/polyscope/src/register_mesh.cpp +++ b/modules/polyscope/src/register_mesh.cpp @@ -53,6 +53,8 @@ std::tuple, std::vector> standardizeNestedList( } // namespace +/// @cond LA_INTERNAL_DOCS + template ::polyscope::SurfaceMesh* register_mesh( std::string_view name, @@ -92,6 +94,29 @@ ::polyscope::SurfaceMesh* register_mesh( } }(); + // Polyscope's requires an explicit edge permutation for edge-based quantities [1]. This tells + // polyscope how to map edge indices between its own internal ordering to Lagrange's edge + // ordering. It also does not support setting permutation on non-triangle meshes [2]. + // + // [1]: https://polyscope.run/structures/surface_mesh/indexing_convention/#edges + // [2]: https://github.com/nmwsharp/polyscope/blob/59da72df6517cab8379865899bdffdbc96171301/src/surface_mesh.cpp#L217 + if (mesh.has_edges() && mesh.is_triangle_mesh()) { + const size_t num_edges = static_cast(mesh.get_num_edges()); + std::vector edge_perm; + edge_perm.reserve(num_edges); + std::vector seen(num_edges, 0); + for (Index c = 0; c < mesh.get_num_corners(); ++c) { + Index e = mesh.get_corner_edge(c); + if (!seen[e]) { + seen[e] = 1; + edge_perm.push_back(static_cast(e)); + } + } + // A mismatch here is impossible by construction. + la_debug_assert(edge_perm.size() == num_edges); + ps_mesh->setEdgePermutation(edge_perm, num_edges); + } + register_attributes(ps_mesh, mesh); return ps_mesh; @@ -119,4 +144,6 @@ LA_SURFACE_MESH_X(register_mesh, 0) const lagrange::Attribute& attr); LA_ATTRIBUTE_X(register_attribute, 0) +/// @endcond + } // namespace lagrange::polyscope diff --git a/modules/polyscope/src/register_point_cloud.cpp b/modules/polyscope/src/register_point_cloud.cpp index dee890de..64c96511 100644 --- a/modules/polyscope/src/register_point_cloud.cpp +++ b/modules/polyscope/src/register_point_cloud.cpp @@ -21,6 +21,8 @@ namespace lagrange::polyscope { +/// @cond LA_INTERNAL_DOCS + template ::polyscope::PointCloud* register_point_cloud( std::string_view name, @@ -67,4 +69,6 @@ LA_SURFACE_MESH_X(register_point_cloud, 0) const lagrange::Attribute& attr); LA_ATTRIBUTE_X(register_attribute, 0) +/// @endcond + } // namespace lagrange::polyscope diff --git a/modules/polyscope/src/register_structure.cpp b/modules/polyscope/src/register_structure.cpp index a3f4ca60..b8e28004 100644 --- a/modules/polyscope/src/register_structure.cpp +++ b/modules/polyscope/src/register_structure.cpp @@ -24,6 +24,8 @@ namespace lagrange::polyscope { +/// @cond LA_INTERNAL_DOCS + template ::polyscope::Structure* register_structure( std::string_view name, @@ -74,4 +76,6 @@ LA_SURFACE_MESH_X(register_structure, 0) const lagrange::Attribute& attr); LA_ATTRIBUTE_X(register_attribute, 0) +/// @endcond + } // namespace lagrange::polyscope diff --git a/modules/polyscope/tests/test_polyscope.cpp b/modules/polyscope/tests/test_polyscope.cpp index aef2f21c..d686644e 100644 --- a/modules/polyscope/tests/test_polyscope.cpp +++ b/modules/polyscope/tests/test_polyscope.cpp @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +#include #include #include #include @@ -220,6 +221,64 @@ TEST_CASE("register_edges_2d", "[polyscope]") REQUIRE(dynamic_cast(ps_struct) != nullptr); } +TEST_CASE("register_edge_attribute", "[polyscope]") +{ + using Scalar = double; + using Index = uint32_t; + + polyscope::init(g_backend); + + // A triangle mesh with initialized edges supports edge-valued quantities: register_mesh sets + // up polyscope's edge permutation, so an edge scalar attribute registers successfully. + { + auto mesh = + lagrange::testing::load_surface_mesh("open/core/simple/cube.obj"); + REQUIRE(mesh.is_triangle_mesh()); + mesh.initialize_edges(); + REQUIRE(mesh.get_num_edges() > 0); + + auto edge_scalar_id = mesh.template create_attribute( + "edge_scalar", + lagrange::AttributeElement::Edge, + lagrange::AttributeUsage::Scalar, + 1); + auto& edge_scalar = mesh.template ref_attribute(edge_scalar_id); + auto data = edge_scalar.ref_all(); + for (Index e = 0; e < mesh.get_num_edges(); ++e) { + data[e] = static_cast(e); + } + + auto ps_mesh = lagrange::polyscope::register_mesh("mesh_edge_tri", mesh); + REQUIRE(ps_mesh != nullptr); + + auto attr = lagrange::polyscope::register_attribute(*ps_mesh, "edge_scalar", edge_scalar); + REQUIRE(attr != nullptr); + } + + // A non-triangle mesh has no polyscope edge ordering, so edge attributes are skipped (the call + // returns nullptr) rather than crashing. + { + auto mesh = lagrange::testing::load_surface_mesh( + "open/core/simple/quad_meshes/cube.obj"); + REQUIRE_FALSE(mesh.is_triangle_mesh()); + mesh.initialize_edges(); + REQUIRE(mesh.get_num_edges() > 0); + + auto edge_scalar_id = mesh.template create_attribute( + "edge_scalar", + lagrange::AttributeElement::Edge, + lagrange::AttributeUsage::Scalar, + 1); + auto& edge_scalar = mesh.template ref_attribute(edge_scalar_id); + + auto ps_mesh = lagrange::polyscope::register_mesh("mesh_edge_quad", mesh); + REQUIRE(ps_mesh != nullptr); + + auto attr = lagrange::polyscope::register_attribute(*ps_mesh, "edge_scalar", edge_scalar); + REQUIRE(attr == nullptr); + } +} + TEST_CASE("register_4channel_attributes", "[polyscope]") { using Scalar = double; diff --git a/modules/primitive/include/lagrange/primitive/legacy/generate_rounded_plane.h b/modules/primitive/include/lagrange/primitive/legacy/generate_rounded_plane.h index e8babccc..815c518e 100644 --- a/modules/primitive/include/lagrange/primitive/legacy/generate_rounded_plane.h +++ b/modules/primitive/include/lagrange/primitive/legacy/generate_rounded_plane.h @@ -81,7 +81,7 @@ struct RoundedPlaneConfig /// @} /// @name Output parameters. - /// @} + /// @{ bool output_normals = true; /// @} diff --git a/modules/primitive/src/primitive_utils.h b/modules/primitive/src/primitive_utils.h index f280160f..2da37469 100644 --- a/modules/primitive/src/primitive_utils.h +++ b/modules/primitive/src/primitive_utils.h @@ -95,8 +95,8 @@ void add_semantic_label( template void normalize_uv( SurfaceMesh& mesh, - Eigen::Matrix min_uv, - Eigen::Matrix max_uv) + const Eigen::Matrix& min_uv, + const Eigen::Matrix& max_uv) { auto uv_mesh = uv_mesh_ref(mesh); auto uvs = vertex_ref(uv_mesh); diff --git a/modules/python/CMakeLists.txt b/modules/python/CMakeLists.txt index 67b10dcf..501bb9dc 100644 --- a/modules/python/CMakeLists.txt +++ b/modules/python/CMakeLists.txt @@ -217,7 +217,8 @@ function(lagrange_generate_python_binding_module) if(SKBUILD) # Write __init__.pyi to import all stubs. set(init_pyi_file ${SKBUILD_PLATLIB_DIR}/lagrange/__init__.pyi) - file(APPEND ${init_pyi_file} "from ._logging import logger\n") + # Explicit re-export (PEP 484) so type checkers treat `logger` as public. + file(APPEND ${init_pyi_file} "from ._logging import logger as logger\n") file(APPEND ${init_pyi_file} "from .core import *\n") file(APPEND ${init_pyi_file} "\n# Package variant identifier\nvariant: str\n") diff --git a/modules/python/lagrange/_logging.py b/modules/python/lagrange/_logging.py index d7b96a4a..3a5b7599 100644 --- a/modules/python/lagrange/_logging.py +++ b/modules/python/lagrange/_logging.py @@ -10,7 +10,7 @@ # governing permissions and limitations under the License. # import logging -import colorama # type: ignore +import colorama import platform if platform.system() == "Windows": diff --git a/modules/python/lagrange/scripts/meshstat.py b/modules/python/lagrange/scripts/meshstat.py index 7eace675..ce339c81 100644 --- a/modules/python/lagrange/scripts/meshstat.py +++ b/modules/python/lagrange/scripts/meshstat.py @@ -18,8 +18,9 @@ import logging import pathlib import platform +from contextlib import ExitStack, contextmanager -import colorama # type: ignore +import colorama import lagrange import numpy as np @@ -31,7 +32,7 @@ # --------------------------------------------------------------------------- -def _colored(text: str, color: str) -> str: +def _colored(text: str, color: object) -> str: return f"{color}{text}{colorama.Style.RESET_ALL}" @@ -115,6 +116,8 @@ def _facet_type(mesh) -> tuple[str, dict | None]: return "triangles", None if mesh.vertex_per_facet == 4: return "quads", None + if mesh.vertex_per_facet == 2: + return "two_gons", None return f"polygons ({mesh.vertex_per_facet})", None counts = {"two_gons": 0, "triangles": 0, "quads": 0, "polygons": 0} @@ -142,6 +145,16 @@ def collect_basic_info(mesh, info: dict) -> None: mesh.initialize_edges() facet_type, facet_counts = _facet_type(mesh) + if facet_counts is None: + n = int(mesh.vertex_per_facet) + num_f = int(mesh.num_facets) + facet_counts = { + "two_gons": num_f if n == 2 else 0, + "triangles": num_f if n == 3 else 0, + "quads": num_f if n == 4 else 0, + "polygons": num_f if n not in (2, 3, 4) else 0, + } + basic: dict = { "dim": int(mesh.dimension), "num_vertices": int(mesh.num_vertices), @@ -149,6 +162,7 @@ def collect_basic_info(mesh, info: dict) -> None: "num_edges": int(mesh.num_edges), "num_corners": int(mesh.num_corners), "facet_type": facet_type, + "facet_counts": facet_counts, } if mesh.num_vertices > 0: bbox_min = np.amin(mesh.vertices, axis=0) @@ -165,8 +179,6 @@ def collect_basic_info(mesh, info: dict) -> None: basic["bbox_extent"] = None basic["bbox_diagonal"] = 0.0 basic["max_extent"] = 0.0 - if facet_counts is not None: - basic["facet_counts"] = facet_counts info["basic"] = basic @@ -195,7 +207,7 @@ def print_basic_info(mesh, info: dict) -> None: print_bad(f"Unsupported dimension: {basic['dim']}") if basic["facet_type"] == "hybrid": print_bad("facet type: hybrid") - counts = basic.get("facet_counts", {}) + counts = basic["facet_counts"] if counts.get("two_gons", 0) > 0: print_bad(f" # 2-gons: {counts['two_gons']}") if counts.get("triangles", 0) > 0: @@ -210,6 +222,8 @@ def print_basic_info(mesh, info: dict) -> None: def collect_extended_info(mesh, info: dict) -> None: """Populate ``info["extended"]`` with topology/manifoldness checks.""" + if not mesh.has_edges: + mesh.initialize_edges() extended: dict = {} extended["num_components"] = int(lagrange.compute_components(mesh)) @@ -250,6 +264,10 @@ def collect_extended_info(mesh, info: dict) -> None: extended["num_degenerate_facets"] = int(len(lagrange.detect_degenerate_facets(mesh))) + extended["euler_characteristic"] = ( + int(mesh.num_vertices) - int(mesh.num_edges) + int(mesh.num_facets) + ) + valence_id = lagrange.compute_vertex_valence(mesh) extended["num_isolated_vertices"] = int(np.sum(mesh.attribute(valence_id).data == 0)) @@ -289,6 +307,7 @@ def print_extra_info(mesh, info: dict) -> None: print_property("orientable", True, True) print_property("num degenerate facets", extended["num_degenerate_facets"], 0) print_property("num isolated vertices", extended["num_isolated_vertices"], 0) + print_property("euler characteristic", extended["euler_characteristic"]) if "num_intersecting_pairs" in extended: print_property("num intersecting pairs", extended["num_intersecting_pairs"], 0) @@ -361,6 +380,21 @@ def _safe_attribute_name(name: str) -> str: return "".join(ch if ch.isalnum() else "_" for ch in name) +def _delete_if_exists(mesh, name: str) -> None: + if mesh.has_attribute(name): + mesh.delete_attribute(name) + + +@contextmanager +def _temp_mesh_attribute(mesh, name_hint: str): + """Reserve a unique attribute name; delete the attribute on exit if present.""" + name = lagrange.get_unique_attribute_name(mesh, name_hint, emit_warning=False) + try: + yield name + finally: + _delete_if_exists(mesh, name) + + def _normalize_uv_attribute(mesh, uv_attr_id: int) -> tuple[int, str]: """Ensure the UV attribute is indexed and float64. Returns (id, name).""" if not mesh.is_attribute_indexed(uv_attr_id): @@ -384,94 +418,105 @@ def collect_uv_info(mesh, info: dict, metrics: list) -> None: logger.warning("No UV attributes on mesh; skipping UV section.") return - edge_lengths_id: int | None = None max_extent = float(info.get("basic", {}).get("max_extent", 0.0)) - for uv_attr_id in uv_ids: - original_name = mesh.get_attribute_name(uv_attr_id) - safe = _safe_attribute_name(original_name) - norm_id, compute_name = _normalize_uv_attribute(mesh, uv_attr_id) + with ExitStack() as outer_stack: + edge_lengths_id: int | None = None - num_facets = int(mesh.num_facets) - entry: dict = {"num_facets_evaluated": num_facets} - - # Charts. Use `get_unique_attribute_name` so re-running on the same mesh - # (or two UV attributes whose names sanitize to the same `safe` string) - # does not collide with an existing attribute. - chart_attr_name = lagrange.get_unique_attribute_name( - mesh, f"@meshstat_{safe}_chart_id", emit_warning=False - ) - entry["num_charts"] = int( - lagrange.compute_uv_charts( - mesh, - uv_attribute_name=compute_name, - output_attribute_name=chart_attr_name, - ) - ) + for uv_attr_id in uv_ids: + original_name = mesh.get_attribute_name(uv_attr_id) + safe = _safe_attribute_name(original_name) + norm_id, compute_name = _normalize_uv_attribute(mesh, uv_attr_id) - # Orientation (flipped + degenerate facets) - orient = lagrange.compute_uv_orientation(mesh, uv_attribute_name=compute_name) - entry["num_flipped_facets"] = int(orient.negative) - entry["num_degenerate_facets"] = int(orient.degenerate) - entry["fraction_flipped_facets"] = ( - float(orient.negative) / num_facets if num_facets > 0 else 0.0 - ) + with ExitStack() as inner_stack: + if compute_name != original_name: + inner_stack.callback(_delete_if_exists, mesh, compute_name) - # Overlap - overlap_result = lagrange.bvh.compute_uv_overlap( - mesh, - uv_attribute_name=compute_name, - compute_overlap_area=True, - compute_overlapping_pairs=True, - compute_overlap_coloring=False, - ) - entry["overlap"] = { - "has_overlap": bool(overlap_result.has_overlap), - "overlap_area": float(overlap_result.overlap_area) - if overlap_result.overlap_area is not None - else 0.0, - "num_overlapping_pairs": int(len(overlap_result.overlapping_pairs)), - } + num_facets = int(mesh.num_facets) + entry: dict = {"num_facets_evaluated": num_facets} - # Seams (need 3D edge lengths once). Boundary edges are also counted as seams. - seam_attr_name = lagrange.get_unique_attribute_name( - mesh, f"@meshstat_{safe}_seam_edges", emit_warning=False - ) - seam_id = lagrange.compute_seam_edges( - mesh, - norm_id, - output_attribute_name=seam_attr_name, - include_boundary_edges=True, - ) - if edge_lengths_id is None: - edge_lengths_attr_name = lagrange.get_unique_attribute_name( - mesh, "@meshstat_edge_lengths", emit_warning=False - ) - edge_lengths_id = lagrange.compute_edge_lengths( - mesh, output_attribute_name=edge_lengths_attr_name - ) - seam_mask = np.asarray(mesh.attribute(seam_id).data) != 0 - edge_lengths = np.asarray(mesh.attribute(edge_lengths_id).data) - total_seam_length = float(edge_lengths[seam_mask].sum()) if seam_mask.any() else 0.0 - entry["seams"] = { - "num_seam_edges": int(seam_mask.sum()), - "total_length_3d": total_seam_length, - "relative_length_3d": (total_seam_length / max_extent if max_extent > 0 else None), - } + chart_attr = inner_stack.enter_context( + _temp_mesh_attribute(mesh, f"@meshstat_{safe}_chart_id") + ) + entry["num_charts"] = int( + lagrange.compute_uv_charts( + mesh, + uv_attribute_name=compute_name, + output_attribute_name=chart_attr, + ) + ) - # Distortion (per metric) - distortion: dict = {} - for metric in metrics: - metric_name = str(metric).split(".")[-1] - out_attr = lagrange.get_unique_attribute_name( - mesh, f"@meshstat_{safe}_uv_distortion_{metric_name}", emit_warning=False - ) - attr_id = lagrange.compute_uv_distortion(mesh, compute_name, out_attr, metric) - values = np.asarray(mesh.attribute(attr_id).data, dtype=np.float64) - distortion[metric_name] = compute_stats(values) - entry["distortion"] = distortion + orient_attr = inner_stack.enter_context( + _temp_mesh_attribute(mesh, f"@meshstat_{safe}_uv_orientation") + ) + orient = lagrange.compute_uv_orientation( + mesh, + uv_attribute_name=compute_name, + output_attribute_name=orient_attr, + ) + entry["num_flipped_facets"] = int(orient.negative) + entry["num_degenerate_facets"] = int(orient.degenerate) + entry["fraction_flipped_facets"] = ( + float(orient.negative) / num_facets if num_facets > 0 else 0.0 + ) - info["uv"][original_name] = entry + overlap_result = lagrange.bvh.compute_uv_overlap( + mesh, + uv_attribute_name=compute_name, + compute_overlap_area=True, + compute_overlapping_pairs=True, + compute_overlap_coloring=False, + ) + entry["overlap"] = { + "has_overlap": bool(overlap_result.has_overlap), + "overlap_area": float(overlap_result.overlap_area) + if overlap_result.overlap_area is not None + else 0.0, + "num_overlapping_pairs": int(len(overlap_result.overlapping_pairs)), + } + + # Seams (need 3D edge lengths once). Boundary edges are also + # counted as seams: they bound the UV chart even though they only + # have UV indices on one side. + seam_attr = inner_stack.enter_context( + _temp_mesh_attribute(mesh, f"@meshstat_{safe}_seam_edges") + ) + seam_id = lagrange.compute_seam_edges( + mesh, + norm_id, + output_attribute_name=seam_attr, + include_boundary_edges=True, + ) + if edge_lengths_id is None: + edge_lengths_attr = outer_stack.enter_context( + _temp_mesh_attribute(mesh, "@meshstat_edge_lengths") + ) + edge_lengths_id = lagrange.compute_edge_lengths( + mesh, output_attribute_name=edge_lengths_attr + ) + seam_mask = np.asarray(mesh.attribute(seam_id).data) != 0 + edge_lengths = np.asarray(mesh.attribute(edge_lengths_id).data) + total_seam_length = float(edge_lengths[seam_mask].sum()) if seam_mask.any() else 0.0 + entry["seams"] = { + "num_seam_edges": int(seam_mask.sum()), + "total_length_3d": total_seam_length, + "relative_length_3d": ( + total_seam_length / max_extent if max_extent > 0 else None + ), + } + + distortion: dict = {} + for metric in metrics: + metric_name = str(metric).split(".")[-1] + out_attr = inner_stack.enter_context( + _temp_mesh_attribute(mesh, f"@meshstat_{safe}_uv_distortion_{metric_name}") + ) + attr_id = lagrange.compute_uv_distortion(mesh, compute_name, out_attr, metric) + values = np.asarray(mesh.attribute(attr_id).data, dtype=np.float64) + distortion[metric_name] = compute_stats(values) + entry["distortion"] = distortion + + info["uv"][original_name] = entry def print_uv_info(info: dict) -> None: @@ -573,7 +618,6 @@ def run(args: argparse.Namespace) -> int: metrics = [getattr(lagrange.DistortionMetric, name) for name in metric_names] mesh = lagrange.io.load_mesh(args.input_mesh, quiet=True, stitch_vertices=args.stitched) - mesh.initialize_edges() info: dict = {"file": str(args.input_mesh)} diff --git a/modules/raycasting/examples/picking_demo.cpp b/modules/raycasting/examples/picking_demo.cpp index 47b161e4..b4aa7a48 100644 --- a/modules/raycasting/examples/picking_demo.cpp +++ b/modules/raycasting/examples/picking_demo.cpp @@ -31,6 +31,8 @@ #include // clang-format on +#include + #include using SurfaceMesh = lagrange::SurfaceMesh32f; diff --git a/modules/raycasting/include/lagrange/raycasting/Options.h b/modules/raycasting/include/lagrange/raycasting/Options.h index d26bc23e..344adc7c 100644 --- a/modules/raycasting/include/lagrange/raycasting/Options.h +++ b/modules/raycasting/include/lagrange/raycasting/Options.h @@ -106,7 +106,7 @@ inline const std::map& fallback_modes() } /// -/// @addtogroup group-raycasting +/// @addtogroup module-raycasting /// @{ /// diff --git a/modules/raycasting/include/lagrange/raycasting/RayCaster.h b/modules/raycasting/include/lagrange/raycasting/RayCaster.h index 67ef2d78..2dfc3780 100644 --- a/modules/raycasting/include/lagrange/raycasting/RayCaster.h +++ b/modules/raycasting/include/lagrange/raycasting/RayCaster.h @@ -192,7 +192,7 @@ enum class BuildQuality { /// setters) are **not** thread-safe and must not be called concurrently with each other /// or with query methods. /// -class RayCaster +class LA_RAYCASTING_API RayCaster { public: /// 3D point type. diff --git a/modules/raycasting/include/lagrange/raycasting/compute_local_feature_size.h b/modules/raycasting/include/lagrange/raycasting/compute_local_feature_size.h index 19333468..cedfc323 100644 --- a/modules/raycasting/include/lagrange/raycasting/compute_local_feature_size.h +++ b/modules/raycasting/include/lagrange/raycasting/compute_local_feature_size.h @@ -22,7 +22,7 @@ namespace lagrange::raycasting { class RayCaster; /// -/// @addtogroup group-raycasting +/// @addtogroup module-raycasting /// @{ /// diff --git a/modules/raycasting/include/lagrange/raycasting/project.h b/modules/raycasting/include/lagrange/raycasting/project.h index 4a0ed349..1b62bf44 100644 --- a/modules/raycasting/include/lagrange/raycasting/project.h +++ b/modules/raycasting/include/lagrange/raycasting/project.h @@ -20,7 +20,7 @@ namespace lagrange::raycasting { class RayCaster; /// -/// @addtogroup group-raycasting +/// @addtogroup module-raycasting /// @{ /// diff --git a/modules/raycasting/include/lagrange/raycasting/project_closest_point.h b/modules/raycasting/include/lagrange/raycasting/project_closest_point.h index 281a79a8..15d7065f 100644 --- a/modules/raycasting/include/lagrange/raycasting/project_closest_point.h +++ b/modules/raycasting/include/lagrange/raycasting/project_closest_point.h @@ -25,7 +25,7 @@ namespace lagrange::raycasting { class RayCaster; /// -/// @addtogroup group-raycasting +/// @addtogroup module-raycasting /// @{ /// diff --git a/modules/raycasting/include/lagrange/raycasting/project_closest_vertex.h b/modules/raycasting/include/lagrange/raycasting/project_closest_vertex.h index 2400b99f..05050aca 100644 --- a/modules/raycasting/include/lagrange/raycasting/project_closest_vertex.h +++ b/modules/raycasting/include/lagrange/raycasting/project_closest_vertex.h @@ -25,7 +25,7 @@ namespace lagrange::raycasting { class RayCaster; /// -/// @addtogroup group-raycasting +/// @addtogroup module-raycasting /// @{ /// diff --git a/modules/raycasting/include/lagrange/raycasting/project_directional.h b/modules/raycasting/include/lagrange/raycasting/project_directional.h index 0e829d4e..4f69789b 100644 --- a/modules/raycasting/include/lagrange/raycasting/project_directional.h +++ b/modules/raycasting/include/lagrange/raycasting/project_directional.h @@ -20,7 +20,7 @@ namespace lagrange::raycasting { class RayCaster; /// -/// @addtogroup group-raycasting +/// @addtogroup module-raycasting /// @{ /// diff --git a/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h b/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h index 7c43dc55..4a7da84a 100644 --- a/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h +++ b/modules/raycasting/include/lagrange/raycasting/remove_occluded_facets.h @@ -23,7 +23,7 @@ namespace lagrange::raycasting { /// -/// @addtogroup group-raycasting +/// @addtogroup module-raycasting /// @{ /// diff --git a/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h b/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h index e74e6ea4..2fd5a16f 100644 --- a/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h +++ b/modules/raycasting/include/lagrange/raycasting/remove_occluded_instances.h @@ -22,7 +22,7 @@ namespace lagrange::raycasting { /// -/// @addtogroup group-raycasting +/// @addtogroup module-raycasting /// @{ /// @@ -142,7 +142,7 @@ LA_RAYCASTING_API void estimate_occluded_instances( /// /// Remove fully-occluded mesh instances. Convenience wrapper around -/// @ref estimate_occluded_instances + @ref scene::filter_instances. +/// @ref estimate_occluded_instances + `lagrange::scene::filter_instances`. /// /// @note Only 3D scenes are supported. /// diff --git a/modules/raycasting/python/src/raycasting.cpp b/modules/raycasting/python/src/raycasting.cpp index e3927028..41787b7d 100644 --- a/modules/raycasting/python/src/raycasting.cpp +++ b/modules/raycasting/python/src/raycasting.cpp @@ -26,10 +26,19 @@ #include #include #include +#include +#include + +#include #include +#include #include +#include +#include +#include #include +#include #include namespace nb = nanobind; @@ -42,9 +51,211 @@ namespace { using Scalar = double; using Index = uint32_t; using MeshType = SurfaceMesh; +// Python None | float scalar | float numpy array +using FloatArray = nb::ndarray; +using FloatParam = std::variant; +// Python None | int (AttributeId) | 3D vector +using DirectionParam = std::variant; using NDArray3D = nb::ndarray, nb::c_contig, nb::device::cpu>; +/// Parse a 1D (3,) or 2D (N, 3) float array and return the number of points/rays. +size_t get_num_points(const FloatArray& arr, const char* name) +{ + if (arr.ndim() == 1) { + if (arr.shape(0) != 3) { + throw std::invalid_argument( + std::string(name) + " must have 3 components, got " + std::to_string(arr.shape(0))); + } + return 1; + } else if (arr.ndim() == 2) { + if (arr.shape(1) != 3) { + throw std::invalid_argument( + std::string(name) + " must have shape (N, 3), got (*, " + + std::to_string(arr.shape(1)) + ")"); + } + return arr.shape(0); + } else { + throw std::invalid_argument(std::string(name) + " must be 1D (3,) or 2D (N, 3)"); + } +} + +/// View one row of a 1D (3,) or 2D (N, 3) c-contiguous float array as an Eigen +/// vector. Returns an Eigen::Map aliasing the array's memory; no copy is made, +/// so `arr` must outlive the returned map. +Eigen::Map get_row(const FloatArray& arr, size_t row) +{ + const float* base = static_cast(arr.data()) + (arr.ndim() == 1 ? 0 : row * 3); + return Eigen::Map(base); +} + +/// Non-owning per-ray view over a FloatParam. A None/scalar parameter is +/// broadcast to all rays via a zero inner stride (every element aliases a single +/// backing float); an array parameter is mapped in place. Reading is `view[i]`; +/// no allocation or copy is performed. +using FloatParamView = + Eigen::Map>; + +/// Build a FloatParamView (None | scalar | array) over `n` rays. None/scalar +/// values are broadcast through a zero-stride map backed by `scratch`, which +/// must outlive the returned view; an array must have shape (n,) and is mapped +/// in place. `name` identifies the parameter (e.g. "tmin"/"tmax") in error +/// messages. +FloatParamView view_float_param( + const FloatParam& param, + size_t n, + float default_val, + const char* name, + float& scratch) +{ + using InnerStride = Eigen::InnerStride; + const auto len = static_cast(n); + return std::visit( + [&](const auto& v) -> FloatParamView { + using T = std::decay_t; + if constexpr (std::is_same_v || std::is_same_v) { + if constexpr (std::is_same_v) { + scratch = v; + } else { + scratch = default_val; + } + // Zero inner stride => every element reads the single `scratch`. + return FloatParamView(&scratch, len, InnerStride(0)); + } else { + if (v.ndim() != 1 || v.shape(0) != n) { + throw std::invalid_argument( + std::string(name) + " array must have shape (" + std::to_string(n) + + ",) to match the number of rays"); + } + return FloatParamView(static_cast(v.data()), len, InnerStride(1)); + } + }, + param); +} + +// ========================================================================= +// Struct-of-arrays results for batch queries. +// +// Building one Python object per ray (and appending to a list) dominates the +// cost of a large batch. Instead we accumulate each field into a contiguous +// buffer and expose them as numpy arrays, which is both faster to fill (the +// loop stays in C++ with the GIL released) and faster to consume (vectorized +// on the Python side). Misses are reported through the `hit` boolean mask; the +// other fields of a missed entry are left at their defaults. +// ========================================================================= + +/// Struct-of-arrays result of a batch ray cast (one entry per input ray). +struct RayHits +{ + size_t n = 0; + std::vector hit; // (n,) 1 = hit, 0 = miss + std::vector mesh_index; // (n,) + std::vector instance_index; // (n,) + std::vector facet_index; // (n,) + std::vector barycentric_coord; // (n, 2) + std::vector position; // (n, 3) + std::vector ray_depth; // (n,) + std::vector normal; // (n, 3) + + void resize(size_t count) + { + n = count; + hit.assign(n, 0); + mesh_index.assign(n, lagrange::invalid()); + instance_index.assign(n, lagrange::invalid()); + facet_index.assign(n, lagrange::invalid()); + barycentric_coord.assign(n * 2, 0.0f); + position.assign(n * 3, 0.0f); + ray_depth.assign(n, 0.0f); + normal.assign(n * 3, 0.0f); + } + + void set(size_t i, const raycasting::RayHit& h) + { + hit[i] = 1; + mesh_index[i] = h.mesh_index; + instance_index[i] = h.instance_index; + facet_index[i] = h.facet_index; + barycentric_coord[2 * i + 0] = h.barycentric_coord[0]; + barycentric_coord[2 * i + 1] = h.barycentric_coord[1]; + position[3 * i + 0] = h.position[0]; + position[3 * i + 1] = h.position[1]; + position[3 * i + 2] = h.position[2]; + ray_depth[i] = h.ray_depth; + normal[3 * i + 0] = h.normal[0]; + normal[3 * i + 1] = h.normal[1]; + normal[3 * i + 2] = h.normal[2]; + } +}; + +/// Struct-of-arrays result of a batch closest-point query (one entry per query). +struct ClosestPointHits +{ + size_t n = 0; + std::vector hit; // (n,) 1 = hit, 0 = miss + std::vector mesh_index; // (n,) + std::vector instance_index; // (n,) + std::vector facet_index; // (n,) + std::vector barycentric_coord; // (n, 2) + std::vector position; // (n, 3) + std::vector distance; // (n,) + + void resize(size_t count) + { + n = count; + hit.assign(n, 0); + mesh_index.assign(n, lagrange::invalid()); + instance_index.assign(n, lagrange::invalid()); + facet_index.assign(n, lagrange::invalid()); + barycentric_coord.assign(n * 2, 0.0f); + position.assign(n * 3, 0.0f); + distance.assign(n, std::numeric_limits::infinity()); + } + + void set(size_t i, const raycasting::ClosestPointHit& h) + { + hit[i] = 1; + mesh_index[i] = h.mesh_index; + instance_index[i] = h.instance_index; + facet_index[i] = h.facet_index; + barycentric_coord[2 * i + 0] = h.barycentric_coord[0]; + barycentric_coord[2 * i + 1] = h.barycentric_coord[1]; + position[3 * i + 0] = h.position[0]; + position[3 * i + 1] = h.position[1]; + position[3 * i + 2] = h.position[2]; + distance[i] = h.distance; + } +}; + +/// numpy array of bool returned by batch occluded() queries. +using BoolArray = nb::ndarray>; + +/// Wrap a uint8_t buffer as a non-copying numpy bool array of length `n`. The +/// returned array keeps `owner` (a bound result struct) alive. +nb::ndarray> +view_hit(std::vector& v, size_t n, nb::handle owner) +{ + return nb::ndarray>(reinterpret_cast(v.data()), {n}, owner); +} + +/// Wrap a per-element buffer as a non-copying 1D numpy array of length `n`. +template +nb::ndarray> view_1d(std::vector& v, size_t n, nb::handle owner) +{ + return nb::ndarray>(v.data(), {n}, owner); +} + +/// Wrap a flat buffer of `n * Cols` floats as a non-copying (n, Cols) numpy array. +template +nb::ndarray> +view_2d(std::vector& v, size_t n, nb::handle owner) +{ + return nb::ndarray>( + v.data(), + {n, static_cast(Cols)}, + owner); +} + std::tuple, Shape, Stride> tensor_to_span(NDArray3D tensor) { Shape shape; @@ -160,6 +371,156 @@ void populate_raycasting_module(nb::module_& m) raycasting::ProjectMode::RayCasting, "Project along a prescribed direction."); + // ========================================================================= + // RayHit result struct + // ========================================================================= + + nb::class_(m, "RayHit", "Result of a ray intersection query.") + .def_ro("mesh_index", &raycasting::RayHit::mesh_index, "Index of the hit mesh.") + .def_ro( + "instance_index", + &raycasting::RayHit::instance_index, + "Index of the hit instance (relative to the source mesh).") + .def_ro("facet_index", &raycasting::RayHit::facet_index, "Index of the hit facet.") + .def_ro( + "barycentric_coord", + &raycasting::RayHit::barycentric_coord, + R"(Barycentric coordinates ``(u, v)`` of the hit point within the triangle. +The surface point is: ``p = (1 - u - v) * p1 + u * p2 + v * p3``.)") + .def_ro("position", &raycasting::RayHit::position, "World-space position of the hit point.") + .def_ro( + "ray_depth", + &raycasting::RayHit::ray_depth, + "Parametric distance along the ray (t value).") + .def_ro( + "normal", + &raycasting::RayHit::normal, + "Unnormalized geometric normal at the hit point."); + + // ========================================================================= + // ClosestPointHit result struct + // ========================================================================= + + nb::class_( + m, + "ClosestPointHit", + "Result of a closest point query.") + .def_ro("mesh_index", &raycasting::ClosestPointHit::mesh_index, "Index of the hit mesh.") + .def_ro( + "instance_index", + &raycasting::ClosestPointHit::instance_index, + "Index of the hit instance (relative to the source mesh).") + .def_ro("facet_index", &raycasting::ClosestPointHit::facet_index, "Index of the hit facet.") + .def_ro( + "barycentric_coord", + &raycasting::ClosestPointHit::barycentric_coord, + R"(Barycentric coordinates ``(u, v)`` of the hit point within the triangle. +The surface point is: ``p = (1 - u - v) * p1 + u * p2 + v * p3``.)") + .def_ro( + "position", + &raycasting::ClosestPointHit::position, + "World-space position of the closest point on the surface.") + .def_ro( + "distance", + &raycasting::ClosestPointHit::distance, + "Distance from the query point to the closest point on the surface."); + + // ========================================================================= + // Struct-of-arrays results for batch queries + // ========================================================================= + + nb::class_( + m, + "RayHits", + R"(Struct-of-arrays result of a batch ray cast, with one entry per input ray. + +Misses are reported through the ``hit`` boolean mask; the remaining fields of a +missed ray are left at default values (invalid indices, zeros). All arrays share +the same length ``N``.)") + .def_prop_ro( + "hit", + [](RayHits& self) { return view_hit(self.hit, self.n, nb::find(&self)); }, + "Boolean mask of shape (N,); ``True`` where the ray hit something.") + .def_prop_ro( + "mesh_index", + [](RayHits& self) { return view_1d(self.mesh_index, self.n, nb::find(&self)); }, + "Index of the hit mesh, shape (N,).") + .def_prop_ro( + "instance_index", + [](RayHits& self) { return view_1d(self.instance_index, self.n, nb::find(&self)); }, + "Index of the hit instance (relative to the source mesh), shape (N,).") + .def_prop_ro( + "facet_index", + [](RayHits& self) { return view_1d(self.facet_index, self.n, nb::find(&self)); }, + "Index of the hit facet, shape (N,).") + .def_prop_ro( + "barycentric_coord", + [](RayHits& self) { + return view_2d<2>(self.barycentric_coord, self.n, nb::find(&self)); + }, + R"(Barycentric coordinates ``(u, v)`` of each hit point, shape (N, 2). +The surface point is: ``p = (1 - u - v) * p1 + u * p2 + v * p3``.)") + .def_prop_ro( + "position", + [](RayHits& self) { return view_2d<3>(self.position, self.n, nb::find(&self)); }, + "World-space hit positions, shape (N, 3).") + .def_prop_ro( + "ray_depth", + [](RayHits& self) { return view_1d(self.ray_depth, self.n, nb::find(&self)); }, + "Parametric distance along each ray (t value), shape (N,).") + .def_prop_ro( + "normal", + [](RayHits& self) { return view_2d<3>(self.normal, self.n, nb::find(&self)); }, + "Unnormalized geometric normals at the hit points, shape (N, 3)."); + + nb::class_( + m, + "ClosestPointHits", + R"(Struct-of-arrays result of a batch closest-point query, one entry per query. + +The ``hit`` boolean mask reports which queries found a closest point; the +remaining fields of a missed query are left at default values. All arrays share +the same length ``N``.)") + .def_prop_ro( + "hit", + [](ClosestPointHits& self) { return view_hit(self.hit, self.n, nb::find(&self)); }, + "Boolean mask of shape (N,); ``True`` where a closest point was found.") + .def_prop_ro( + "mesh_index", + [](ClosestPointHits& self) { + return view_1d(self.mesh_index, self.n, nb::find(&self)); + }, + "Index of the hit mesh, shape (N,).") + .def_prop_ro( + "instance_index", + [](ClosestPointHits& self) { + return view_1d(self.instance_index, self.n, nb::find(&self)); + }, + "Index of the hit instance (relative to the source mesh), shape (N,).") + .def_prop_ro( + "facet_index", + [](ClosestPointHits& self) { + return view_1d(self.facet_index, self.n, nb::find(&self)); + }, + "Index of the hit facet, shape (N,).") + .def_prop_ro( + "barycentric_coord", + [](ClosestPointHits& self) { + return view_2d<2>(self.barycentric_coord, self.n, nb::find(&self)); + }, + R"(Barycentric coordinates ``(u, v)`` of each closest point, shape (N, 2). +The surface point is: ``p = (1 - u - v) * p1 + u * p2 + v * p3``.)") + .def_prop_ro( + "position", + [](ClosestPointHits& self) { + return view_2d<3>(self.position, self.n, nb::find(&self)); + }, + "World-space closest-point positions, shape (N, 3).") + .def_prop_ro( + "distance", + [](ClosestPointHits& self) { return view_1d(self.distance, self.n, nb::find(&self)); }, + "Distance from each query point to its closest surface point, shape (N,)."); + // ========================================================================= // RayCaster class (construction and scene population only) // ========================================================================= @@ -349,7 +710,401 @@ The number and order of vertices must not change. :param mesh_index: Index of the source mesh. :param instance_index: Local instance index. -:param visible: True to make visible, False to hide.)"); +:param visible: True to make visible, False to hide.)") + + .def( + "cast", + [](const raycasting::RayCaster& caster, + FloatArray origins, + FloatArray directions, + FloatParam tmin_param, + FloatParam tmax_param) -> std::variant, RayHits> { + if (origins.ndim() != directions.ndim()) { + throw std::invalid_argument( + "origins and directions must have the same number of dimensions " + "(both (3,) or both (N, 3))"); + } + size_t n_origins = get_num_points(origins, "origins"); + size_t n_directions = get_num_points(directions, "directions"); + if (n_origins != n_directions) { + throw std::invalid_argument( + "origins and directions must have the same number of rays"); + } + size_t n = n_origins; + + // Backing storage for broadcast (None/scalar) tmin/tmax maps; + // must outlive the maps and the parallel region below. + float tmin_scratch = 0.0f, tmax_scratch = 0.0f; + const auto tmin_vals = view_float_param(tmin_param, n, 0.0f, "tmin", tmin_scratch); + const auto tmax_vals = view_float_param( + tmax_param, + n, + std::numeric_limits::infinity(), + "tmax", + tmax_scratch); + + // Return type follows input dimensionality: + // - 1D (3,) -> single RayHit | None + // - 2D (N,3) -> RayHits struct-of-arrays, one entry per ray (even for N == 1). + if (origins.ndim() == 1) { + return caster.cast( + get_row(origins, 0), + get_row(directions, 0), + tmin_vals[0], + tmax_vals[0]); + } + + // Convert one entry of a RayHitN packet to an optional RayHit. + auto to_hit = [](const auto& hits, size_t i) -> std::optional { + if (!hits.is_valid(i)) return std::nullopt; + raycasting::RayHit h; + h.mesh_index = hits.mesh_indices[i]; + h.instance_index = hits.instance_indices[i]; + h.facet_index = hits.facet_indices[i]; + h.barycentric_coord = hits.barycentric_coords.col(i); + h.position = hits.positions.col(i); + h.ray_depth = hits.ray_depths[i]; + h.normal = hits.normals.col(i); + return h; + }; + + RayHits out; + out.resize(n); + + // Process rays in parallel over chunks of up to 16, picking the packet + // width (16, 8, 4, or a single-ray query) that best fits each chunk. The + // GIL is released for the parallel region: results are written straight + // into the struct-of-arrays (pure C++, no per-ray Python objects). + const size_t num_chunks = (n + 15) / 16; + { + nb::gil_scoped_release release; + tbb::parallel_for(size_t(0), num_chunks, [&](size_t chunk) { + const size_t offset = chunk * 16; + const size_t count = std::min(n - offset, size_t(16)); + + auto cast_batch = [&](auto point_tag, auto float_tag, size_t N) { + using PointNf = decltype(point_tag); + using FloatN = decltype(float_tag); + PointNf orig, dir; + FloatN tmin_v, tmax_v; + for (size_t i = 0; i < count; ++i) { + orig.row(i) = get_row(origins, offset + i).transpose(); + dir.row(i) = get_row(directions, offset + i).transpose(); + tmin_v[i] = tmin_vals[offset + i]; + tmax_v[i] = tmax_vals[offset + i]; + } + for (size_t i = count; i < N; ++i) { + orig.row(i).setZero(); + dir.row(i).setZero(); + tmin_v[i] = 0.0f; + tmax_v[i] = std::numeric_limits::infinity(); + } + if constexpr (PointNf::RowsAtCompileTime == 4) { + return caster.cast4(orig, dir, count, tmin_v, tmax_v); + } else if constexpr (PointNf::RowsAtCompileTime == 8) { + return caster.cast8(orig, dir, count, tmin_v, tmax_v); + } else { + return caster.cast16(orig, dir, count, tmin_v, tmax_v); + } + }; + + if (count == 1) { + if (auto h = caster.cast( + get_row(origins, offset), + get_row(directions, offset), + tmin_vals[offset], + tmax_vals[offset])) + out.set(offset, *h); + } else if (count <= 4) { + auto hits = cast_batch( + raycasting::RayCaster::Point4f{}, + raycasting::RayCaster::Float4{}, + 4); + for (size_t i = 0; i < count; ++i) + if (auto h = to_hit(hits, i)) out.set(offset + i, *h); + } else if (count <= 8) { + auto hits = cast_batch( + raycasting::RayCaster::Point8f{}, + raycasting::RayCaster::Float8{}, + 8); + for (size_t i = 0; i < count; ++i) + if (auto h = to_hit(hits, i)) out.set(offset + i, *h); + } else { + auto hits = cast_batch( + raycasting::RayCaster::Point16f{}, + raycasting::RayCaster::Float16{}, + 16); + for (size_t i = 0; i < count; ++i) + if (auto h = to_hit(hits, i)) out.set(offset + i, *h); + } + }); + } + + return out; + }, + "origins"_a, + "directions"_a, + "tmin"_a = nb::none(), + "tmax"_a = nb::none(), + R"(Cast one or more rays and find the closest intersections. + +For a single ray, pass 1D arrays of shape (3,) for origin and direction. +For batch queries, pass 2D arrays of shape (N, 3) for any N >= 1. +The appropriate SIMD packet function (cast4, cast8, cast16) is selected +automatically based on batch size, and batches are processed in parallel. +For N > 16, rays are processed in chunks of up to 16. + +``origins`` and ``directions`` must have matching dimensionality (both +(3,) or both (N, 3)); mixing the two raises ``ValueError``. + +:param origins: Ray origin(s). Shape (3,) for a single ray or (N, 3) for a batch. +:param directions: Ray direction(s). Shape (3,) for a single ray or (N, 3) for a batch. +:param tmin: Minimum parametric distance(s). Scalar or array of shape (N,). If None, default is 0. +:param tmax: Maximum parametric distance(s). Scalar or array of shape (N,). If None, default is inf. +:return: For a single ray (1D input): a ``RayHit`` or ``None`` on a miss. + For a batch (2D input): a ``RayHits`` struct-of-arrays with one entry per + ray; misses are flagged by the ``hit`` boolean mask.)") + + .def( + "closest_point", + [](const raycasting::RayCaster& caster, FloatArray query_points) + -> std::variant, ClosestPointHits> { + size_t n = get_num_points(query_points, "query_points"); + + // Return type follows input dimensionality: + // - 1D (3,) -> single ClosestPointHit | None + // - 2D (N,3) -> ClosestPointHits struct-of-arrays, one entry per query. + if (query_points.ndim() == 1) { + return caster.closest_point(get_row(query_points, 0)); + } + + // Convert one entry of a ClosestPointHitN packet to an optional hit. + auto to_hit = [](const auto& hits, + size_t i) -> std::optional { + if (!hits.is_valid(i)) return std::nullopt; + raycasting::ClosestPointHit h; + h.mesh_index = hits.mesh_indices[i]; + h.instance_index = hits.instance_indices[i]; + h.facet_index = hits.facet_indices[i]; + h.barycentric_coord = hits.barycentric_coords.col(i); + h.position = hits.positions.col(i); + h.distance = hits.distances[i]; + return h; + }; + + ClosestPointHits out; + out.resize(n); + + // Process queries in parallel over chunks of up to 16, picking the packet + // width (16, 8, 4, or a single query) that best fits each chunk. The GIL + // is released for the parallel region: results are written straight into + // the struct-of-arrays (pure C++, no per-query Python objects). + const size_t num_chunks = (n + 15) / 16; + { + nb::gil_scoped_release release; + tbb::parallel_for(size_t(0), num_chunks, [&](size_t chunk) { + const size_t offset = chunk * 16; + const size_t count = std::min(n - offset, size_t(16)); + + auto query_batch = [&](auto point_tag, size_t N) { + using PointNf = decltype(point_tag); + PointNf pts; + for (size_t i = 0; i < count; ++i) { + pts.row(i) = get_row(query_points, offset + i).transpose(); + } + for (size_t i = count; i < N; ++i) { + pts.row(i).setZero(); + } + if constexpr (PointNf::RowsAtCompileTime == 4) { + return caster.closest_point4(pts, count); + } else if constexpr (PointNf::RowsAtCompileTime == 8) { + return caster.closest_point8(pts, count); + } else { + return caster.closest_point16(pts, count); + } + }; + + if (count == 1) { + if (auto h = caster.closest_point(get_row(query_points, offset))) + out.set(offset, *h); + } else if (count <= 4) { + auto hits = query_batch(raycasting::RayCaster::Point4f{}, 4); + for (size_t i = 0; i < count; ++i) + if (auto h = to_hit(hits, i)) out.set(offset + i, *h); + } else if (count <= 8) { + auto hits = query_batch(raycasting::RayCaster::Point8f{}, 8); + for (size_t i = 0; i < count; ++i) + if (auto h = to_hit(hits, i)) out.set(offset + i, *h); + } else { + auto hits = query_batch(raycasting::RayCaster::Point16f{}, 16); + for (size_t i = 0; i < count; ++i) + if (auto h = to_hit(hits, i)) out.set(offset + i, *h); + } + }); + } + + return out; + }, + "query_points"_a, + R"(Find the closest point on the scene for one or more query points. + +For a single query, pass a 1D array of shape (3,). +For batch queries, pass a 2D array of shape (N, 3) for any N >= 1. +The appropriate SIMD packet function (closest_point4, closest_point8, +closest_point16) is selected automatically based on batch size, and +batches are processed in parallel. For N > 16, queries are processed in +chunks of up to 16. + +:param query_points: Query point(s). Shape (3,) for a single point or (N, 3) for a batch. +:return: For a single query (1D input): a ``ClosestPointHit`` or ``None`` on a miss. + For a batch (2D input): a ``ClosestPointHits`` struct-of-arrays with one + entry per query; misses are flagged by the ``hit`` boolean mask.)") + + .def( + "occluded", + [](const raycasting::RayCaster& caster, + FloatArray origins, + FloatArray directions, + FloatParam tmin_param, + FloatParam tmax_param) -> std::variant { + if (origins.ndim() != directions.ndim()) { + throw std::invalid_argument( + "origins and directions must have the same number of dimensions " + "(both (3,) or both (N, 3))"); + } + size_t n_origins = get_num_points(origins, "origins"); + size_t n_directions = get_num_points(directions, "directions"); + if (n_origins != n_directions) { + throw std::invalid_argument( + "origins and directions must have the same number of rays"); + } + size_t n = n_origins; + + // Backing storage for broadcast (None/scalar) tmin/tmax maps; + // must outlive the maps and the parallel region below. + float tmin_scratch = 0.0f, tmax_scratch = 0.0f; + const auto tmin_vals = view_float_param(tmin_param, n, 0.0f, "tmin", tmin_scratch); + const auto tmax_vals = view_float_param( + tmax_param, + n, + std::numeric_limits::infinity(), + "tmax", + tmax_scratch); + + // Return type follows input dimensionality: + // - 1D (3,) -> single bool + // - 2D (N,3) -> list of bool, one per ray (even for N == 1). + if (origins.ndim() == 1) { + return caster.occluded( + get_row(origins, 0), + get_row(directions, 0), + tmin_vals[0], + tmax_vals[0]); + } + + // 0/1 byte per ray; std::vector is unsafe for concurrent writes. + std::vector results(n, 0); + + // Process rays in parallel over chunks of up to 16, picking the packet + // width (16, 8, 4, or a single-ray query) that best fits each chunk. The + // GIL is released for the parallel region (pure C++, no Python access); + // the result is wrapped in a numpy array afterwards. + const size_t num_chunks = (n + 15) / 16; + { + nb::gil_scoped_release release; + tbb::parallel_for(size_t(0), num_chunks, [&](size_t chunk) { + const size_t offset = chunk * 16; + const size_t count = std::min(n - offset, size_t(16)); + + auto occluded_batch = [&](auto point_tag, auto float_tag, size_t N) { + using PointNf = decltype(point_tag); + using FloatN = decltype(float_tag); + PointNf orig, dir; + FloatN tmin_v, tmax_v; + for (size_t i = 0; i < count; ++i) { + orig.row(i) = get_row(origins, offset + i).transpose(); + dir.row(i) = get_row(directions, offset + i).transpose(); + tmin_v[i] = tmin_vals[offset + i]; + tmax_v[i] = tmax_vals[offset + i]; + } + for (size_t i = count; i < N; ++i) { + orig.row(i).setZero(); + dir.row(i).setZero(); + tmin_v[i] = 0.0f; + tmax_v[i] = std::numeric_limits::infinity(); + } + if constexpr (PointNf::RowsAtCompileTime == 4) { + return caster.occluded4(orig, dir, count, tmin_v, tmax_v); + } else if constexpr (PointNf::RowsAtCompileTime == 8) { + return caster.occluded8(orig, dir, count, tmin_v, tmax_v); + } else { + return caster.occluded16(orig, dir, count, tmin_v, tmax_v); + } + }; + + if (count == 1) { + results[offset] = caster.occluded( + get_row(origins, offset), + get_row(directions, offset), + tmin_vals[offset], + tmax_vals[offset]) + ? 1u + : 0u; + return; + } + + uint32_t mask = 0; + if (count <= 4) { + mask = occluded_batch( + raycasting::RayCaster::Point4f{}, + raycasting::RayCaster::Float4{}, + 4); + } else if (count <= 8) { + mask = occluded_batch( + raycasting::RayCaster::Point8f{}, + raycasting::RayCaster::Float8{}, + 8); + } else { + mask = occluded_batch( + raycasting::RayCaster::Point16f{}, + raycasting::RayCaster::Float16{}, + 16); + } + for (size_t i = 0; i < count; ++i) { + results[offset + i] = (mask & (1u << i)) != 0 ? 1u : 0u; + } + }); + } + + // Hand ownership of the buffer to a capsule so the returned numpy + // array can view it directly (no copy, no per-ray Python objects). + auto* buffer = new std::vector(std::move(results)); + nb::capsule owner(buffer, [](void* p) noexcept { + delete static_cast*>(p); + }); + return BoolArray(reinterpret_cast(buffer->data()), {n}, owner); + }, + "origins"_a, + "directions"_a, + "tmin"_a = nb::none(), + "tmax"_a = nb::none(), + R"(Test whether one or more rays are occluded (hit anything). + +For a single ray, pass 1D arrays of shape (3,) for origin and direction. +For batch queries, pass 2D arrays of shape (N, 3) for any N >= 1. +The appropriate SIMD packet function (occluded4, occluded8, occluded16) is +selected automatically based on batch size, and batches are processed in +parallel. For N > 16, rays are processed in chunks of up to 16. + +``origins`` and ``directions`` must have matching dimensionality (both +(3,) or both (N, 3)); mixing the two raises ``ValueError``. + +:param origins: Ray origin(s). Shape (3,) for a single ray or (N, 3) for a batch. +:param directions: Ray direction(s). Shape (3,) for a single ray or (N, 3) for a batch. +:param tmin: Minimum parametric distance(s). Scalar or array of shape (N,). If None, default is 0. +:param tmax: Maximum parametric distance(s). Scalar or array of shape (N,). If None, default is inf. +:return: For a single ray (1D input): a ``bool``. + For a batch (2D input): a numpy ``bool`` array of shape (N,), one per ray.)"); // ========================================================================= // project_attributes (combined convenience function) @@ -362,7 +1117,7 @@ The number and order of vertices must not change. std::vector attribute_ids, bool project_vertices, raycasting::ProjectMode project_mode, - nb::object direction, + DirectionParam direction, raycasting::CastMode cast_mode, raycasting::FallbackMode fallback_mode, double default_value, @@ -371,13 +1126,16 @@ The number and order of vertices must not change. opts.attribute_ids = std::move(attribute_ids); opts.project_vertices = project_vertices; opts.project_mode = project_mode; - if (direction.is_none()) { - opts.direction = std::monostate{}; - } else if (nb::isinstance(direction)) { - opts.direction = nb::cast(direction); - } else { - opts.direction = nb::cast(direction); - } + std::visit( + [&](const auto& v) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + opts.direction = std::monostate{}; + } else { + opts.direction = v; + } + }, + direction); opts.cast_mode = cast_mode; opts.fallback_mode = fallback_mode; opts.default_value = default_value; @@ -418,7 +1176,7 @@ The number and order of vertices must not change. MeshType& target, std::vector attribute_ids, bool project_vertices, - nb::object direction, + DirectionParam direction, raycasting::CastMode cast_mode, raycasting::FallbackMode fallback_mode, double default_value, @@ -426,13 +1184,16 @@ The number and order of vertices must not change. raycasting::ProjectDirectionalOptions opts; opts.attribute_ids = std::move(attribute_ids); opts.project_vertices = project_vertices; - if (direction.is_none()) { - opts.direction = std::monostate{}; - } else if (nb::isinstance(direction)) { - opts.direction = nb::cast(direction); - } else { - opts.direction = nb::cast(direction); - } + std::visit( + [&](const auto& v) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + opts.direction = std::monostate{}; + } else { + opts.direction = v; + } + }, + direction); opts.cast_mode = cast_mode; opts.fallback_mode = fallback_mode; opts.default_value = default_value; @@ -531,9 +1292,10 @@ that source vertex (no interpolation). // ========================================================================= // NumPy-array overloads // - // Instead of a SurfaceMesh target, accept an (N,3) NumPy array of query points (and optionally - // directions). A temporary SurfaceMesh is created by wrapping the input buffer via - // wrap_as_vertices (zero-copy). The input buffer is modified in place and returned by value. + // Instead of a SurfaceMesh target, accept an (N,3) NumPy array of query points (and + // optionally directions). A temporary SurfaceMesh is created by wrapping the input buffer + // via wrap_as_vertices (zero-copy). The input buffer is modified in place and returned by + // value. // ========================================================================= // ----- project_closest_point (NumPy overload) ---------------------------- diff --git a/modules/raycasting/python/tests/test_raycasting.py b/modules/raycasting/python/tests/test_raycasting.py index f96539eb..52e1ffc8 100644 --- a/modules/raycasting/python/tests/test_raycasting.py +++ b/modules/raycasting/python/tests/test_raycasting.py @@ -493,6 +493,291 @@ def test_raycasting_mode(self, unit_cube): np.testing.assert_allclose(result[0, 2], 1.0, atol=1e-5) +# --------------------------------------------------------------------------- +# RayCaster.cast +# --------------------------------------------------------------------------- + + +class TestCast: + def _make_caster(self, mesh): + rc = lagrange.raycasting.RayCaster() + rc.add_mesh(mesh) + rc.commit_updates() + return rc + + def test_single_ray_hit(self, unit_cube): + rc = self._make_caster(unit_cube) + origin = np.array([0.5, 0.5, -1.0], dtype=np.float32) + direction = np.array([0.0, 0.0, 1.0], dtype=np.float32) + hit = rc.cast(origin, direction) + assert hit is not None + assert isinstance(hit, lagrange.raycasting.RayHit) + np.testing.assert_allclose(hit.position[2], 0.0, atol=1e-5) + assert hit.ray_depth == pytest.approx(1.0, abs=1e-5) + assert hit.mesh_index == 0 + + def test_single_ray_miss(self, unit_cube): + rc = self._make_caster(unit_cube) + origin = np.array([10.0, 10.0, -1.0], dtype=np.float32) + direction = np.array([0.0, 0.0, 1.0], dtype=np.float32) + hit = rc.cast(origin, direction) + assert hit is None + + def test_batch_rays(self, unit_cube): + rc = self._make_caster(unit_cube) + origins = np.array( + [[0.5, 0.5, -1.0], [10.0, 10.0, -1.0], [0.5, 0.5, 2.0]], + dtype=np.float32, + ) + directions = np.array( + [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]], + dtype=np.float32, + ) + results = rc.cast(origins, directions) + assert isinstance(results, lagrange.raycasting.RayHits) + assert results.hit.shape == (3,) + assert results.hit.dtype == bool + assert results.hit[0] + assert not results.hit[1] + assert results.hit[2] + np.testing.assert_allclose(results.position[2, 2], 1.0, atol=1e-5) + + def test_batch_larger_than_16(self, unit_cube): + """Ensure batches > 16 are processed correctly in chunks.""" + rc = self._make_caster(unit_cube) + n = 20 + origins = np.tile(np.array([0.5, 0.5, -1.0], dtype=np.float32), (n, 1)) + directions = np.tile(np.array([0.0, 0.0, 1.0], dtype=np.float32), (n, 1)) + results = rc.cast(origins, directions) + assert results.hit.shape == (n,) + assert np.all(results.hit) + assert results.position.shape == (n, 3) + + def test_2d_single_row_returns_soa(self, unit_cube): + """A 2D (1, 3) input is a batch query and returns a length-1 RayHits.""" + rc = self._make_caster(unit_cube) + origins = np.array([[0.5, 0.5, -1.0]], dtype=np.float32) + directions = np.array([[0.0, 0.0, 1.0]], dtype=np.float32) + results = rc.cast(origins, directions) + assert isinstance(results, lagrange.raycasting.RayHits) + assert results.hit.shape == (1,) + assert results.hit[0] + # 1D (3,) input of the same ray returns a scalar RayHit, not a struct. + single = rc.cast(origins[0], directions[0]) + assert isinstance(single, lagrange.raycasting.RayHit) + + def test_mixed_dim_raises(self, unit_cube): + """Mixing 1D and 2D origins/directions is rejected.""" + rc = self._make_caster(unit_cube) + with pytest.raises((ValueError, RuntimeError)): + rc.cast( + np.array([[0.5, 0.5, -1.0]], dtype=np.float32), + np.array([0.0, 0.0, 1.0], dtype=np.float32), + ) + + def test_tmin_tmax_scalar(self, unit_cube): + rc = self._make_caster(unit_cube) + origin = np.array([0.5, 0.5, -1.0], dtype=np.float32) + direction = np.array([0.0, 0.0, 1.0], dtype=np.float32) + # tmax too short to reach the cube + hit = rc.cast(origin, direction, tmax=0.5) + assert hit is None + # tmin past the near face, should hit the far face + hit = rc.cast(origin, direction, tmin=1.5) + assert hit is not None + np.testing.assert_allclose(hit.position[2], 1.0, atol=1e-5) + + def test_tmin_tmax_array(self, unit_cube): + rc = self._make_caster(unit_cube) + origins = np.array([[0.5, 0.5, -1.0], [0.5, 0.5, -1.0]], dtype=np.float32) + directions = np.array([[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], dtype=np.float32) + tmax = np.array([0.5, 10.0], dtype=np.float32) + results = rc.cast(origins, directions, tmax=tmax) + assert not results.hit[0] # too short + assert results.hit[1] # reaches cube + + def test_rayhits_soa_fields(self, unit_cube): + """Batch cast returns a RayHits struct-of-arrays with the expected shapes/dtypes.""" + rc = self._make_caster(unit_cube) + origins = np.array([[0.5, 0.5, -1.0], [0.5, 0.5, 2.0]], dtype=np.float32) + directions = np.array([[0.0, 0.0, 1.0], [0.0, 0.0, -1.0]], dtype=np.float32) + r = rc.cast(origins, directions) + assert r.hit.shape == (2,) and r.hit.dtype == bool + assert r.mesh_index.shape == (2,) and r.mesh_index.dtype == np.uint32 + assert r.instance_index.shape == (2,) + assert r.facet_index.shape == (2,) + assert r.barycentric_coord.shape == (2, 2) + assert r.position.shape == (2, 3) and r.position.dtype == np.float32 + assert r.ray_depth.shape == (2,) + assert r.normal.shape == (2, 3) + + def test_rayhit_fields(self, unit_cube): + rc = self._make_caster(unit_cube) + origin = np.array([0.5, 0.5, -1.0], dtype=np.float32) + direction = np.array([0.0, 0.0, 1.0], dtype=np.float32) + hit = rc.cast(origin, direction) + assert hit is not None + assert hasattr(hit, "mesh_index") + assert hasattr(hit, "instance_index") + assert hasattr(hit, "facet_index") + assert hasattr(hit, "barycentric_coord") + assert hasattr(hit, "position") + assert hasattr(hit, "ray_depth") + assert hasattr(hit, "normal") + + def test_invalid_shape_raises(self, unit_cube): + rc = self._make_caster(unit_cube) + with pytest.raises((ValueError, RuntimeError)): + rc.cast( + np.array([1.0, 2.0], dtype=np.float32), + np.array([0.0, 0.0, 1.0], dtype=np.float32), + ) + + +# --------------------------------------------------------------------------- +# RayCaster.closest_point +# --------------------------------------------------------------------------- + + +class TestClosestPoint: + def _make_caster(self, mesh): + rc = lagrange.raycasting.RayCaster() + rc.add_mesh(mesh) + rc.commit_updates() + return rc + + def test_single_query(self, unit_cube): + rc = self._make_caster(unit_cube) + query = np.array([0.5, 0.5, -0.5], dtype=np.float32) + hit = rc.closest_point(query) + assert hit is not None + assert isinstance(hit, lagrange.raycasting.ClosestPointHit) + np.testing.assert_allclose(hit.position[2], 0.0, atol=1e-5) + assert hit.distance == pytest.approx(0.5, abs=1e-5) + + def test_batch_query(self, unit_cube): + rc = self._make_caster(unit_cube) + queries = np.array( + [[0.5, 0.5, -0.5], [0.5, 0.5, 1.5], [0.5, 0.5, 0.5]], + dtype=np.float32, + ) + results = rc.closest_point(queries) + assert isinstance(results, lagrange.raycasting.ClosestPointHits) + assert results.hit.shape == (3,) + assert np.all(results.hit) + # point below cube → snaps to z=0 face + np.testing.assert_allclose(results.position[0, 2], 0.0, atol=1e-5) + # point above cube → snaps to z=1 face + np.testing.assert_allclose(results.position[1, 2], 1.0, atol=1e-5) + + def test_batch_larger_than_16(self, unit_cube): + rc = self._make_caster(unit_cube) + n = 20 + queries = np.tile(np.array([0.5, 0.5, -0.5], dtype=np.float32), (n, 1)) + results = rc.closest_point(queries) + assert results.hit.shape == (n,) + assert np.all(results.hit) + + def test_2d_single_row_returns_soa(self, unit_cube): + """A 2D (1, 3) input is a batch query and returns a length-1 ClosestPointHits.""" + rc = self._make_caster(unit_cube) + queries = np.array([[0.5, 0.5, -0.5]], dtype=np.float32) + results = rc.closest_point(queries) + assert isinstance(results, lagrange.raycasting.ClosestPointHits) + assert results.hit.shape == (1,) + assert results.hit[0] + # 1D (3,) input returns a scalar ClosestPointHit, not a struct. + single = rc.closest_point(queries[0]) + assert isinstance(single, lagrange.raycasting.ClosestPointHit) + + def test_closestpointhit_fields(self, unit_cube): + rc = self._make_caster(unit_cube) + hit = rc.closest_point(np.array([0.5, 0.5, -1.0], dtype=np.float32)) + assert hit is not None + assert hasattr(hit, "mesh_index") + assert hasattr(hit, "instance_index") + assert hasattr(hit, "facet_index") + assert hasattr(hit, "barycentric_coord") + assert hasattr(hit, "position") + assert hasattr(hit, "distance") + + +# --------------------------------------------------------------------------- +# RayCaster.occluded +# --------------------------------------------------------------------------- + + +class TestOccluded: + def _make_caster(self, mesh): + rc = lagrange.raycasting.RayCaster() + rc.add_mesh(mesh) + rc.commit_updates() + return rc + + def test_single_ray_occluded(self, unit_cube): + rc = self._make_caster(unit_cube) + origin = np.array([0.5, 0.5, -1.0], dtype=np.float32) + direction = np.array([0.0, 0.0, 1.0], dtype=np.float32) + result = rc.occluded(origin, direction) + assert result is True + + def test_single_ray_not_occluded(self, unit_cube): + rc = self._make_caster(unit_cube) + origin = np.array([10.0, 10.0, -1.0], dtype=np.float32) + direction = np.array([0.0, 0.0, 1.0], dtype=np.float32) + result = rc.occluded(origin, direction) + assert result is False + + def test_batch_rays(self, unit_cube): + rc = self._make_caster(unit_cube) + origins = np.array( + [[0.5, 0.5, -1.0], [10.0, 10.0, -1.0], [0.5, 0.5, 2.0]], + dtype=np.float32, + ) + directions = np.array( + [[0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]], + dtype=np.float32, + ) + results = rc.occluded(origins, directions) + assert isinstance(results, np.ndarray) + assert results.dtype == bool + assert results.shape == (3,) + assert results[0] + assert not results[1] + assert results[2] + + def test_batch_larger_than_16(self, unit_cube): + rc = self._make_caster(unit_cube) + n = 20 + origins = np.tile(np.array([0.5, 0.5, -1.0], dtype=np.float32), (n, 1)) + directions = np.tile(np.array([0.0, 0.0, 1.0], dtype=np.float32), (n, 1)) + results = rc.occluded(origins, directions) + assert results.shape == (n,) + assert np.all(results) + + def test_2d_single_row_returns_array(self, unit_cube): + """A 2D (1, 3) input is a batch query and returns a length-1 bool array.""" + rc = self._make_caster(unit_cube) + origins = np.array([[0.5, 0.5, -1.0]], dtype=np.float32) + directions = np.array([[0.0, 0.0, 1.0]], dtype=np.float32) + results = rc.occluded(origins, directions) + assert isinstance(results, np.ndarray) + assert results.shape == (1,) + assert results[0] + # 1D (3,) input returns a scalar bool, not an array. + single = rc.occluded(origins[0], directions[0]) + assert single is True + + def test_tmin_tmax(self, unit_cube): + rc = self._make_caster(unit_cube) + origin = np.array([0.5, 0.5, -1.0], dtype=np.float32) + direction = np.array([0.0, 0.0, 1.0], dtype=np.float32) + # tmax too short + assert rc.occluded(origin, direction, tmax=0.5) is False + # normal range + assert rc.occluded(origin, direction) is True + + # --------------------------------------------------------------------------- # compute_local_feature_size # --------------------------------------------------------------------------- @@ -629,4 +914,4 @@ def test_keyword_only_arguments(self, unit_cube): # This should fail because we're passing positional args after mesh with pytest.raises(TypeError): - lagrange.raycasting.compute_local_feature_size(unit_cube, 1e-4) + lagrange.raycasting.compute_local_feature_size(unit_cube, 1e-4) # ty: ignore[too-many-positional-arguments] diff --git a/modules/raycasting/python/tests/test_remove_occluded.py b/modules/raycasting/python/tests/test_remove_occluded.py index ed564b54..a5172edd 100644 --- a/modules/raycasting/python/tests/test_remove_occluded.py +++ b/modules/raycasting/python/tests/test_remove_occluded.py @@ -48,7 +48,7 @@ def test_keyword_only_arguments(self, cube_triangular): scene = lagrange.scene.mesh_to_simple_scene(cube_triangular) lagrange.raycasting.remove_occluded_facets(scene, num_rays=10000) with pytest.raises(TypeError): - lagrange.raycasting.remove_occluded_facets(scene, 10000) + lagrange.raycasting.remove_occluded_facets(scene, 10000) # ty: ignore[too-many-positional-arguments] # --------------------------------------------------------------------------- diff --git a/modules/raycasting/raycasting.md b/modules/raycasting/raycasting.md index 41a64c43..507cbcae 100644 --- a/modules/raycasting/raycasting.md +++ b/modules/raycasting/raycasting.md @@ -7,13 +7,14 @@ Raycasting Module @defgroup module-raycasting Raycasting Module @brief Raycasting operations. -### Quick links +Quick links +----------- -- [create_ray_caster](@ref lagrange::raycasting::create_ray_caster) - [RayCaster](@ref lagrange::raycasting::RayCaster) - - [EmbreeRayCaster](@ref lagrange::raycasting::EmbreeRayCaster) -- [project_attributes](@ref lagrange::raycasting::project_attributes) - - [project_attributes_closest_point](@ref lagrange::raycasting::project_attributes_closest_point) - - [project_attributes_directional](@ref lagrange::raycasting::project_attributes_directional) - - [project_attributes_closest_vertex](@ref lagrange::bvh::project_attributes_closest_vertex) +- [project](@ref lagrange::raycasting::project) + - [project_closest_point](@ref lagrange::raycasting::project_closest_point) + - [project_closest_vertex](@ref lagrange::raycasting::project_closest_vertex) + - [project_directional](@ref lagrange::raycasting::project_directional) - [compute_local_feature_size](@ref lagrange::raycasting::compute_local_feature_size) +- [remove_occluded_facets](@ref lagrange::raycasting::remove_occluded_facets) +- [remove_occluded_instances](@ref lagrange::raycasting::remove_occluded_instances) diff --git a/modules/raycasting/src/RayCaster.cpp b/modules/raycasting/src/RayCaster.cpp index efde716b..fef2f2c9 100644 --- a/modules/raycasting/src/RayCaster.cpp +++ b/modules/raycasting/src/RayCaster.cpp @@ -1651,6 +1651,8 @@ uint32_t RayCaster::occluded16( // OBB overlap query // ============================================================================ +/// @cond LA_INTERNAL_DOCS + void RayCaster::overlap_obb_internal( const OrientedBox& obb, function_ref callback) const @@ -1763,6 +1765,8 @@ void RayCaster::overlap_obb16_internal( check_errors_debug(m_impl->m_device); } +/// @endcond + // ============================================================================ // Explicit template instantiation // ============================================================================ diff --git a/modules/raycasting/src/remove_occluded_facets.cpp b/modules/raycasting/src/remove_occluded_facets.cpp index 64c74565..266348e1 100644 --- a/modules/raycasting/src/remove_occluded_facets.cpp +++ b/modules/raycasting/src/remove_occluded_facets.cpp @@ -245,6 +245,7 @@ struct OccludedFacetSampler::Impl } }; +/// @cond LA_INTERNAL_DOCS template OccludedFacetSampler::OccludedFacetSampler( const scene::SimpleScene& scene, @@ -330,6 +331,7 @@ OccludedFacetSampler::OccludedFacetSampler( m_impl->m_ray_caster.add_scene(std::move(occluder_scene)); m_impl->m_ray_caster.commit_updates(); } +/// @endcond template OccludedFacetSampler::~OccludedFacetSampler() = default; diff --git a/modules/raycasting/src/remove_occluded_instances.cpp b/modules/raycasting/src/remove_occluded_instances.cpp index 07f381fa..9503af20 100644 --- a/modules/raycasting/src/remove_occluded_instances.cpp +++ b/modules/raycasting/src/remove_occluded_instances.cpp @@ -130,6 +130,7 @@ struct OccludedInstanceSampler::Impl void end_batch() {} }; +/// @cond LA_INTERNAL_DOCS template OccludedInstanceSampler::OccludedInstanceSampler( const scene::SimpleScene& scene, @@ -182,6 +183,7 @@ OccludedInstanceSampler::OccludedInstanceSampler( m_impl->m_ray_caster.add_scene(std::move(occluder_scene)); m_impl->m_ray_caster.commit_updates(); } +/// @endcond template OccludedInstanceSampler::~OccludedInstanceSampler() = default; diff --git a/modules/scene/python/scripts/extract_texture.py b/modules/scene/python/scripts/extract_texture.py index 3f6365ac..80690289 100755 --- a/modules/scene/python/scripts/extract_texture.py +++ b/modules/scene/python/scripts/extract_texture.py @@ -54,10 +54,12 @@ def main(): assert instance.mesh != lagrange.invalid_index for mat_id in instance.materials: mat = scene.materials[mat_id] - if mat.base_color_texture.index != lagrange.invalid_index: - tex = scene.textures[mat.base_color_texture.index] - assert tex.image != lagrange.invalid_index - img = scene.images[tex.image] + texture_index = mat.base_color_texture.index + if texture_index is not None and texture_index != lagrange.invalid_index: + tex = scene.textures[texture_index] + image_index = tex.image + assert image_index is not None and image_index != lagrange.invalid_index + img = scene.images[image_index] if len(img.image.data) != 0: texture_filename = output_filename.with_suffix(".png").with_stem( f"{basename}_{texture_count:03}" diff --git a/modules/scene/python/src/bind_scene.h b/modules/scene/python/src/bind_scene.h index 588986c1..5d325110 100644 --- a/modules/scene/python/src/bind_scene.h +++ b/modules/scene/python/src/bind_scene.h @@ -86,7 +86,8 @@ void bind_scene(nb::module_& m) &SceneMeshInstance::materials, "Material indices in the scene.materials vector. This is typically a single material " "index. When a single mesh uses multiple materials, the AttributeName::material_id " - "facet attribute should be defined."); + "facet attribute should be defined.", + LA_SAFE_VECTOR_SETTER("materials", "ElementIdList | collections.abc.Sequence[int]")); nb::class_(m, "Node", "Represents a node in the scene hierarchy") .def(nb::init<>()) @@ -110,7 +111,8 @@ void bind_scene(nb::module_& m) } } }, - "Transform of the node, relative to its parent") + "Transform of the node, relative to its parent", + nb::for_setter(nb::sig("def transform(self, arg: numpy.typing.ArrayLike, /) -> None"))) .def_prop_rw( "parent", [](Node& node) -> std::optional { @@ -122,7 +124,11 @@ void bind_scene(nb::module_& m) [](Node& node, ElementId parent) { node.parent = parent; }, "Parent index. May be invalid if the node has no parent (e.g. the root)") .def_rw("children", &Node::children, "Children indices. May be empty") - .def_rw("meshes", &Node::meshes, "List of meshes contained in this node") + .def_rw( + "meshes", + &Node::meshes, + "List of meshes contained in this node", + LA_SAFE_VECTOR_SETTER("meshes", "collections.abc.Sequence[SceneMeshInstance]")) .def_rw("cameras", &Node::cameras, "List of cameras contained in this node") .def_rw("lights", &Node::lights, "List of lights contained in this node") .def_rw("extensions", &Node::extensions); @@ -266,7 +272,9 @@ void bind_scene(nb::module_& m) self.data.data()); }, "Raw buffer of size (width * height * num_channels * num_bits_per_element / 8) bytes " - "containing image data") + "containing image data", + nb::for_getter( + nb::sig("def data(self) -> Annotated[NDArray, dict(order='C', device='cpu')]"))) .def_prop_ro( "dtype", [](ImageBufferExperimental& self) -> std::optional { @@ -611,7 +619,11 @@ void bind_scene(nb::module_& m) "root_nodes", &SceneType::root_nodes, "Root nodes. This is typically one. Must be at least one") - .def_rw("meshes", &SceneType::meshes, "Scene meshes") + .def_rw( + "meshes", + &SceneType::meshes, + "Scene meshes", + LA_SAFE_VECTOR_SETTER("meshes", "collections.abc.Sequence[lagrange.core.SurfaceMesh]")) .def_rw("images", &SceneType::images, "Images") .def_rw("textures", &SceneType::textures, "Textures. They can reference images") .def_rw("materials", &SceneType::materials, "Materials. They can reference textures") @@ -699,63 +711,82 @@ void bind_scene(nb::module_& m) [](const SceneType& scene, bool normalize_normals, bool normalize_tangents_bitangents, + bool reorient, bool preserve_attributes) { TransformOptions transform_options; transform_options.normalize_normals = normalize_normals; transform_options.normalize_tangents_bitangents = normalize_tangents_bitangents; + transform_options.reorient = reorient; return scene::scene_to_mesh(scene, transform_options, preserve_attributes); }, "scene"_a, + nb::kw_only(), "normalize_normals"_a = TransformOptions{}.normalize_normals, "normalize_tangents_bitangents"_a = TransformOptions{}.normalize_tangents_bitangents, + "reorient"_a = TransformOptions{}.reorient, "preserve_attributes"_a = true, R"(Converts a scene into a concatenated mesh with all the transforms applied. :param scene: Scene to convert. :param normalize_normals: If enabled, normals are normalized after transformation. :param normalize_tangents_bitangents: If enabled, tangents and bitangents are normalized after transformation. +:param reorient: If enabled, flip facets and reorient attributes for instances with a negative-determinant transform. :param preserve_attributes: Preserve shared attributes and map them to the output mesh. :return: Concatenated mesh.)"); m.def( "scene_to_meshes", - [](const SceneType& scene, bool normalize_normals, bool normalize_tangents_bitangents) { + [](const SceneType& scene, + bool normalize_normals, + bool normalize_tangents_bitangents, + bool reorient) { TransformOptions transform_options; transform_options.normalize_normals = normalize_normals; transform_options.normalize_tangents_bitangents = normalize_tangents_bitangents; + transform_options.reorient = reorient; return scene::scene_to_meshes(scene, transform_options); }, "scene"_a, + nb::kw_only(), "normalize_normals"_a = TransformOptions{}.normalize_normals, "normalize_tangents_bitangents"_a = TransformOptions{}.normalize_tangents_bitangents, + "reorient"_a = TransformOptions{}.reorient, R"(Converts a scene into a list of meshes with all the transforms applied. :param scene: Scene to convert. :param normalize_normals: If enabled, normals are normalized after transformation. :param normalize_tangents_bitangents: If enabled, tangents and bitangents are normalized after transformation. +:param reorient: If enabled, flip facets and reorient attributes for instances with a negative-determinant transform. :return: List of transformed meshes.)"); m.def( "scene_to_meshes_and_materials", - [](const SceneType& scene, bool normalize_normals, bool normalize_tangents_bitangents) + [](const SceneType& scene, + bool normalize_normals, + bool normalize_tangents_bitangents, + bool reorient) -> std::pair, std::vector>> { TransformOptions transform_options; transform_options.normalize_normals = normalize_normals; transform_options.normalize_tangents_bitangents = normalize_tangents_bitangents; + transform_options.reorient = reorient; auto [meshes, material_ids] = scene::scene_to_meshes_and_materials(scene, transform_options); return {std::move(meshes), std::move(material_ids)}; }, "scene"_a, + nb::kw_only(), "normalize_normals"_a = TransformOptions{}.normalize_normals, "normalize_tangents_bitangents"_a = TransformOptions{}.normalize_tangents_bitangents, + "reorient"_a = TransformOptions{}.reorient, R"(Converts a scene into a list of meshes with all the transforms applied and a list of material IDs. :param scene: Scene to convert. :param normalize_normals: If enabled, normals are normalized after transformation. :param normalize_tangents_bitangents: If enabled, tangents and bitangents are normalized after transformation. +:param reorient: If enabled, flip facets and reorient attributes for instances with a negative-determinant transform. :return: List of meshes with transforms applied and a list of material IDs.)"); diff --git a/modules/scene/python/src/bind_simple_scene.h b/modules/scene/python/src/bind_simple_scene.h index 9fac01c1..cda07f4f 100644 --- a/modules/scene/python/src/bind_simple_scene.h +++ b/modules/scene/python/src/bind_simple_scene.h @@ -88,7 +88,8 @@ void bind_simple_scene(nb::module_& m) R"(4x4 transformation matrix for this instance. The transformation matrix is stored in column-major order. Both row-major and column-major -input tensors are supported for setting the transform.)"); +input tensors are supported for setting the transform.)", + nb::for_setter(nb::sig("def transform(self, arg: numpy.typing.ArrayLike, /) -> None"))); using SimpleScene3D = lagrange::scene::SimpleScene; nb::class_(m, "SimpleScene3D", "Simple scene container for instanced meshes") @@ -178,41 +179,53 @@ input tensors are supported for setting the transform.)"); [](const SimpleScene3D& scene, bool normalize_normals, bool normalize_tangents_bitangents, + bool reorient, bool preserve_attributes) { TransformOptions transform_options; transform_options.normalize_normals = normalize_normals; transform_options.normalize_tangents_bitangents = normalize_tangents_bitangents; + transform_options.reorient = reorient; return scene::simple_scene_to_mesh(scene, transform_options, preserve_attributes); }, "scene"_a, + nb::kw_only(), "normalize_normals"_a = TransformOptions{}.normalize_normals, "normalize_tangents_bitangents"_a = TransformOptions{}.normalize_tangents_bitangents, + "reorient"_a = TransformOptions{}.reorient, "preserve_attributes"_a = true, R"(Converts a scene into a concatenated mesh with all the transforms applied. :param scene: Scene to convert. :param normalize_normals: If enabled, normals are normalized after transformation. :param normalize_tangents_bitangents: If enabled, tangents and bitangents are normalized after transformation. +:param reorient: If enabled, flip facets and reorient attributes for instances with a negative-determinant transform. :param preserve_attributes: Preserve shared attributes and map them to the output mesh. :return: Concatenated mesh.)"); m.def( "simple_scene_to_meshes", - [](const SimpleScene3D& scene, bool normalize_normals, bool normalize_tangents_bitangents) { + [](const SimpleScene3D& scene, + bool normalize_normals, + bool normalize_tangents_bitangents, + bool reorient) { TransformOptions transform_options; transform_options.normalize_normals = normalize_normals; transform_options.normalize_tangents_bitangents = normalize_tangents_bitangents; + transform_options.reorient = reorient; return scene::simple_scene_to_meshes(scene, transform_options); }, "scene"_a, + nb::kw_only(), "normalize_normals"_a = TransformOptions{}.normalize_normals, "normalize_tangents_bitangents"_a = TransformOptions{}.normalize_tangents_bitangents, + "reorient"_a = TransformOptions{}.reorient, R"(Converts a scene into a list of meshes with all the transforms applied. :param scene: Scene to convert. :param normalize_normals: If enabled, normals are normalized after transformation. :param normalize_tangents_bitangents: If enabled, tangents and bitangents are normalized after transformation. +:param reorient: If enabled, flip facets and reorient attributes for instances with a negative-determinant transform. :return: List of transformed meshes.)"); diff --git a/modules/scene/scene.md b/modules/scene/scene.md new file mode 100644 index 00000000..3bc4f70c --- /dev/null +++ b/modules/scene/scene.md @@ -0,0 +1,10 @@ +Scene Module +============ + +@namespace lagrange::scene + +@defgroup module-scene Scene Module +@brief Scene graph data structures. + +Scene and SimpleScene representations holding meshes, materials, lights, +cameras, and node hierarchies, with extension and casting utilities. diff --git a/modules/serialization2/include/lagrange/serialization/serialize_mesh.h b/modules/serialization2/include/lagrange/serialization/serialize_mesh.h index 332545ce..cbbe7be5 100644 --- a/modules/serialization2/include/lagrange/serialization/serialize_mesh.h +++ b/modules/serialization2/include/lagrange/serialization/serialize_mesh.h @@ -23,13 +23,12 @@ namespace lagrange::serialization { /// -/// @defgroup group-serialization2 Serialization -/// @ingroup module-serialization2 Binary serialization of SurfaceMesh, SimpleScene and Scene objects. +/// @addtogroup module-serialization2 +/// @{ /// /// The suggested file extension for serialized meshes is `.lgm`, and `.lgs` for serialized scenes /// (both SimpleScene and Scene). /// -/// @{ /// /// Current mesh serialization format version. diff --git a/modules/serialization2/include/lagrange/serialization/serialize_scene.h b/modules/serialization2/include/lagrange/serialization/serialize_scene.h index b552fe31..dc06b955 100644 --- a/modules/serialization2/include/lagrange/serialization/serialize_scene.h +++ b/modules/serialization2/include/lagrange/serialization/serialize_scene.h @@ -22,7 +22,7 @@ namespace lagrange::serialization { -/// @addtogroup group-serialization2 +/// @addtogroup module-serialization2 /// @{ /// diff --git a/modules/serialization2/include/lagrange/serialization/serialize_simple_scene.h b/modules/serialization2/include/lagrange/serialization/serialize_simple_scene.h index e8547ab2..0eb53bc1 100644 --- a/modules/serialization2/include/lagrange/serialization/serialize_simple_scene.h +++ b/modules/serialization2/include/lagrange/serialization/serialize_simple_scene.h @@ -22,7 +22,7 @@ namespace lagrange::serialization { -/// @addtogroup group-serialization2 +/// @addtogroup module-serialization2 /// @{ /// diff --git a/modules/serialization2/serialization2.md b/modules/serialization2/serialization2.md new file mode 100644 index 00000000..e10e8c01 --- /dev/null +++ b/modules/serialization2/serialization2.md @@ -0,0 +1,10 @@ +Serialization2 Module +============ + +@namespace lagrange::serialization + +@defgroup module-serialization2 Serialization2 Module +@brief Binary serialization of meshes and scenes. + +Serialize and deserialize SurfaceMesh, SimpleScene, and Scene objects to and +from a compact binary format. diff --git a/modules/solver/CMakeLists.txt b/modules/solver/CMakeLists.txt index f7a2dd56..8c6a65e1 100644 --- a/modules/solver/CMakeLists.txt +++ b/modules/solver/CMakeLists.txt @@ -21,13 +21,17 @@ if(NOT EMSCRIPTEN AND (NOT LAGRANGE_NO_INTERNAL OR NOT SKBUILD)) # Note: For now we avoid using MKL in our open-source Python bindings, to avoid bloating up the size of # the uploaded wheels. The long-term solution is to depend on the PyPI package for MKL at build-time. - include(blas) # Accelerate on macOS, MKL on other platforms - if(APPLE) - target_compile_definitions(lagrange_solver INTERFACE LA_SOLVER_ACCELERATE) - else() - target_compile_definitions(lagrange_solver INTERFACE LA_SOLVER_MKL) + # Intel MKL has no Windows ARM64 support; fall back to Eigen's SimplicialLDLT on that platform + # (DirectSolver.h uses SimplicialLDLT when LA_SOLVER_MKL is not defined). + if(NOT (WIN32 AND CMAKE_SYSTEM_PROCESSOR STREQUAL "ARM64")) + include(blas) # Accelerate on macOS, MKL on other platforms + if(APPLE) + target_compile_definitions(lagrange_solver INTERFACE LA_SOLVER_ACCELERATE) + else() + target_compile_definitions(lagrange_solver INTERFACE LA_SOLVER_MKL) + endif() + target_link_libraries(lagrange_solver INTERFACE BLAS::BLAS) endif() - target_link_libraries(lagrange_solver INTERFACE BLAS::BLAS) endif() if(USE_SANITIZER MATCHES "([Tt]hread)") diff --git a/modules/solver/solver.md b/modules/solver/solver.md new file mode 100644 index 00000000..73b9758f --- /dev/null +++ b/modules/solver/solver.md @@ -0,0 +1,9 @@ +Solver Module +============ + +@namespace lagrange::solver + +@defgroup module-solver Solver Module +@brief Linear system solvers. + +Direct and Eigen-based solvers for sparse linear systems. diff --git a/modules/subdivision/src/TopologyRefinerFactory.h b/modules/subdivision/src/TopologyRefinerFactory.h index b6dbd90c..e9e9298f 100644 --- a/modules/subdivision/src/TopologyRefinerFactory.h +++ b/modules/subdivision/src/TopologyRefinerFactory.h @@ -22,6 +22,8 @@ namespace OPENSUBDIV_VERSION { namespace Far { +/// @cond LA_INTERNAL_DOCS + // Specify the number of vertices, faces, face-vertices, etc. template <> bool TopologyRefinerFactory::resizeComponentTopology( @@ -235,6 +237,8 @@ void TopologyRefinerFactory::reportInvalidTopology( lagrange::logger().warn("[opensubdiv] {}", msg); } +/// @endcond + } // namespace Far } // namespace OPENSUBDIV_VERSION diff --git a/modules/texproc/examples/texture_processing_gui.cpp b/modules/texproc/examples/texture_processing_gui.cpp index 329f171b..537f2a15 100644 --- a/modules/texproc/examples/texture_processing_gui.cpp +++ b/modules/texproc/examples/texture_processing_gui.cpp @@ -40,6 +40,8 @@ #include // clang-format on +#include + #include using SurfaceMesh = lagrange::SurfaceMesh32d; diff --git a/modules/texproc/python/scripts/geodesic_dilation.py b/modules/texproc/python/scripts/geodesic_dilation.py index ac82bf53..4c90f628 100644 --- a/modules/texproc/python/scripts/geodesic_dilation.py +++ b/modules/texproc/python/scripts/geodesic_dilation.py @@ -88,11 +88,11 @@ def single_mesh_from_scene( ) texture_id = material.base_color_texture.index - assert texture_id < len(scene.textures) + assert texture_id is not None and texture_id < len(scene.textures) texture = scene.textures[texture_id] image_id = texture.image - assert image_id < len(scene.images) + assert image_id is not None and image_id < len(scene.images) image = scene.images[image_id].image.data return mesh, image diff --git a/modules/texproc/python/scripts/texture_from_multiview.py b/modules/texproc/python/scripts/texture_from_multiview.py index d0a72524..4d7c5aef 100644 --- a/modules/texproc/python/scripts/texture_from_multiview.py +++ b/modules/texproc/python/scripts/texture_from_multiview.py @@ -88,9 +88,7 @@ def append_cameras(scene: lagrange.scene.Scene, cameras: dict) -> lagrange.scene return scene -def split_multiview( - multiview: Image.Image, grid_shape: Tuple[int, int] -) -> List[ArrayNxNxK[np.float32]]: +def split_multiview(multiview: Image.Image, grid_shape: Tuple[int, int]) -> List[ArrayNxNxK]: """ Split the multiview image into individual render images based on camera data. """ diff --git a/modules/texproc/python/src/texproc.cpp b/modules/texproc/python/src/texproc.cpp index 5db2b7bb..21a5eb5d 100644 --- a/modules/texproc/python/src/texproc.cpp +++ b/modules/texproc/python/src/texproc.cpp @@ -74,7 +74,7 @@ void populate_texproc_module(nb::module_& m) tp::texture_filtering(mesh, image.to_mdspan(), options); - return image_array_to_tensor(image); + return image_array_to_tensor(std::move(image)); }, "mesh"_a, "image"_a, @@ -124,7 +124,7 @@ void populate_texproc_module(nb::module_& m) tp::texture_stitching(mesh, image.to_mdspan(), options); - return image_array_to_tensor(image); + return image_array_to_tensor(std::move(image)); }, "mesh"_a, "image"_a, @@ -162,7 +162,7 @@ void populate_texproc_module(nb::module_& m) tp::geodesic_dilation(mesh, image.to_mdspan(), options); - return image_array_to_tensor(image); + return image_array_to_tensor(std::move(image)); }, "mesh"_a, "image"_a, @@ -193,7 +193,7 @@ void populate_texproc_module(nb::module_& m) tp::geodesic_dilation(mesh, image.to_mdspan(), options); - return image_array_to_tensor(image); + return image_array_to_tensor(std::move(image)); }, "mesh"_a, "width"_a, @@ -248,7 +248,7 @@ void populate_texproc_module(nb::module_& m) auto image = tp::texture_compositing(mesh, weighted_textures, options); - return image_array_to_tensor(image); + return image_array_to_tensor(std::move(image)); }, "mesh"_a, "colors"_a, @@ -280,15 +280,13 @@ void populate_texproc_module(nb::module_& m) auto pack_textures_and_weights = [](std::vector>& textures_and_weights) { - std::vector textures; - std::vector weights; + std::vector> textures; + std::vector> weights; textures.reserve(textures_and_weights.size()); weights.reserve(textures_and_weights.size()); for (auto& [texture_, weight_] : textures_and_weights) { - auto texture = image_array_to_tensor(texture_); - auto weight = image_array_to_tensor(weight_); - textures.emplace_back(texture); - weights.emplace_back(weight); + textures.emplace_back(image_array_to_tensor(std::move(texture_))); + weights.emplace_back(image_array_to_tensor(std::move(weight_))); } return std::make_tuple(textures, weights); }; diff --git a/modules/texproc/python/tests/test_mesh_with_alpha_mask.py b/modules/texproc/python/tests/test_mesh_with_alpha_mask.py index 02babeb4..3c13d1f0 100644 --- a/modules/texproc/python/tests/test_mesh_with_alpha_mask.py +++ b/modules/texproc/python/tests/test_mesh_with_alpha_mask.py @@ -33,11 +33,15 @@ def load_alpha_data(scene_path: Path): assert material.alpha_mode == lagrange.scene.Material.AlphaMode.Blend assert material.alpha_cutoff >= 0.0 assert material.alpha_cutoff <= 1.0 - image = scene.images[material.base_color_texture.index].image.data + base_color_index = material.base_color_texture.index + assert base_color_index is not None + image = scene.images[base_color_index].image.data assert image.shape[2] == 4 # retrieve mesh - mesh = scene.meshes[instance.mesh] + mesh_index = instance.mesh + assert mesh_index is not None + mesh = scene.meshes[mesh_index] texcoord_id = mesh.get_attribute_id(f"texcoord_{material.base_color_texture.texcoord}") lagrange.cast_attribute(mesh, texcoord_id, np.float64) assert mesh.is_attribute_indexed(texcoord_id) diff --git a/modules/ui/README.md b/modules/ui/README.md deleted file mode 100644 index 213c52d1..00000000 --- a/modules/ui/README.md +++ /dev/null @@ -1,693 +0,0 @@ - -## Table of Contents -- [Table of Contents](#table-of-contents) -- [Overview](#overview) -- [Geometry loading and registration](#geometry-loading-and-registration) - - [Loading mesh](#loading-mesh) - - [Retrieving and interacting with the mesh](#retrieving-and-interacting-with-the-mesh) - - [Loading scene](#loading-scene) -- [Adding geometry to scene](#adding-geometry-to-scene) - - [Default Physically Based Render (PBR)](#default-physically-based-render-pbr) - - [Mesh visualizations](#mesh-visualizations) - - [`GlyphType::Surface`](#glyphtypesurface) - - [Colormaps](#colormaps) -- [Materials](#materials) - - [Color/Texture Material Properties](#colortexture-material-properties) - - [PBRMaterial](#pbrmaterial) - - [Rasterizer Properties](#rasterizer-properties) - - [Custom Shader Properties](#custom-shader-properties) -- [Common components](#common-components) - - [`Name`](#name) - - [`Transform`](#transform) - - [`Tree`](#tree) - - [`MeshGeometry`](#meshgeometry) - - [`Hovered` and `Selected`](#hovered-and-selected) - - [`Layer`](#layer) - - [`UIPanel`](#uipanel) - - [`Viewport`](#viewport) -- [User Interface Panels](#user-interface-panels) -- [Viewports](#viewports) - - [Entity visibility](#entity-visibility) - - [Multi viewport](#multi-viewport) -- [Entity Component System](#entity-component-system) - - [Registry](#registry) - - [Entity](#entity) - - [Components](#components) - - [Tag Components](#tag-components) - - [Systems](#systems) - - [Context variables](#context-variables) - - [Design considerations](#design-considerations) -- [Customizing Lagrange UI](#customizing-lagrange-ui) - - [Components](#components-1) - - [Tools](#tools) - - [Geometry](#geometry) - - [Rendering](#rendering) - - [Shader and Material properties](#shader-and-material-properties) -- [Examples](#examples) - - -## Overview - -Lagrange UI uses an **Entity-Component-System (ECS)** architecture: -- Entity is a unique identifier -- Components define data and behavior (but no logic) -- Systems define logic (but no data). - -See *[ECS implementation section](#entity-component-system)* for more information about ECS and how it's implemented in Lagrange UI. The underlying library for ECS is [`entt`](https://github.com/skypjack/entt). - - -Recommended namespace usage -```c++ -namespace ui = lagrange::ui; -``` - -The entry point to the library is the `Viewer` class. It instantiates a window and owns a `Registry` instance and `Systems` instance. `Registry` contains all the data (entities, components) and `Systems` contain all the behavior (sequence of functions that is called every frame). To start the UI: - -```c++ -ui::Viewer viewer; -viewer.run([](){ - //Main loop code -}); - -//Or - -viewer.run([](ui::Registry & r){ - //Main loop code - return should_continue_running; -}); -``` - -The API to interact with the UI follows this pattern -```c++ -ui::Entity entity = ui::do_something(registry, params) -SomeData & data = registry.get(entity); -``` - -For example: -```c++ -//Loads mesh from path -ui::Entity mesh_geometry = ui::load_mesh(registry, path); - -//Adds the mesh to scene -ui::Entity mesh_visualization = ui::show_mesh(registry, mesh_geometry); - -//Retrieves Transform component of the visualized mesh -Transform & transform = registry.get(mesh_visualization); -``` - -All entities and their components live in a `Registry`. To access/set/modify the entities and components, use the `Viewer::registry()`. -```c++ -auto & registry = viewer.registry(); -auto entity = registry.create(); -registry.emplace(entity, MyPositionComponent(0,0,0)); -``` - -*TODO: lifetime discussion* - -## Geometry loading and registration - -### Loading mesh - -Creates and entity that represents the mesh. This entity is only a resource - it is not rendered. -It can be referenced by components that need this geometry for rendering/picking/etc. -These entities have `MeshData` component attached that contains a `lagrange::MeshBase` pointer. - -```c++ -ui::Entity mesh_from_disk = ui::load_mesh(registry, path); -ui::Entity mesh_from_memory = ui::register_mesh(registry, lagrange::create_sphere()); -``` - -### Retrieving and interacting with the mesh - -To retrieve a mesh: -```c++ -MeshType & mesh = ui::get_mesh(registry, mesh_entity); -``` - -There are several methods that do not require the knowledge of the mesh type. These may however incur copy and conversion costs. -```c++ -RowMajorMatrixXf get_mesh_vertices(const MeshData& d); -RowMajorMatrixXf get_mesh_facets(const MeshData& d); -bool has_mesh_vertex_attribute(const MeshData& d, const std::string& name); -bool has_mesh_facet_attribute(const MeshData& d, const std::string& name); -... -RowMajorMatrixXf get_mesh_vertex_attribute(const MeshData& d, const std::string& name); -RowMajorMatrixXf get_mesh_facet_attribute(const MeshData& d, const std::string& name); -... -std::optional intersect_ray(const MeshData& d, const Eigen::Vector3f& origin, const Eigen::Vector3f& dir); -... -``` - - -### Loading scene -Loads a scene using Assimp. Creates a hierarchy of entities and loads meshes, materials and textures. Returns the top-level entity. - -```c++ -ui::Entity root = ui::load_scene(registry, path); -``` - -To iterate over the scene, see the [`Tree` component](#tree). - -## Adding geometry to scene - -### Default Physically Based Render (PBR) - -Adds previously registered mesh geometry to the scene. This mesh will be rendered using PBR. - -```c++ -ui::Entity scene_object = ui::show_mesh(registry, mesh_entity); -``` - -Uses `DefaultShaders::PBR` shader. - -See [Materials](#Materials) section to see how to control the appearance. - - -### Mesh visualizations -Adds a visualization of a mesh -([Jeremie's idea](https://git.corp.adobe.com/lagrange/Lagrange/issues/657)). - -```c++ -auto vertex_viz_entity = ui::show_vertex_attribute(registry, mesh_entity, attribute_name, glyph_type); -auto facet_viz_entity = ui::show_facet_attribute(registry, mesh_entity, attribute_name, glyph_type); -auto corner_viz_entity = ui::show_corner_attribute(registry, mesh_entity, attribute_name, glyph_type); -auto edge_viz_entity = ui::show_edge_attribute(registry, mesh_entity, attribute_name, glyph_type); -``` - -These functions will create a new scene object and render the supplied attribute using the selected glyph type. - -#### `GlyphType::Surface` - -Renders unshaded surface with color mapped from the supplied attribute. Supports attributes of dimension: 1, 2, 3, and 4. - -*Normalization*: The attribute value is automatically remapped to (0,1) range. To change the range, use `ui::set_colormap_range` -*Colormapping*: By default, the attribute is interpreted as R, RG, RGB or RGBA value. To use different mapping, refer to [Colormaps](#Colormaps) section. - -#### Colormaps - -If the glyph or shader supports colormapping, use the following function to set the colormap: - -To use on of the default colormaps: -```c++ -ui::set_colormap(registry, entity, ui::generate_colormap(ui::colormap_magma)) -``` -Or generate your own -```c++ -ui::set_colormap(registry, entity, ui::generate_colormap([](float t){ - return Color( - //... function of t from 0 to 1 - ); -})); -``` - -Default colormaps: -``` -colormap_viridis -colormap_magma -colormap_plasma -colormap_inferno -colormap_turbo -colormap_coolwarm -``` - - - - -## Materials - -Any entity with `MeshRender` component has a `Material` associated with it (`MeshRender::material`). - -To get a reference to entity's material, use: - -```c++ -std::shared_ptr material_ptr = ui::get_material(r, entity_with_meshrender); -``` - -Similarly, you may set a new material: -```c++ -ui::set_material(r, entity_with_mesh_render, std::make_shared(r, DefaultShaders::PBR); -``` - -### Color/Texture Material Properties - -You may set colors and textures of materials using the following API: - -```c++ -auto & material = *ui::get_material(r, entity_with_meshrender); - -//Sets "property name" to a red color -material.set_color("property name", ui::Color(1,0,0)); - -//Sets "texture name" to texture loaded from file -material.set_texture("texture name", ui::load_texture("texture.jpg")); -``` - -#### PBRMaterial -For the default `PBRMaterial`, you may use aliases for the property names: -```c++ - -//Uniform rgba color -material.set_color(PBRMaterial::BaseColor, ui::Color(1,0,0,1)); -//RGB(A) color/albedo texture -material.set_texture(PBRMaterial::BaseColor, ui::load_texture("color.jpg")); - -//Normal texture (and texture only) -material.set_texture(PBRMaterial::Normal, ui::load_texture("normal.jpg")); - -//Uniform roughness -material.set_float(PBRMaterial::Roughness, 0.75f); -//Roughness texture -material.set_texture(PBRMaterial::Roughness, ui::load_texture("metallic.jpg")); - -//Uniform roughness -material.set_float(PBRMaterial::Metallic, 0.75f); -//Metallic texture -material.set_texture(PBRMaterial::Metallic, ui::load_texture("metallic.jpg")); - -//Uniform opacity -material.set_float(PBRMaterial::Opacity, 1.0f); -//Opacity texture -material.set_texture(PBRMaterial::Opacity, ui::load_texture("opacity.jpg")); -``` - -### Rasterizer Properties - -To control OpenGl properties, you may following syntax: - -```c++ -material.set_int(RasterizerOptions::PolygonMode, GL_LINE); -material.set_float(RasterizerOptions::PointSize, PointSize); -``` - -See `` for a list of supported `RasterizerOptions`; - -### Custom Shader Properties - -You may set arbitrary `int` or `float` or `Color` or `Texture` to the material. It will be set as a shader uniform if it exists in the shader, otherwise there will be no effect. - - -## Common components -Entities can have several components that define their behavior. Here is a list of the common components used throughout Lagrange UI. - -### `Name` -Subclassed `std::string`. Acts as a display name. Will be shown in UI if it exists, otherwise a generated name will be used. Does not have to be unique. - -### `Transform` -Contains local and global transformations and a viewport transform. - -```c++ -// Translates entity one unit in X direction -ui::Transform & transform = registry.get(e); -transform.local = Eigen::Translation3f(1,0,0); -``` - -Global transformation is recomputed after each `Simulation` step. Only change the `local` transform. - -### `Tree` -Defines scene tree relationship. Data is stored using `parent`, `first_child`, `previous_sibling` and `next_sibling` entity IDs. - -Use helper functions to query or change the tree structure, do not change directly (unless you know what you're doing). -```c++ -//Orphans entity and parents it under new_parent -ui::reparent(registry, entity, new_parent); - -//Applies lambda to each direct child entity of parent -ui::foreach_child(registry, parent, [](Entity child){ - //... -}); - -//Applies lambda to each child entity of parent, recursively -ui::foreach_child_recursive(registry, parent, [](Entity child){ - //... -}); - -//In-order traversal of scene tree -ui::iterate_inorder(registry, root, [](Entity current){ - //On Enter - - //Return true to continue to traverse children - return true; -},[](Entity current){ - //On Exit -}); - -// See utils/treenode.h for more details -``` - -### `MeshGeometry` -Contains reference to geometry entity - -```c++ -MeshGeometry mg; -mg.entity = .. -``` - - -### `Hovered` and `Selected` - -These components acts as flags whether the entity is hovered or selected respectively. - -Useful helper functions -```c++ -bool is_selected(Registry ®istry, Entity e); -bool is_hovered(Registry ®istry, Entity e); -bool select(Registry& registry, Entity e); -bool deselect(Registry& registry, Entity e); -std::vector collect_selected(const Registry& registry); -std::vector collect_hovered(const Registry& registry); -//See `utils/selection.h` for details -``` - -### `Layer` - -There are 256 layers an entity can belong to. The `Layer` component specifies which layers the entity belongs to. Entity can belong to several layers at once. There are several default layers: -`ui::DefaultLayers::Default` - everything belongs to it by default -`ui::DefaultLayers::Selection` - selected entities -`ui::DefaultLayers::Hover` - hovered entities - -Default constructed `Layer` component belongs to `ui::DefaultLayers::Default`. - -You can register your own layer by calling -```c++ -ui::LayerIndex layer_index = ui::register_layer_name(r, "my layer name"); -``` - -There are several utility functions for working with layers: -```c++ -void add_to_layer(Registry&, Entity e, LayerIndex index); -void remove_from_layer(Registry&, Entity e, LayerIndex index); -bool is_in_layer(Registry&, Entity e, LayerIndex index); -bool is_in_any_layers(Registry&, Entity e, Layer layers_bitset); -bool is_visible_in( - const Registry&, - Entity e, - const Layer& visible_layers, - const Layer& hidden_layers); -``` - - -### `UIPanel` -See [section](#User-Interface-Panels) below. - -### `Viewport` -See [section](#Viewports) below. - -## User Interface Panels - -UI Panels are implemented also as entities. Panels have the `UIPanel` component. The `UIPanel` components describes the ImGui information (panel title, position, etc.). - -To create a new UI panel: -```c++ -auto panel_entity = ui::add_panel(registry, "Title of the panel",[](){ - // Do NOT call Imgui::Begin()/End() - Imgui::Text("Hello world"); -}); -//or -auto panel_entity = ui::add_panel(registry, "Title of the panel", [](Registry ®istry, Entity e){ - //Entity e is the panel_entity -}); -``` - -Example of multiple instances of a same "type" of panel: - -```c++ - -struct MyPanelState { int x = 0; } - -auto panel_fn = [](Registry ®istry, Entity e){ - auto & state = registry.get_or_emplace(e); - ImGui::InputInt("x", &state.x); -}; - -auto panel0 = ui::add_panel(registry,"panel with x = 0",panel_fn) -registry.emplace(panel0, MyPanelState{0}) - -auto panel1 = ui::add_panel(registry,"panel with x = 1",panel_fn); -registry.emplace(panel1, MyPanelState{1}) - -``` - - -## Viewports - -Viewports are implemented as entities with `ViewportComponent` component. Those referenced in `ViewportPanel` are rendered to screen, otherwise they are rendered off-screen. There is always one **focused** `ViewportPanel` (identified by the context variable `FocusedViewportPanel`). - -See `components/Viewport.h` and `utils/viewport.h` for utility functions related to viewport, viewport panels and cameras. - -### Entity visibility - -Each `ViewportComponent` has `visible_layers` and `hidden_layers` that control which entities can be renderer in this viewport (see [`Layer` component](#layer) for details). - -The default viewport shows only `DefaultLayers::DefaultLayer` - - -### Multi viewport - -Additional viewports can be created by calling -```c++ -ui::Entity camera_entity = add_camera(ui::Registry &, ui::Camera camera); -// or use get_focused_camera_entity(ui::Registry &) to reuse current camera - -// Creates an offscreen viewport with the specified camera -ui::Entity viewport_entity = add_viewport(ui::Registry &, ui::Entity camera_entity) - -// Creates a UI panel that shows the viewport -ui::Entity viewport_entity = add_viewport_panel(ui::Registry &, const std::string & name, ui::Entity viewport_entity); -``` - ---- - -## Entity Component System - -For more information about the ECS architecture, see: -- [What you need to know about ECS](https://medium.com/ingeniouslysimple/entities-components-and-systems-89c31464240d) for quick overview -- [Overwatch Gameplay Architecture - GDC Talk](https://www.youtube.com/watch?v=W3aieHjyNvw) for a good example of usage and design considerations. -- [entt Crash Course](https://github.com/skypjack/entt/wiki/Crash-Course:-entity-component-system) for overview of the underlying `entt` library -- [ECS Back and Forth](https://skypjack.github.io/2019-06-25-ecs-baf-part-4/) for more details about ECS design, in particular hierarchies -- [Unity ECS documentation](https://docs.unity3d.com/Packages/com.unity.entities@0.1/manual/index.html) for Unity's version of ECS - -### Registry -The `Viewer` uses a `Registry` (alias for `entt::registry`) to store all entities and their data. To manipulate entities and their components directly, use the object: -```c++ -auto & registry = viewer.registry(); -``` -`Viewer` class exposes API that simplifies interaction with the `Registry`, e.g. `Viewer::show_mesh`. - -### Entity -Unique identifier - it's just that. It's used to identify a unique "object" or "entity". Lagrange UI defines a `Entity` alias. Internally implemented as `std::uint32_t`. - -To create a new entity, use: -```c++ -Entity new_entity = registry.create(); -``` - -To destroy: -```c++ -registry.destroy(entity); -``` - -### Components -Any data that is attached to an `Entity`. Uniquely identified by template typename `` and `Entity`. - -Components **don't have logic, that means no code**. They only store data and implicitly define behavior. Ideally, the components should be `structs` with no functions. However, it may be beneficial to have setters/getters as member functions in some cases. - - -To attach a component of type `MyComponent` to an entity : -```c++ -// When it doesn't exist -registry.emplace(entity, MyComponent(42)) - -// When it might exist already -registry.emplace_or_replace(entity, MyComponent(42)) -``` - -To retrieve a component: -```c++ -// If it exists already -MyComponent & c = registry.get(entity); - -// If you're not sure it exists -MyComponent * c = registry.try_get(entity); -//or -if(registry.all_of()){ - MyComponent& c = registry.get(entity); -} -``` - -#### Tag Components - -"Empty" components may be used to tag entities, e.g. `Selected`, `Hovered`, etc. These types however must have non-zero size: -```c++ -struct Hidden { - bool dummy; -} -``` - -### Systems - -Systems are the logic of the application. They are defined as functions that iterate over entities that have specified components only. -For example, running this system: -```c++ -registry.view().each([](Entity e, Velocity & velocity, Transform & transform){ - transform.local = Eigen::Translation3f(velocity) * transform.local; -}); -``` -will iterate over all entities that have both `Velocity` and `Transform` and apply the velocity vector to the transform. - - -Lagrange UI defines `System` as alias to `std::function`, that is, a function that does something with the `Registry`. Typically these will be defined as: -```c++ -System my_system = [](Registry &w){ - w.view.each([](Entity e, Component1 & c1, Component2 & c2, ...){ - // - }); -}; -``` - - -### Context variables - -Systems **do not have data**. However, it's often useful to have some state associated with a given system, e.g. for caching. Sometimes it's useful that this state be shared among several systems. Instead of storing this state in some single instance of a component, we can use *context* variables. These can be thought of as *singleton* components - only one instance of a `Type` can exist at a given time. - -`InputState` is such a *singleton* component. At the beginning of the frame, it is filled with key/mouse information, including last mouse position, mouse delta, active keybinds, etc.: -```c++ -void update_input_system(Registry & registry){ - InputState & input_state = registry.ctx().emplace(); - input_state.mouse.position = ... - input_state.mouse.delta = ... - input_state.keybinds.update(...); -} -``` - -It can then be used by any other system down the line: -```c++ -void print_mouse_position(Registry & registry){ - const auto & input_state = registry.ctx().get(); - - lagrange::logger().info("Mouse position: {}", input_state.mouse_pos); -} -``` - -### Design considerations - -Rules to follow when designing components and systems: -- Components have no functions, only data -- Systems have no data -- State associated with systems is stored as context variable (`registry.ctx().get()`) - -*TODO: const Systems / const views* - - - -## Customizing Lagrange UI - -TODO: add more details and examples. In the meantime, refer to files named `default_{}` to see how the UI registers the default types and functionality. - -### Components - -You may add any time of component using `registry.emplace(entity)`. However to enable more advanced features, you may register the components in the UI: - -`register_component` -- enables reflection -- enables runtime add/clone/move of components - -`register_component_widget` -- defines ImGui code to render -- enables drag-and-drop - - -### Tools - -*TBD* - -`register_element_type` (Object/Facet/Edge/Vertex/...) - -`register_tool` (Select/Translate/Rotate/Scale/...) - -### Geometry - -Lagrange meshes must be registered to work. By default, only the `TriangleMesh3Df` and `TriangleMesh3D` are registered. - -`ui::register_mesh_type()` - -### Rendering - -#### Shader and Material properties - -Material properties can be defined in the shader using the following syntax: - - -```glsl -#pragma property NAME "DISPLAY NAME" TYPE(DEFAULT VALUE AND/OR RANGE) [TAG1, TAG2] -``` - -For example: -```glsl -//Defines a 2D texture property with the default value of rgba(0.7,0.7,0.7,1) if no texture is bound -#pragma property material_base_color "Base Color" Texture2D(0.7,0.7,0.7,1) -//Defines a 2D texture property with the default value of red=0.4 if no texture is bound -#pragma property material_roughness "Roughness" Texture2D(0.4) -//Defines a 2D texture property with the default value of red=0.1 if no texture is bound -#pragma property material_metallic "Metallic" Texture2D(0.1) -//Defines a 2D texture property that is to be interpreted as normal texture -#pragma property material_normal "Normal" Texture2D [normal] -//Defines a float property, with the default value of 1 and range 0,1 -#pragma property material_opacity "Opacity" float(1,0,1) -``` - -The pragmas are parsed whenever a shader is loaded and replaced with: -```glsl -uniform TYPE NAME = DEFAULT_VALUE -``` -In case of `Texture2D`, these uniforms are generated: -```glsl -uniform sampler2D NAME; -uniform bool NAME_texture_bound = false; -uniform VEC_TYPE NAME_default_value = DEFAULT_VALUE; -``` - - - - -## Examples - -Refer to `modules/ui/examples`. Build Lagrange with `-DLAGRANGE_EXAMPLES=On`. diff --git a/modules/ui/include/lagrange/ui/imgui/imconfig.h b/modules/ui/include/lagrange/ui/imgui/imconfig.h index cf702834..814ba7f1 100644 --- a/modules/ui/include/lagrange/ui/imgui/imconfig.h +++ b/modules/ui/include/lagrange/ui/imgui/imconfig.h @@ -41,6 +41,10 @@ // #define IMGUI_API __declspec( dllexport ) // #define IMGUI_API __declspec( dllimport ) +// Explicitly set Spectrum light theme. imgui.h 1.92.8+ includes imgui_spectrum.h directly, so we +// must define the theme here (before the inclusion) to ensure GRAY100/GRAY500 etc. are available. +#define SPECTRUM_USE_LIGHT_THEME + //---- Don't define obsolete functions/enums/behaviors. Consider enabling from time to time after updating to avoid using soon-to-be obsolete function/names. // #define IMGUI_DISABLE_OBSOLETE_FUNCTIONS diff --git a/modules/ui/include/lagrange/ui/systems/update_mesh_hovered.h b/modules/ui/include/lagrange/ui/systems/update_mesh_hovered.h index ad5e8047..849452ae 100644 --- a/modules/ui/include/lagrange/ui/systems/update_mesh_hovered.h +++ b/modules/ui/include/lagrange/ui/systems/update_mesh_hovered.h @@ -17,7 +17,7 @@ namespace lagrange { namespace ui { -/// Sets component if the mesh is hovered a ViewportPanel. +/// Sets `` component if the mesh is hovered in a ViewportPanel. /// See SelectionContext and default_tools for details LA_UI_API void update_mesh_hovered(Registry& ctx); diff --git a/modules/ui/include/lagrange/ui/systems/update_scene_bounds.h b/modules/ui/include/lagrange/ui/systems/update_scene_bounds.h index a77a9c46..935f96fa 100644 --- a/modules/ui/include/lagrange/ui/systems/update_scene_bounds.h +++ b/modules/ui/include/lagrange/ui/systems/update_scene_bounds.h @@ -19,7 +19,7 @@ namespace lagrange { namespace ui { -/// Sets context variable +/// Sets context `` variable LA_UI_API void update_scene_bounds_system(Registry& ctx); diff --git a/modules/ui/include/lagrange/ui/types/AABB.h b/modules/ui/include/lagrange/ui/types/AABB.h index a66f7132..6d2b2ec9 100644 --- a/modules/ui/include/lagrange/ui/types/AABB.h +++ b/modules/ui/include/lagrange/ui/types/AABB.h @@ -61,8 +61,8 @@ class LA_UI_API AABB : public Eigen::AlignedBox3f AABB transformed(const Eigen::Affine3f& transform) const; bool intersects_ray( - Eigen::Vector3f origin, - Eigen::Vector3f dir, + const Eigen::Vector3f& origin, + const Eigen::Vector3f& dir, float* tmin_out = nullptr, float* tmax_out = nullptr) const; diff --git a/modules/ui/include/lagrange/ui/types/Camera.h b/modules/ui/include/lagrange/ui/types/Camera.h index 3e518b38..e1e7a1a9 100644 --- a/modules/ui/include/lagrange/ui/types/Camera.h +++ b/modules/ui/include/lagrange/ui/types/Camera.h @@ -199,7 +199,7 @@ class LA_UI_API Camera void rotate_turntable( float yaw_delta, float pitch_delta, - Eigen::Vector3f primary_axis = Eigen::Vector3f::Zero()); + const Eigen::Vector3f& primary_axis = Eigen::Vector3f::Zero()); void rotate_arcball( @@ -227,7 +227,7 @@ class LA_UI_API Camera /// /// @param viewport Orthographic rectangle /// - void set_ortho_viewport(Eigen::Vector4f viewport); + void set_ortho_viewport(const Eigen::Vector4f& viewport); Eigen::Vector4f get_ortho_viewport() const; @@ -314,7 +314,7 @@ class LA_UI_API Camera /// /// @return Frustum planes of a region /// - Frustum get_frustum(Eigen::Vector2f min, Eigen::Vector2f max) const; + Frustum get_frustum(const Eigen::Vector2f& min, const Eigen::Vector2f& max) const; protected: void update_view(); diff --git a/modules/ui/include/lagrange/ui/types/Keybinds.h b/modules/ui/include/lagrange/ui/types/Keybinds.h index b1ba93a1..d98fb0b6 100644 --- a/modules/ui/include/lagrange/ui/types/Keybinds.h +++ b/modules/ui/include/lagrange/ui/types/Keybinds.h @@ -76,7 +76,6 @@ class LA_UI_API Keybinds void push_context(const std::string& context); /// @brief Pops the last pushed context - /// @param context context name void pop_context(); void reset_context(); diff --git a/modules/ui/include/lagrange/ui/types/Shader.h b/modules/ui/include/lagrange/ui/types/Shader.h index 9eab156d..f3c5628c 100644 --- a/modules/ui/include/lagrange/ui/types/Shader.h +++ b/modules/ui/include/lagrange/ui/types/Shader.h @@ -153,15 +153,15 @@ struct LA_UI_API ShaderValue int size; GLenum type; ShaderInterface shaderInterface; - const ShaderValue& operator=(Eigen::Vector2f val) const; - const ShaderValue& operator=(Eigen::Vector3f val) const; - const ShaderValue& operator=(Eigen::Vector4f val) const; - - const ShaderValue& operator=(Eigen::Matrix2f val) const; - const ShaderValue& operator=(Eigen::Matrix3f val) const; - const ShaderValue& operator=(Eigen::Matrix4f val) const; - const ShaderValue& operator=(Eigen::Affine3f val) const; - const ShaderValue& operator=(Eigen::Projective3f val) const; + const ShaderValue& operator=(const Eigen::Vector2f& val) const; + const ShaderValue& operator=(const Eigen::Vector3f& val) const; + const ShaderValue& operator=(const Eigen::Vector4f& val) const; + + const ShaderValue& operator=(const Eigen::Matrix2f& val) const; + const ShaderValue& operator=(const Eigen::Matrix3f& val) const; + const ShaderValue& operator=(const Eigen::Matrix4f& val) const; + const ShaderValue& operator=(const Eigen::Affine3f& val) const; + const ShaderValue& operator=(const Eigen::Projective3f& val) const; const ShaderValue& operator=(double val) const; diff --git a/modules/ui/include/lagrange/ui/types/Systems.h b/modules/ui/include/lagrange/ui/types/Systems.h index 31329fde..42057a22 100644 --- a/modules/ui/include/lagrange/ui/types/Systems.h +++ b/modules/ui/include/lagrange/ui/types/Systems.h @@ -56,8 +56,8 @@ class LA_UI_API Systems /// @brief Places system with `system_id` after the system with `after_id` /// Note: Does not handle cycles nor topological ordering, only moves `system_id` in execution order. - /// @param system StringID - /// @param after StringID + /// @param system_id StringID + /// @param after_id StringID /// @return true on success, false if `system_id` or `after_id` do not exist bool succeeds(StringID system_id, StringID after_id); diff --git a/modules/ui/include/lagrange/ui/utils/bounds.h b/modules/ui/include/lagrange/ui/utils/bounds.h index fd74cf91..25d00c6d 100644 --- a/modules/ui/include/lagrange/ui/utils/bounds.h +++ b/modules/ui/include/lagrange/ui/utils/bounds.h @@ -28,7 +28,7 @@ LA_UI_API AABB get_bounding_box(const Registry& registry, Entity e); /// If entity does not have bounds, returns an empty AABB LA_UI_API AABB get_bounding_box_local(const Registry& registry, Entity e); -/// Returns Axis Aligned Bounding Box of all entities with component +/// Returns Axis Aligned Bounding Box of all entities with `` component /// If there's no selection returns an empty AABB LA_UI_API AABB get_selection_bounding_box(const Registry& registry); @@ -57,7 +57,7 @@ LA_UI_API AABB get_scene_bounding_box(const Registry& registry); /// (must be set as context variable after update_scene_bounds) LA_UI_API const Bounds& get_scene_bounds(const Registry& registry); -/// @copydoc +/// @copydoc get_scene_bounds(const Registry&) LA_UI_API Bounds& get_scene_bounds(Registry& registry); diff --git a/modules/ui/include/lagrange/ui/utils/events.h b/modules/ui/include/lagrange/ui/utils/events.h index 54f59956..f1c82b69 100644 --- a/modules/ui/include/lagrange/ui/utils/events.h +++ b/modules/ui/include/lagrange/ui/utils/events.h @@ -29,7 +29,6 @@ inline EventEmitter& get_event_emitter(Registry& r) /// @tparam Event /// @param r Registry instance /// @param listener function taking reference to Event as parameter -/// @return Connection instance, can be used to disconnect listener later template void on(Registry& r, std::function listener) { diff --git a/modules/ui/include/lagrange/ui/utils/ibl.h b/modules/ui/include/lagrange/ui/utils/ibl.h index f3a0f9c6..c2c3a468 100644 --- a/modules/ui/include/lagrange/ui/utils/ibl.h +++ b/modules/ui/include/lagrange/ui/utils/ibl.h @@ -30,13 +30,13 @@ LA_UI_API IBL generate_ibl(const fs::path& path, size_t resolution = 1024); /// @brief Generates Image Based Light from given rectangular texture. /// Throws std::runtime_error on failure. -/// @param path +/// @param background_texture /// @param resolution /// @return IBL LA_UI_API IBL generate_ibl(const std::shared_ptr& background_texture, size_t resolution = 1024); -/// @brief Returns first entity found in registry. If there are none, returns invalid Entity +/// @brief Returns first `` entity found in registry. If there are none, returns invalid Entity /// @param registry /// @return Entity LA_UI_API Entity get_ibl_entity(const Registry& registry); @@ -46,7 +46,7 @@ LA_UI_API Entity get_ibl_entity(const Registry& registry); /// @return IBL pointer LA_UI_API const IBL* get_ibl(const Registry& registry); -/// @copydoc +/// @copydoc get_ibl(const Registry&) LA_UI_API IBL* get_ibl(Registry& registry); /// @brief Adds IBL to the scene diff --git a/modules/ui/include/lagrange/ui/utils/io.h b/modules/ui/include/lagrange/ui/utils/io.h index 53f12098..9ae4621e 100644 --- a/modules/ui/include/lagrange/ui/utils/io.h +++ b/modules/ui/include/lagrange/ui/utils/io.h @@ -29,7 +29,8 @@ LA_UI_API std::shared_ptr load_texture( const fs::path& path, const Texture::Params& params = Texture::Params()); -/// @brief Convertrs tinyobj's material_t to UI's Material +/// @brief Converts tinyobj's material_t to UI's Material +/// @param r /// @param base_dir /// @param tinymat /// @return diff --git a/modules/ui/include/lagrange/ui/utils/math.h b/modules/ui/include/lagrange/ui/utils/math.h index c69efe6e..02771546 100644 --- a/modules/ui/include/lagrange/ui/utils/math.h +++ b/modules/ui/include/lagrange/ui/utils/math.h @@ -28,7 +28,7 @@ LA_UI_API Eigen::Matrix4f normal_matrix(const Eigen::Affine3f& transform); /// Constructs perspective projection matrix /// -/// @param[in] fov_y vertical field of vision in RADIANS +/// @param[in] fovy vertical field of vision in RADIANS /// @param[in] aspect aspect ratio /// @param[in] zNear near plane /// @param[in] zFar far plane diff --git a/modules/ui/include/lagrange/ui/utils/render.h b/modules/ui/include/lagrange/ui/utils/render.h index 426f72e9..b2df1112 100644 --- a/modules/ui/include/lagrange/ui/utils/render.h +++ b/modules/ui/include/lagrange/ui/utils/render.h @@ -54,7 +54,7 @@ LA_UI_API void set_render_pass_defaults(GLScope& scope); /// /// Returns a pair of orthogonal directions, that together with direction form a orthogonal basis LA_UI_API std::pair compute_perpendicular_plane( - Eigen::Vector3f direction); + const Eigen::Vector3f& direction); } // namespace render @@ -129,6 +129,7 @@ LA_UI_API int get_gl_attribute_dimension(GLenum attrib_type); /// @param shader /// @param glvd /// @param indexing +/// @param submesh_index LA_UI_API void update_vertex_data( const GLMesh& glmesh, const Shader& shader, diff --git a/modules/ui/include/lagrange/ui/utils/template.h b/modules/ui/include/lagrange/ui/utils/template.h index d90b1247..eb32d555 100644 --- a/modules/ui/include/lagrange/ui/utils/template.h +++ b/modules/ui/include/lagrange/ui/utils/template.h @@ -36,6 +36,7 @@ struct lambda_helper namespace util { +/// @cond LA_INTERNAL_DOCS template struct AsFunction : public AsFunction { @@ -58,6 +59,7 @@ struct AsFunction { using arg_type = Arg; }; +/// @endcond } // namespace util diff --git a/modules/ui/include/lagrange/ui/utils/treenode.h b/modules/ui/include/lagrange/ui/utils/treenode.h index 0f7aa9b8..816916d6 100644 --- a/modules/ui/include/lagrange/ui/utils/treenode.h +++ b/modules/ui/include/lagrange/ui/utils/treenode.h @@ -33,20 +33,20 @@ LA_UI_API Entity create_scene_node( /// @param recursive removes all children recursively LA_UI_API void remove(Registry& r, Entity e, bool recursive = false); -/// @brief Sets new_parent as as child's new parent. Both must have component. +/// @brief Sets new_parent as the child's new parent. Both must have `` component. /// @param registry -/// @param Entity child -/// @param Entity new_parent +/// @param child +/// @param new_parent LA_UI_API void set_parent(Registry& registry, Entity child, Entity new_parent); -/// @brief Returns parent of e. Must have component. +/// @brief Returns parent of e. Must have `` component. /// Returns NullEntity if e is top-level. /// @param registry /// @param e /// @return LA_UI_API Entity get_parent(const Registry& registry, Entity e); -/// @brief Returns all children of e. Must have component. +/// @brief Returns all children of e. Must have `` component. /// Note: causes dynamic allocation. Use lagrange::ui::foreach_child to iterate children efficiently. /// @param registry /// @param e diff --git a/modules/ui/include/lagrange/ui/utils/uipanel.h b/modules/ui/include/lagrange/ui/utils/uipanel.h index d0abbabb..979ebd2c 100644 --- a/modules/ui/include/lagrange/ui/utils/uipanel.h +++ b/modules/ui/include/lagrange/ui/utils/uipanel.h @@ -32,9 +32,9 @@ LA_UI_API void end_panel(UIPanel& panel); /// @brief Adds window that executed given imgui_code. Imgui begin/end is called for you, do not /// call it inside the imgui_code. -/// @param registry +/// @param r /// @param title -/// @param imgui_code +/// @param body_fn /// @return window entity LA_UI_API Entity add_panel(Registry& r, const std::string& title, const std::function& body_fn); @@ -52,7 +52,7 @@ LA_UI_API void toggle_panel(Registry& r, Entity e); /// @brief Returns global window size -/// @param registry +/// @param r /// @return LA_UI_API const WindowSize& get_window_size(const Registry& r); diff --git a/modules/ui/src/Viewer.cpp b/modules/ui/src/Viewer.cpp index 17a409d8..8faaf447 100644 --- a/modules/ui/src/Viewer.cpp +++ b/modules/ui/src/Viewer.cpp @@ -58,6 +58,7 @@ #include #include #include //todo move dock stuff to uiwindow system +#include #include #include @@ -554,7 +555,7 @@ void Viewer::render_one_frame(const std::function& main_loop) m_systems.run(Systems::Stage::Post, registry()); } -bool Viewer::run(const std::function& main_loop) +bool Viewer::run(const std::function& main_loop) { if (!is_initialized()) return false; @@ -890,8 +891,6 @@ bool Viewer::init_imgui_fonts() &icons_config, icons_ranges); - io.Fonts->Build(); - return font_awesome != nullptr; } @@ -998,9 +997,8 @@ void Viewer::start_imgui_frame() // Set up ui scaling { - auto& io = ImGui::GetIO(); - io.FontGlobalScale = 0.5f * m_ui_scaling; // divide by two since we're oversampling auto& style = ImGui::GetStyle(); + style.FontScaleMain = 0.5f * m_ui_scaling; // divide by two since we're oversampling ImGui::PushStyleVar( ImGuiStyleVar_FramePadding, diff --git a/modules/ui/src/default_components.cpp b/modules/ui/src/default_components.cpp index bb709c18..5eafc290 100644 --- a/modules/ui/src/default_components.cpp +++ b/modules/ui/src/default_components.cpp @@ -31,6 +31,7 @@ #include #include +#include namespace lagrange { @@ -85,12 +86,12 @@ void show_mesh_geometry(Registry* rptr, Entity orig_e) ImGui::Text("MeshType: %s", typeinfo.name().data()); const size_t num_vertices = get_num_vertices(mesh_data); - if (num_vertices == 0) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::Spectrum::RED400); + if (num_vertices == 0) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::Spectrum::Static::RED400); ImGui::Text("Vertices: %zu", num_vertices); if (num_vertices == 0) ImGui::PopStyleColor(); const size_t num_facets = get_num_facets(mesh_data); - if (num_facets == 0) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::Spectrum::RED400); + if (num_facets == 0) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::Spectrum::Static::RED400); ImGui::Text("Facets: %zu", num_facets); if (num_facets == 0) ImGui::PopStyleColor(); @@ -1194,7 +1195,7 @@ void show_bounds(Registry* rptr, Entity e) const auto show_bb = [](const AABB& bb) { if (bb.isEmpty()) { - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::Spectrum::RED400); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::Spectrum::Static::RED400); ImGui::Text("Empty"); ImGui::PopStyleColor(); } else { diff --git a/modules/ui/src/imgui/UIWidget.cpp b/modules/ui/src/imgui/UIWidget.cpp index 3e13b721..543d6721 100644 --- a/modules/ui/src/imgui/UIWidget.cpp +++ b/modules/ui/src/imgui/UIWidget.cpp @@ -119,18 +119,9 @@ bool UIWidget::operator()(Texture& value, int width /* = 0*/, int height /* = 0* static_cast(value.get_id()), ImVec2(float(width), float(height)), uv0, - uv1, - ImVec4(1.0f, 1.0f, 1.0f, 1.0f), - ImVec4(0.0f, 0.0f, 0.0f, 0.5f)); + uv1); return ImGui::IsItemClicked(0) || ImGui::IsItemClicked(1); - /*if (ImGui::BeginPopupContextItem(("Texture " + m_name).c_str())) { - ImGui::Text("%s", m_name.c_str()); - ImGui::Image(texID, ImVec2(800, 800), uv0, uv1, ImVec4(1.0f, 1.0f, 1.0f, 1.0f), ImVec4(0.0f, - 0.0f, 0.0f, 0.5f)); ImGui::EndPopup(); - }*/ - - return false; } bool UIWidget::operator()(Eigen::Vector2f& value) diff --git a/modules/ui/src/imgui/buttons.cpp b/modules/ui/src/imgui/buttons.cpp index 526ec271..5d072d19 100644 --- a/modules/ui/src/imgui/buttons.cpp +++ b/modules/ui/src/imgui/buttons.cpp @@ -12,6 +12,8 @@ #include +#include + namespace lagrange { namespace ui { @@ -61,12 +63,12 @@ bool button_icon( ImGui::GetStyleColorVec4(selected ? ImGuiCol_Header : ImGuiCol_Button)); if (selected) { - ImGui::PushStyleColor(ImGuiCol_Text, ImColor(ImGui::Spectrum::GRAY100).Value); + ImGui::PushStyleColor(ImGuiCol_Text, ImColor(ImGui::Spectrum::Colors->GRAY100).Value); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetStyleColorVec4(ImGuiCol_Header)); } if (!enabled) { - ImGui::PushStyleColor(ImGuiCol_Button, ImColor(ImGui::Spectrum::GRAY500).Value); + ImGui::PushStyleColor(ImGuiCol_Button, ImColor(ImGui::Spectrum::Static::GRAY500).Value); // hovered text: GRAY900 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetStyleColorVec4(ImGuiCol_Button)); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::GetStyleColorVec4(ImGuiCol_Button)); diff --git a/modules/ui/src/imgui/progress.cpp b/modules/ui/src/imgui/progress.cpp index f8eb372c..43b3536e 100644 --- a/modules/ui/src/imgui/progress.cpp +++ b/modules/ui/src/imgui/progress.cpp @@ -124,7 +124,7 @@ bool Spinner(const char* label, float radius, int thickness, const ImU32& color) centre.y + ImSin(a + float(g.Time) * 8) * radius)); } - window->DrawList->PathStroke(color, false, float(thickness)); + window->DrawList->PathStroke(color, float(thickness), ImDrawFlags_None); return false; } diff --git a/modules/ui/src/panels/KeybindsPanel.cpp b/modules/ui/src/panels/KeybindsPanel.cpp index 973b9f57..87a948ed 100644 --- a/modules/ui/src/panels/KeybindsPanel.cpp +++ b/modules/ui/src/panels/KeybindsPanel.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -213,7 +214,7 @@ void keybinds_panel_system(Registry& registry, Entity /*e*/) ImGui::InputText("Key bind", &keybind_str, ImGuiInputTextFlags_ReadOnly); ImGui::TextColored( - ImVec4(ImColor(ImGui::Spectrum::GREEN500).Value), + ImVec4(ImColor(ImGui::Spectrum::Static::GREEN500).Value), "Press Enter to Save"); if (ImGui::IsKeyReleased(ImGuiKey_Escape)) { diff --git a/modules/ui/src/panels/LoggerPanel.cpp b/modules/ui/src/panels/LoggerPanel.cpp index 774e296a..3c27edc5 100644 --- a/modules/ui/src/panels/LoggerPanel.cpp +++ b/modules/ui/src/panels/LoggerPanel.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -36,7 +37,7 @@ struct LoggerPanel class LogData { public: - using ColorType = std::remove_const_t; + using ColorType = unsigned int; std::deque> data; std::mutex mutex; }; @@ -57,14 +58,14 @@ class SpdlogUISink : public spdlog::sinks::base_sink protected: void sink_it_(const spdlog::details::log_msg& msg) override { - LogData::ColorType color = ImGui::Spectrum::GRAY800; + LogData::ColorType color = ImGui::Spectrum::Static::GRAY800; switch (msg.level) { - case spdlog::level::trace: color = ImGui::Spectrum::GRAY500; break; - case spdlog::level::debug: color = ImGui::Spectrum::BLUE400; break; - case spdlog::level::info: color = ImGui::Spectrum::GREEN500; break; - case spdlog::level::warn: color = ImGui::Spectrum::YELLOW500; break; - case spdlog::level::err: color = ImGui::Spectrum::RED500; break; - case spdlog::level::critical: color = ImGui::Spectrum::PURPLE500; break; + case spdlog::level::trace: color = ImGui::Spectrum::Static::GRAY500; break; + case spdlog::level::debug: color = ImGui::Spectrum::Static::BLUE400; break; + case spdlog::level::info: color = ImGui::Spectrum::Static::GREEN500; break; + case spdlog::level::warn: color = ImGui::Spectrum::Colors->YELLOW500; break; + case spdlog::level::err: color = ImGui::Spectrum::Static::RED500; break; + case spdlog::level::critical: color = ImGui::Spectrum::Colors->PURPLE500; break; case spdlog::level::off: break; default: break; } diff --git a/modules/ui/src/panels/ViewportPanel.cpp b/modules/ui/src/panels/ViewportPanel.cpp index 3d651afc..9ca0eed8 100644 --- a/modules/ui/src/panels/ViewportPanel.cpp +++ b/modules/ui/src/panels/ViewportPanel.cpp @@ -33,6 +33,7 @@ #include #include +#include namespace lagrange { namespace ui { @@ -52,13 +53,7 @@ void draw_imgui_gl_texture(GLuint tex_id, int w, int h) const ImVec2 uv0 = ImVec2(0, 1); const ImVec2 uv1 = ImVec2(1, 0); - ImGui::Image( - static_cast(tex_id), - ImVec2(float(w), float(h)), - uv0, - uv1, - ImVec4(1.0f, 1.0f, 1.0f, 1.0f), - ImVec4(1.0f, 1.0f, 1.0f, 0.5f)); + ImGui::Image(static_cast(tex_id), ImVec2(float(w), float(h)), uv0, uv1); } void separator() @@ -67,7 +62,7 @@ void separator() ImGui::GetWindowDrawList()->AddLine( ImVec2(p.x, p.y), ImVec2(p.x, p.y + ImGui::GetTextLineHeightWithSpacing()), - ImColor(ImGui::Spectrum::GRAY300), + ImColor(ImGui::Spectrum::Static::GRAY300), 1.0f); ImGui::Dummy(ImVec2(2.5f, 0)); ImGui::SameLine(); diff --git a/modules/ui/src/systems/update_mesh_hovered.cpp b/modules/ui/src/systems/update_mesh_hovered.cpp index b88d6d8a..6932e979 100644 --- a/modules/ui/src/systems/update_mesh_hovered.cpp +++ b/modules/ui/src/systems/update_mesh_hovered.cpp @@ -142,7 +142,7 @@ void update_mesh_hovered_GL(Registry& r, const SelectionContext& sel_ctx) } -/// Updates component based on current selection context +/// Updates `` component based on current selection context void update_mesh_hovered(Registry& r) { // Copy selection context diff --git a/modules/ui/src/types/AABB.cpp b/modules/ui/src/types/AABB.cpp index 158d657f..6093919d 100644 --- a/modules/ui/src/types/AABB.cpp +++ b/modules/ui/src/types/AABB.cpp @@ -43,8 +43,8 @@ Eigen::Affine3f AABB::get_normalization_transform(bool preserve_aspect) const } bool AABB::intersects_ray( - Eigen::Vector3f origin, - Eigen::Vector3f dir, + const Eigen::Vector3f& origin, + const Eigen::Vector3f& dir, float* tmin_out /* = nullptr*/, float* tmax_out /* = nullptr*/) const { diff --git a/modules/ui/src/types/Camera.cpp b/modules/ui/src/types/Camera.cpp index c8ce0f7a..4ce4c85a 100644 --- a/modules/ui/src/types/Camera.cpp +++ b/modules/ui/src/types/Camera.cpp @@ -311,7 +311,10 @@ void Camera::rotate_tumble(float yaw_delta, float pitch_delta) } -void Camera::rotate_turntable(float yaw_delta, float pitch_delta, Eigen::Vector3f primary_axis) +void Camera::rotate_turntable( + float yaw_delta, + float pitch_delta, + const Eigen::Vector3f& primary_axis) { if (primary_axis.x() != 0 || primary_axis.y() != 0 || primary_axis.z() != 0) { set_up(primary_axis); @@ -400,7 +403,7 @@ void Camera::move_up(float delta) update_view(); } -void Camera::set_ortho_viewport(Eigen::Vector4f viewport) +void Camera::set_ortho_viewport(const Eigen::Vector4f& viewport) { if (std::isnan(viewport.x()) || std::isnan(viewport.y()) || std::isnan(viewport.z()) || std::isnan(viewport.w())) @@ -514,7 +517,7 @@ Frustum Camera::get_frustum() const return get_frustum(Eigen::Vector2f(0), Eigen::Vector2f(get_window_size())); } -Frustum Camera::get_frustum(Eigen::Vector2f min, Eigen::Vector2f max) const +Frustum Camera::get_frustum(const Eigen::Vector2f& min, const Eigen::Vector2f& max) const { auto ray_bottom_left = cast_ray(min); auto ray_top_left = cast_ray({min.x(), max.y()}); diff --git a/modules/ui/src/types/Shader.cpp b/modules/ui/src/types/Shader.cpp index 4c4b9c4a..c0ae2009 100644 --- a/modules/ui/src/types/Shader.cpp +++ b/modules/ui/src/types/Shader.cpp @@ -783,7 +783,7 @@ ShaderValue::set_matrices(const Eigen::Affine3f* data, int n, bool transpose /*= ShaderValue ShaderValue::none = {-1, 0, 0, SHADER_INTERFACE_NONE}; -const ShaderValue& ShaderValue::operator=(Eigen::Vector2f val) const +const ShaderValue& ShaderValue::operator=(const Eigen::Vector2f& val) const { if (location == -1) return *this; assert(type == GL_FLOAT_VEC2); @@ -797,7 +797,7 @@ const ShaderValue& ShaderValue::operator=(Eigen::Vector2f val) const return *this; } -const ShaderValue& ShaderValue::operator=(Eigen::Vector3f val) const +const ShaderValue& ShaderValue::operator=(const Eigen::Vector3f& val) const { if (location == -1) return *this; assert(type == GL_FLOAT_VEC3); @@ -811,7 +811,7 @@ const ShaderValue& ShaderValue::operator=(Eigen::Vector3f val) const return *this; } -const ShaderValue& ShaderValue::operator=(Eigen::Vector4f val) const +const ShaderValue& ShaderValue::operator=(const Eigen::Vector4f& val) const { if (location == -1) return *this; @@ -837,7 +837,7 @@ const ShaderValue& ShaderValue::operator=(Eigen::Vector4f val) const return *this; } -const ShaderValue& ShaderValue::operator=(Eigen::Matrix2f val) const +const ShaderValue& ShaderValue::operator=(const Eigen::Matrix2f& val) const { if (location == -1) return *this; assert(type == GL_FLOAT_MAT2 && shaderInterface == SHADER_INTERFACE_UNIFORM); @@ -845,7 +845,7 @@ const ShaderValue& ShaderValue::operator=(Eigen::Matrix2f val) const return *this; } -const ShaderValue& ShaderValue::operator=(Eigen::Matrix3f val) const +const ShaderValue& ShaderValue::operator=(const Eigen::Matrix3f& val) const { if (location == -1) return *this; assert(type == GL_FLOAT_MAT3 && shaderInterface == SHADER_INTERFACE_UNIFORM); @@ -853,7 +853,7 @@ const ShaderValue& ShaderValue::operator=(Eigen::Matrix3f val) const return *this; } -const ShaderValue& ShaderValue::operator=(Eigen::Matrix4f val) const +const ShaderValue& ShaderValue::operator=(const Eigen::Matrix4f& val) const { if (location == -1) return *this; assert(type == GL_FLOAT_MAT4 && shaderInterface == SHADER_INTERFACE_UNIFORM); @@ -861,13 +861,13 @@ const ShaderValue& ShaderValue::operator=(Eigen::Matrix4f val) const return *this; } -const ShaderValue& ShaderValue::operator=(Eigen::Affine3f val) const +const ShaderValue& ShaderValue::operator=(const Eigen::Affine3f& val) const { // defer to Matrix4f return ((*this) = val.matrix()); } -const ShaderValue& ShaderValue::operator=(Eigen::Projective3f val) const +const ShaderValue& ShaderValue::operator=(const Eigen::Projective3f& val) const { // defer to Matrix4f return ((*this) = val.matrix()); diff --git a/modules/ui/src/utils/render.cpp b/modules/ui/src/utils/render.cpp index f0d80bc7..720b548e 100644 --- a/modules/ui/src/utils/render.cpp +++ b/modules/ui/src/utils/render.cpp @@ -25,9 +25,10 @@ namespace ui { namespace utils { namespace render { -std::pair compute_perpendicular_plane(Eigen::Vector3f direction) +std::pair compute_perpendicular_plane( + const Eigen::Vector3f& direction) { - Eigen::Vector3f& u1 = direction; + const Eigen::Vector3f& u1 = direction; Eigen::Vector3f v2; if (std::abs(direction.x()) == 1.0f && direction.y() == 0.0f && direction.z() == 0.0f) { diff --git a/modules/volume/examples/grid_viewer.cpp b/modules/volume/examples/grid_viewer.cpp index 7bfb2a90..13f58c54 100644 --- a/modules/volume/examples/grid_viewer.cpp +++ b/modules/volume/examples/grid_viewer.cpp @@ -25,6 +25,8 @@ // clang-format on #endif +#include + #include namespace fs = lagrange::fs; diff --git a/modules/volume/examples/voxelize_gui.cpp b/modules/volume/examples/voxelize_gui.cpp index dd73cd3b..4830b999 100644 --- a/modules/volume/examples/voxelize_gui.cpp +++ b/modules/volume/examples/voxelize_gui.cpp @@ -27,6 +27,8 @@ #include // clang-format on +#include + #include namespace fs = lagrange::fs; diff --git a/modules/volume/python/src/volume.cpp b/modules/volume/python/src/volume.cpp index a25c7e00..7bb04132 100644 --- a/modules/volume/python/src/volume.cpp +++ b/modules/volume/python/src/volume.cpp @@ -406,8 +406,8 @@ void populate_volume_module(nb::module_& m) :returns: Memory buffer (bytes).)", nb::sig( - "def to_buffer(ext: typing.Literal['vdb', 'nvdb'], compression: Compression = " - "Compression.Blosc) -> bytes")); + "def to_buffer(self, grid_type: typing.Literal['vdb', 'nvdb'], compression: " + "Compression = Compression.Blosc) -> bytes")); ////////////////////////////////////////////// // RW properties diff --git a/modules/volume/python/tests/test_volume.py b/modules/volume/python/tests/test_volume.py index 452d3ee6..86f7ed07 100644 --- a/modules/volume/python/tests/test_volume.py +++ b/modules/volume/python/tests/test_volume.py @@ -13,9 +13,12 @@ from lagrange.volume import Grid import numpy as np import tempfile +from typing import Literal from pathlib import Path import pytest +GRID_TYPES: tuple[Literal["vdb", "nvdb"], ...] = ("vdb", "nvdb") + class TestMeshToVolume: def test_bbox(self, cube): @@ -70,7 +73,7 @@ def test_cube(self, cube): mesh = cube.clone() with tempfile.TemporaryDirectory() as tmp_dir: tmp_dir = Path(tmp_dir) - for ext in ["vdb", "nvdb"]: + for ext in GRID_TYPES: for comp in [ lagrange.volume.Compression.Uncompressed, lagrange.volume.Compression.Zip, @@ -288,7 +291,7 @@ def make_pair(va_shared, va_only, vb_shared, vb_only): def test_dense(self, cube): mesh = cube.clone() - for ext in ["vdb", "nvdb"]: + for ext in GRID_TYPES: for comp in [ lagrange.volume.Compression.Uncompressed, lagrange.volume.Compression.Zip, diff --git a/modules/volume/volume.md b/modules/volume/volume.md new file mode 100644 index 00000000..37e4b9d2 --- /dev/null +++ b/modules/volume/volume.md @@ -0,0 +1,10 @@ +Volume Module +============ + +@namespace lagrange::volume + +@defgroup module-volume Volume Module +@brief Volumetric mesh representations. + +Convert between meshes and volumetric grids, fill volumes with spheres, and +sample volumetric quantities. diff --git a/modules/winding/CMakeLists.txt b/modules/winding/CMakeLists.txt index c7391bd1..48fc624a 100644 --- a/modules/winding/CMakeLists.txt +++ b/modules/winding/CMakeLists.txt @@ -30,3 +30,8 @@ endif() if(LAGRANGE_EXAMPLES) add_subdirectory(examples) endif() + +# 4. python binding +if(LAGRANGE_MODULE_PYTHON) + add_subdirectory(python) +endif() diff --git a/modules/winding/python/CMakeLists.txt b/modules/winding/python/CMakeLists.txt new file mode 100644 index 00000000..884be284 --- /dev/null +++ b/modules/winding/python/CMakeLists.txt @@ -0,0 +1,12 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +lagrange_add_python_binding() diff --git a/modules/winding/python/include/lagrange/python/winding.h b/modules/winding/python/include/lagrange/python/winding.h new file mode 100644 index 00000000..72fae6e4 --- /dev/null +++ b/modules/winding/python/include/lagrange/python/winding.h @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include + +namespace lagrange::python { + +/// @brief Populate the winding sub-module with Python bindings. +/// @param m The nanobind module to populate. +void populate_winding_module(nanobind::module_& m); + +} // namespace lagrange::python diff --git a/modules/winding/python/src/winding.cpp b/modules/winding/python/src/winding.cpp new file mode 100644 index 00000000..6c5d1a4b --- /dev/null +++ b/modules/winding/python/src/winding.cpp @@ -0,0 +1,132 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include + +// clang-format off +#include +#include +#include +// clang-format on + +#include +#include + +namespace nb = nanobind; +using namespace nb::literals; + +namespace lagrange::python { + +void populate_winding_module(nb::module_& m) +{ + using Scalar = double; + using Index = uint32_t; + using MeshType = SurfaceMesh; + + // Query points provided as an (N, 3) array. + using ConstPoints = nb::ndarray, nb::c_contig, nb::device::cpu>; + + nb::class_( + m, + "FastWindingNumber", + R"(Fast winding number engine for inside/outside queries on triangle soups. + +Builds an acceleration structure over a triangle mesh to answer winding-number +based queries, following the fast winding number method of [Barill et al. 2018]. + +.. note:: + Internally, point coordinates are converted to single precision and vertex + indices are converted to ``int``.)") + .def( + nb::init(), + "mesh"_a, + R"(Construct an acceleration structure for fast winding number queries. + +:param mesh: Input triangle mesh. Must be a 3D triangle mesh.)") + .def( + "is_inside", + [](const winding::FastWindingNumber& self, const std::array& point) { + return self.is_inside(point); + }, + "point"_a, + R"(Determine whether a single query point is inside the volume. + +:param point: Query position as a length-3 sequence. + +:return: True if the point is inside, False otherwise.)") + .def( + "is_inside", + [](const winding::FastWindingNumber& self, ConstPoints points) { + const size_t n = points.shape(0); + const double* data = points.data(); + auto arr = nb::cast>>( + nb::module_::import_("numpy").attr("empty")(n, "dtype"_a = "bool")); + auto v = arr.view(); + { + nb::gil_scoped_release release; + tbb::parallel_for(size_t(0), n, [&](size_t i) { + v(i) = self.is_inside( + {static_cast(data[3 * i + 0]), + static_cast(data[3 * i + 1]), + static_cast(data[3 * i + 2])}); + }); + } + return arr; + }, + "points"_a, + R"(Determine whether each of a batch of query points is inside the volume. + +:param points: Query positions as a NumPy array of shape (N, 3). + +:return: A NumPy array of shape (N,) of booleans, True where the point is inside.)") + .def( + "solid_angle", + [](const winding::FastWindingNumber& self, const std::array& point) { + return self.solid_angle(point); + }, + "point"_a, + R"(Compute the solid angle at a single query point. + +:param point: Query position as a length-3 sequence. + +:return: Solid angle at the query point.)") + .def( + "solid_angle", + [](const winding::FastWindingNumber& self, ConstPoints points) { + const size_t n = points.shape(0); + const double* data = points.data(); + auto arr = nb::cast>>( + nb::module_::import_("numpy").attr("empty")(n, "dtype"_a = "float32")); + auto v = arr.view(); + { + nb::gil_scoped_release release; + tbb::parallel_for(size_t(0), n, [&](size_t i) { + v(i) = self.solid_angle( + {static_cast(data[3 * i + 0]), + static_cast(data[3 * i + 1]), + static_cast(data[3 * i + 2])}); + }); + } + return arr; + }, + "points"_a, + R"(Compute the solid angle at a batch of query points. + +:param points: Query positions as a NumPy array of shape (N, 3). + +:return: A NumPy array of shape (N,) of solid angles at each query point.)"); +} + +} // namespace lagrange::python diff --git a/modules/winding/python/tests/test_fast_winding_number.py b/modules/winding/python/tests/test_fast_winding_number.py new file mode 100644 index 00000000..0a15de1d --- /dev/null +++ b/modules/winding/python/tests/test_fast_winding_number.py @@ -0,0 +1,58 @@ +# +# 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 TestFastWindingNumber: + def test_is_inside_single(self, cube_triangular): + engine = lagrange.winding.FastWindingNumber(cube_triangular) + assert engine.is_inside([0.5, 0.5, 0.5]) + assert not engine.is_inside([2.0, 2.0, 2.0]) + assert not engine.is_inside([-1.0, 0.5, 0.5]) + + def test_is_inside_batch(self, cube_triangular): + engine = lagrange.winding.FastWindingNumber(cube_triangular) + points = np.array( + [ + [0.5, 0.5, 0.5], + [0.1, 0.9, 0.5], + [2.0, 2.0, 2.0], + [-1.0, 0.5, 0.5], + ], + dtype=float, + ) + inside = engine.is_inside(points) + assert inside.shape == (4,) + assert inside.dtype == bool + np.testing.assert_array_equal(inside, [True, True, False, False]) + + def test_solid_angle_single(self, cube_triangular): + engine = lagrange.winding.FastWindingNumber(cube_triangular) + # Solid angle is ~4*pi inside and ~0 outside. + assert engine.solid_angle([0.5, 0.5, 0.5]) == pytest.approx(4.0 * np.pi, abs=1e-3) + assert engine.solid_angle([5.0, 5.0, 5.0]) == pytest.approx(0.0, abs=1e-3) + + def test_solid_angle_batch(self, cube_triangular): + engine = lagrange.winding.FastWindingNumber(cube_triangular) + points = np.array( + [ + [0.5, 0.5, 0.5], + [5.0, 5.0, 5.0], + ], + dtype=float, + ) + angles = engine.solid_angle(points) + assert angles.shape == (2,) + assert angles[0] == pytest.approx(4.0 * np.pi, abs=1e-3) + assert angles[1] == pytest.approx(0.0, abs=1e-3) diff --git a/modules/winding/winding.md b/modules/winding/winding.md new file mode 100644 index 00000000..65f16056 --- /dev/null +++ b/modules/winding/winding.md @@ -0,0 +1,7 @@ +Winding Module +============ + +@defgroup module-winding Winding Module +@brief Fast winding number computation. + +Compute generalized winding numbers for inside/outside queries on meshes. diff --git a/modules/xatlas/CMakeLists.txt b/modules/xatlas/CMakeLists.txt new file mode 100644 index 00000000..46287837 --- /dev/null +++ b/modules/xatlas/CMakeLists.txt @@ -0,0 +1,38 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# + +# 1. define module +lagrange_add_module() + +# 2. dependencies +lagrange_include_modules(scene) +include(xatlas) +target_link_libraries(lagrange_xatlas + PUBLIC + lagrange::core + lagrange::scene + PRIVATE + xatlas::xatlas +) + +# 3. unit tests, examples, python bindings +if(LAGRANGE_UNIT_TESTS) + add_subdirectory(tests) +endif() + +if(LAGRANGE_MODULE_PYTHON) + add_subdirectory(python) +endif() + +if(LAGRANGE_EXAMPLES) + add_subdirectory(examples) +endif() diff --git a/modules/xatlas/examples/CMakeLists.txt b/modules/xatlas/examples/CMakeLists.txt new file mode 100644 index 00000000..bd4e8e2b --- /dev/null +++ b/modules/xatlas/examples/CMakeLists.txt @@ -0,0 +1,16 @@ +# +# 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(cli11) +lagrange_include_modules(io) + +lagrange_add_example(unwrap_xatlas unwrap_xatlas.cpp) +target_link_libraries(unwrap_xatlas lagrange::xatlas lagrange::io CLI11::CLI11) diff --git a/modules/xatlas/examples/unwrap_xatlas.cpp b/modules/xatlas/examples/unwrap_xatlas.cpp new file mode 100644 index 00000000..4639ee99 --- /dev/null +++ b/modules/xatlas/examples/unwrap_xatlas.cpp @@ -0,0 +1,134 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include +#include +#include +#include + +#include + +#include + +int main(int argc, char** argv) +{ + using namespace lagrange; + using Scalar = float; + using Index = uint32_t; + using SurfaceMesh = lagrange::SurfaceMesh; + + const std::map multi_atlas_policy_map{ + {"normalize", xatlas::MultiAtlasPolicy::NormalizePerTile}, + {"udim", xatlas::MultiAtlasPolicy::Udim}, + {"error", xatlas::MultiAtlasPolicy::ErrorIfMultiple}, + }; + + struct + { + fs::path input; + fs::path output = "output.ply"; + fs::path uv_mesh_output; + std::string uv_attribute_name = "texcoord"; + std::string atlas_attribute_name; + std::string chart_attribute_name; + xatlas::MultiAtlasPolicy multi_atlas_policy = xatlas::MultiAtlasPolicy::NormalizePerTile; + bool enable_sharing_uvs_between_instances = false; + int log_level = 2; + } args; + + CLI::App app{argv[0]}; + app.option_defaults()->always_capture_default(); + app.add_option("input,-i,--input", args.input, "Input mesh.") + ->required() + ->check(CLI::ExistingFile); + app.add_option("output,-o,--output", args.output, "Output unwrapped mesh."); + app.add_option( + "--uv-mesh", + args.uv_mesh_output, + "Output the UV layout as a flat mesh (vertices are the UV coordinates)."); + app.add_option("--uv-attr-name", args.uv_attribute_name, "UV attribute name."); + app.add_option("--atlas-attr-name", args.atlas_attribute_name, "Atlas attribute name."); + app.add_option("--chart-attr-name", args.chart_attribute_name, "Chart attribute name."); + app.add_option( + "--multi-atlas-policy", + args.multi_atlas_policy, + "Multi-atlas policy: 'normalize', 'udim', or 'error'.") + ->transform(CLI::CheckedTransformer(multi_atlas_policy_map, CLI::ignore_case)); + app.add_flag( + "--share-uvs,!--no-share-uvs", + args.enable_sharing_uvs_between_instances, + "Share UVs between instances of the same mesh."); + app.add_option("-l,--log-level", args.log_level, "Log level."); + + CLI11_PARSE(app, argc, argv) + + logger().set_level(static_cast(args.log_level)); + + + logger().info("Loading input mesh \"{}\"", args.input.string()); + auto mesh = io::load_mesh(args.input); + logger().info( + "{}v {}f{}", + mesh.get_num_vertices(), + mesh.get_num_facets(), + mesh.is_triangle_mesh() ? " tri" : ""); + + xatlas::UnwrapOptions options; + options.output_uv_attribute_name = args.uv_attribute_name; + options.output_atlas_attribute_name = args.atlas_attribute_name; + options.output_chart_attribute_name = args.chart_attribute_name; + options.enable_sharing_uvs_between_instances = args.enable_sharing_uvs_between_instances; + options.multi_atlas_policy = args.multi_atlas_policy; + + logger().info("Unwrapping mesh with xatlas"); + + auto mesh_ = xatlas::unwrap_mesh(mesh, options, [](const std::string& msg, float progress) { + logger().debug("[{:03.0f}%] {}", progress * 100.f, msg); + }); + + la_runtime_assert(mesh_.has_attribute(args.uv_attribute_name)); + la_runtime_assert(mesh_.is_attribute_indexed(args.uv_attribute_name)); + la_runtime_assert(mesh_.template is_attribute_type(args.uv_attribute_name)); + + if (!args.atlas_attribute_name.empty()) { + la_runtime_assert(mesh_.has_attribute(args.atlas_attribute_name)); + la_runtime_assert(!mesh_.is_attribute_indexed(args.atlas_attribute_name)); + la_runtime_assert(mesh_.template is_attribute_type(args.atlas_attribute_name)); + } + + if (!args.chart_attribute_name.empty()) { + la_runtime_assert(mesh_.has_attribute(args.chart_attribute_name)); + la_runtime_assert(!mesh_.is_attribute_indexed(args.chart_attribute_name)); + la_runtime_assert(mesh_.template is_attribute_type(args.chart_attribute_name)); + } + + if (!args.output.empty()) { + logger().info("Saving output mesh \"{}\"", args.output.string()); + io::SaveOptions save_options; + save_options.attribute_conversion_policy = + io::SaveOptions::AttributeConversionPolicy::ConvertAsNeeded; + io::save_mesh(args.output, mesh_, save_options); + } + + if (!args.uv_mesh_output.empty()) { + logger().info("Saving UV mesh \"{}\"", args.uv_mesh_output.string()); + + // Extract a flat mesh whose vertex positions are the UV coordinates. + UVMeshOptions uv_mesh_options; + uv_mesh_options.uv_attribute_name = args.uv_attribute_name; + auto uv_mesh = uv_mesh_view(mesh_, uv_mesh_options); + + io::save_mesh(args.uv_mesh_output, uv_mesh); + } + + return 0; +} diff --git a/modules/xatlas/include/lagrange/xatlas/Options.h b/modules/xatlas/include/lagrange/xatlas/Options.h new file mode 100644 index 00000000..d9a66923 --- /dev/null +++ b/modules/xatlas/include/lagrange/xatlas/Options.h @@ -0,0 +1,207 @@ +/* + * 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::xatlas { + +/// +/// Chart computation options. Maps 1-to-1 onto `xatlas::ChartOptions`. +/// +/// Charts are extracted by greedily growing seed faces while a cost function is below +/// `max_cost`. The cost is a weighted sum of normal deviation, roundness, straightness, and seam +/// terms; tuning the weights tunes the chart shapes. +/// +struct ChartOptions +{ + /// Maximum chart area, in input units squared. Set to 0 (default) to disable the limit. + float max_chart_area = 0.f; + + /// Maximum chart boundary length, in input units. Set to 0 (default) to disable the limit. + float max_boundary_length = 0.f; + + /// Weight applied to normal deviation when growing charts. Higher values produce flatter + /// charts. + float normal_deviation_weight = 2.f; + + /// Weight applied to chart roundness. Higher values prefer compact (disk-like) charts. + float roundness_weight = 0.01f; + + /// Weight applied to chart boundary straightness. Higher values prefer straight cuts. + float straightness_weight = 6.f; + + /// Weight applied to crossing input normal seams. Values > 1000 effectively forbid crossing + /// normal seams. + float normal_seam_weight = 4.f; + + /// Weight applied to crossing input UV seams (when `use_input_mesh_uvs` is true). + float texture_seam_weight = 0.5f; + + /// Maximum allowed chart cost. Faces whose addition would exceed this cost are not merged. + float max_cost = 2.f; + + /// Maximum number of segmentation iterations. Higher values may produce better charts but cost + /// more time. + uint32_t max_iterations = 1; + + /// If true, treat the input UV attribute as the chart layout (faces with the same UV island + /// belong to the same chart). + bool use_input_mesh_uvs = false; + + /// If true, fix triangle winding so all charts have a consistent orientation. + bool fix_winding = true; +}; + +/// +/// Packing options. Maps 1-to-1 onto `xatlas::PackOptions`. +/// +struct PackOptions +{ + /// Maximum chart size, in pixels. Charts larger than this are scaled down. 0 (default) + /// means no limit. + uint32_t max_chart_size = 0; + + /// Empty pixels of padding to leave around each chart. Useful for mip-map filtering. + uint32_t padding = 0; + + /// Texel-to-world-unit ratio. If 0, xatlas estimates a value such that the entire atlas + /// fits within `resolution` x `resolution` (or a single tile of any size if `resolution` is + /// also 0). + float texels_per_unit = 0.f; + + /// Atlas tile resolution, in pixels (square). 0 (default) means a single atlas sized by + /// `texels_per_unit`. If both are non-zero, charts that don't fit produce additional tiles. + uint32_t resolution = 0; + + /// If true, leave a 1-pixel safety margin so bilinear filtering doesn't sample neighbouring + /// charts. + bool bilinear = true; + + /// If true, align charts to 4-pixel blocks (useful for compressed texture formats like BCn). + bool block_align = false; + + /// If true, use brute-force search to find optimal chart placement (significantly slower). + bool brute_force = false; + + /// If true, rotate each chart so that its longest principal axis aligns with the U or V axis. + bool rotate_charts_to_axis = true; + + /// If true, allow xatlas to rotate charts during packing (in addition to axis alignment). + bool rotate_charts = true; +}; + +/// +/// How to encode multi-atlas output in the returned UV attribute. +/// +/// xatlas may produce more than one atlas tile when charts don't fit at the requested +/// `texels_per_unit`. This enum controls how the per-corner UVs encode the tile id. +/// +enum class MultiAtlasPolicy { + /// UVs are normalized inside each atlas tile (`[0, 1] x [0, 1]`). Tile id is stored + /// separately as a corner attribute. + NormalizePerTile, + + /// UVs include integer U offset = `atlasIndex`; V stays normalized in `[0, 1]`. Useful for + /// downstream UDIM-style pipelines. + Udim, + + /// Throw `lagrange::Error` if xatlas produces more than one atlas tile. + ErrorIfMultiple, +}; + +/// +/// Options controlling a full unwrap (segment + parameterize + pack). +/// +struct UnwrapOptions +{ + /// Chart computation options. + ChartOptions chart; + + /// Packing options. + PackOptions packing; + + /// Optional input UV attribute name. When non-empty, the UV values are passed to xatlas as a + /// per-vertex hint (regardless of `chart.use_input_mesh_uvs`). When + /// `chart.use_input_mesh_uvs = true`, xatlas uses the UV islands directly as charts. + /// May be a per-vertex or indexed UV attribute. Indexed UVs are handled internally via + /// `lagrange::unify_index_buffer()` so seams are preserved. + std::string_view input_uv_attribute_name; + + /// Optional input per-vertex normal attribute name. When set, xatlas uses these normals as a + /// hint for chart segmentation. Must be a non-indexed vertex attribute (float or double). + std::string_view input_normal_attribute_name; + + /// Multi-atlas encoding policy for the output UV attribute. + MultiAtlasPolicy multi_atlas_policy = MultiAtlasPolicy::NormalizePerTile; + + /// Output indexed UV attribute name. If the attribute already exists and is `float` or + /// `double`, its value type is preserved; otherwise it defaults to the mesh `Scalar` type. + std::string_view output_uv_attribute_name = "@uv"; + + /// Output corner attribute name for the per-corner xatlas atlas tile id (only written when attribute name is non-empty). + std::string_view output_atlas_attribute_name = ""; + + /// Output corner attribute name for the per-corner xatlas chart id (only written when attribute name is non-empty). + std::string_view output_chart_attribute_name = ""; + + /// Whether to share generated UVs between instances of the same scene mesh. When false, each + /// scene instance is unwrapped independently with its instance transform baked in. + bool enable_sharing_uvs_between_instances = false; +}; + +/// +/// Options controlling a repack (existing UVs → new packing, no chart computation). +/// +struct RepackOptions +{ + /// Packing options. + PackOptions packing; + + /// Input indexed UV attribute name. Must be an indexed `float` or `double` UV attribute. + std::string_view input_uv_attribute_name; + + /// Optional per-facet chart id attribute name. When set, each input UV island is pinned to its + /// own chart; otherwise xatlas re-derives charts from shared UV edges. + std::string_view input_chart_attribute_name; + + /// Multi-atlas encoding policy for the output UV attribute. + MultiAtlasPolicy multi_atlas_policy = MultiAtlasPolicy::NormalizePerTile; + + /// Output indexed UV attribute name. If the attribute already exists, its value type is + /// preserved; otherwise a new attribute is created using the mesh `Scalar` type. + std::string_view output_uv_attribute_name = "@uv"; + + /// Output corner attribute name for the per-corner xatlas atlas tile id (only written when attribute name is non-empty). + std::string_view output_atlas_attribute_name = ""; + + /// Output corner attribute name for the per-corner xatlas chart id (only written when attribute name is non-empty). + std::string_view output_chart_attribute_name = ""; + + /// Whether to share generated UVs between instances of the same scene mesh. When false, each + /// scene instance is repacked independently. + bool enable_sharing_uvs_between_instances = false; +}; + +/// +/// Scene-specific options for `unwrap_scene` / `repack_scene`. +/// +struct SceneOptions +{ + /// Optional per-instance importance weights. Size must be 0 or equal to the number of scene + /// instances; values must be `> 0` and finite. Affects `unwrap_scene` only. + std::vector per_instance_importance; +}; + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/include/lagrange/xatlas/api.h b/modules/xatlas/include/lagrange/xatlas/api.h new file mode 100644 index 00000000..65b394d2 --- /dev/null +++ b/modules/xatlas/include/lagrange/xatlas/api.h @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#ifdef LA_XATLAS_STATIC_DEFINE + #define LA_XATLAS_API +#else + #ifndef LA_XATLAS_API + #ifdef lagrange_xatlas_EXPORTS + // We are building this library + #if defined(_WIN32) || defined(_WIN64) + #define LA_XATLAS_API __declspec(dllexport) + #else + #define LA_XATLAS_API __attribute__((visibility("default"))) + #endif + #else + // We are using this library + #if defined(_WIN32) || defined(_WIN64) + #define LA_XATLAS_API __declspec(dllimport) + #else + #define LA_XATLAS_API __attribute__((visibility("default"))) + #endif + #endif + #endif +#endif diff --git a/modules/xatlas/include/lagrange/xatlas/repack_mesh.h b/modules/xatlas/include/lagrange/xatlas/repack_mesh.h new file mode 100644 index 00000000..7ef2d226 --- /dev/null +++ b/modules/xatlas/include/lagrange/xatlas/repack_mesh.h @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace lagrange::xatlas { + +/// +/// Repack the existing UV islands of a mesh using xatlas. +/// +/// @param[in] mesh Mesh whose existing indexed UV attribute will be repacked. Must +/// be triangle-only. +/// @param[in] options Repack options. +/// @param[in] notification_func Optional progress callback. +/// @param[in] cancel Optional cancellation flag. +/// +/// @tparam Scalar Scene mesh scalar type. +/// @tparam Index Scene mesh index type. +/// +/// @return Mesh with the repacked indexed UV attribute under `options.output_uv_attribute_name`. +/// +template +LA_XATLAS_API SurfaceMesh repack_mesh( + const SurfaceMesh& mesh, + const RepackOptions& options = {}, + std::function notification_func = nullptr, + const std::atomic_bool* cancel = nullptr); + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/include/lagrange/xatlas/repack_scene.h b/modules/xatlas/include/lagrange/xatlas/repack_scene.h new file mode 100644 index 00000000..911d2fa4 --- /dev/null +++ b/modules/xatlas/include/lagrange/xatlas/repack_scene.h @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace lagrange::xatlas { + +/// +/// Repack all UV islands of a scene using xatlas. +/// +/// Each mesh (when sharing UVs) or each instance (when not sharing) is repacked independently. +/// +/// @param[in] scene Scene whose meshes already have an indexed UV attribute. +/// @param[in] repack_options Per-mesh repack options. +/// @param[in] scene_options Scene-specific options. +/// @param[in] notification_func Optional progress callback. +/// @param[in] cancel Optional cancellation flag. +/// +/// @tparam Scalar Scene mesh scalar type. +/// @tparam Index Scene mesh index type. +/// +/// @return A new scene with the same instance topology and repacked UV attributes. +/// +template +LA_XATLAS_API scene::SimpleScene repack_scene( + const scene::SimpleScene& scene, + const RepackOptions& repack_options = {}, + const SceneOptions& scene_options = {}, + std::function notification_func = nullptr, + const std::atomic_bool* cancel = nullptr); + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/include/lagrange/xatlas/unwrap_mesh.h b/modules/xatlas/include/lagrange/xatlas/unwrap_mesh.h new file mode 100644 index 00000000..6f9cbdba --- /dev/null +++ b/modules/xatlas/include/lagrange/xatlas/unwrap_mesh.h @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace lagrange::xatlas { + +/// +/// Unwrap a single mesh using xatlas. +/// +/// @param[in] mesh Mesh to unwrap. Must be triangle-only. +/// @param[in] options Unwrap options. +/// @param[in] notification_func Optional progress callback. +/// @param[in] cancel Optional cancellation flag. +/// +/// @tparam Scalar Scene mesh scalar type. +/// @tparam Index Scene mesh index type. +/// +/// @return Mesh with the new indexed UV attribute (and optional atlas/chart index attributes). +/// +template +LA_XATLAS_API SurfaceMesh unwrap_mesh( + const SurfaceMesh& mesh, + const UnwrapOptions& options = {}, + std::function notification_func = nullptr, + const std::atomic_bool* cancel = nullptr); + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/include/lagrange/xatlas/unwrap_scene.h b/modules/xatlas/include/lagrange/xatlas/unwrap_scene.h new file mode 100644 index 00000000..1546e1de --- /dev/null +++ b/modules/xatlas/include/lagrange/xatlas/unwrap_scene.h @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace lagrange::xatlas { + +/// +/// Unwrap all meshes in a scene using xatlas. +/// +/// Each mesh (when sharing UVs) or each instance (when not sharing) is unwrapped independently as +/// its own xatlas atlas. +/// +/// @param[in] scene Input scene. +/// @param[in] unwrap_options Per-mesh unwrap options. +/// @param[in] scene_options Scene-specific options. +/// @param[in] notification_func Optional progress callback. +/// @param[in] cancel Optional cancellation flag. +/// +/// @tparam Scalar Scene mesh scalar type. +/// @tparam Index Scene mesh index type. +/// +/// @return A new scene with the same instance topology and new indexed UV attributes. +/// +template +LA_XATLAS_API scene::SimpleScene unwrap_scene( + const scene::SimpleScene& scene, + const UnwrapOptions& unwrap_options = {}, + const SceneOptions& scene_options = {}, + std::function notification_func = nullptr, + const std::atomic_bool* cancel = nullptr); + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/python/CMakeLists.txt b/modules/xatlas/python/CMakeLists.txt new file mode 100644 index 00000000..884be284 --- /dev/null +++ b/modules/xatlas/python/CMakeLists.txt @@ -0,0 +1,12 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +lagrange_add_python_binding() diff --git a/modules/xatlas/python/include/lagrange/python/xatlas.h b/modules/xatlas/python/include/lagrange/python/xatlas.h new file mode 100644 index 00000000..665b2c46 --- /dev/null +++ b/modules/xatlas/python/include/lagrange/python/xatlas.h @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include + +namespace lagrange::python { +void populate_xatlas_module(nanobind::module_& m); +} diff --git a/modules/xatlas/python/src/xatlas.cpp b/modules/xatlas/python/src/xatlas.cpp new file mode 100644 index 00000000..7cf51921 --- /dev/null +++ b/modules/xatlas/python/src/xatlas.cpp @@ -0,0 +1,561 @@ +/* + * 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 + +namespace lagrange::python { + +namespace nb = nanobind; + +namespace { + +using namespace lagrange::xatlas; + +UnwrapOptions make_unwrap_options( + // ChartOptions + float max_chart_area, + float max_boundary_length, + float normal_deviation_weight, + float roundness_weight, + float straightness_weight, + float normal_seam_weight, + float texture_seam_weight, + float max_cost, + uint32_t max_iterations, + bool use_input_mesh_uvs, + bool fix_winding, + // PackOptions + uint32_t max_chart_size, + uint32_t padding, + float texels_per_unit, + uint32_t resolution, + bool bilinear, + bool block_align, + bool brute_force, + bool rotate_charts_to_axis, + bool rotate_charts, + // UnwrapOptions + std::string_view input_normal_attribute_name, + std::string_view input_uv_attribute_name, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name, + MultiAtlasPolicy multi_atlas_policy, + bool enable_sharing_uvs_between_instances) +{ + UnwrapOptions opts; + opts.chart.max_chart_area = max_chart_area; + opts.chart.max_boundary_length = max_boundary_length; + opts.chart.normal_deviation_weight = normal_deviation_weight; + opts.chart.roundness_weight = roundness_weight; + opts.chart.straightness_weight = straightness_weight; + opts.chart.normal_seam_weight = normal_seam_weight; + opts.chart.texture_seam_weight = texture_seam_weight; + opts.chart.max_cost = max_cost; + opts.chart.max_iterations = max_iterations; + opts.chart.use_input_mesh_uvs = use_input_mesh_uvs; + opts.chart.fix_winding = fix_winding; + opts.packing.max_chart_size = max_chart_size; + opts.packing.padding = padding; + opts.packing.texels_per_unit = texels_per_unit; + opts.packing.resolution = resolution; + opts.packing.bilinear = bilinear; + opts.packing.block_align = block_align; + opts.packing.brute_force = brute_force; + opts.packing.rotate_charts_to_axis = rotate_charts_to_axis; + opts.packing.rotate_charts = rotate_charts; + opts.input_normal_attribute_name = input_normal_attribute_name; + opts.input_uv_attribute_name = input_uv_attribute_name; + opts.output_uv_attribute_name = output_uv_attribute_name; + opts.output_atlas_attribute_name = output_atlas_attribute_name; + opts.output_chart_attribute_name = output_chart_attribute_name; + opts.multi_atlas_policy = multi_atlas_policy; + opts.enable_sharing_uvs_between_instances = enable_sharing_uvs_between_instances; + return opts; +} + +RepackOptions make_repack_options( + // PackOptions + uint32_t max_chart_size, + uint32_t padding, + float texels_per_unit, + uint32_t resolution, + bool bilinear, + bool block_align, + bool brute_force, + bool rotate_charts_to_axis, + bool rotate_charts, + // RepackOptions + std::string_view input_uv_attribute_name, + std::string_view input_chart_attribute_name, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name, + MultiAtlasPolicy multi_atlas_policy, + bool enable_sharing_uvs_between_instances) +{ + RepackOptions opts; + opts.packing.max_chart_size = max_chart_size; + opts.packing.padding = padding; + opts.packing.texels_per_unit = texels_per_unit; + opts.packing.resolution = resolution; + opts.packing.bilinear = bilinear; + opts.packing.block_align = block_align; + opts.packing.brute_force = brute_force; + opts.packing.rotate_charts_to_axis = rotate_charts_to_axis; + opts.packing.rotate_charts = rotate_charts; + opts.input_uv_attribute_name = input_uv_attribute_name; + opts.input_chart_attribute_name = input_chart_attribute_name; + opts.output_uv_attribute_name = output_uv_attribute_name; + opts.output_atlas_attribute_name = output_atlas_attribute_name; + opts.output_chart_attribute_name = output_chart_attribute_name; + opts.multi_atlas_policy = multi_atlas_policy; + opts.enable_sharing_uvs_between_instances = enable_sharing_uvs_between_instances; + return opts; +} + +} // namespace + +void populate_xatlas_module(nb::module_& m) +{ + using Scalar = double; + using Index = uint32_t; + using namespace nb::literals; + + nb::enum_(m, "MultiAtlasPolicy", "How to encode multi-atlas output.") + .value( + "NormalizePerTile", + MultiAtlasPolicy::NormalizePerTile, + "UVs are normalized inside each atlas tile; tile id is stored separately.") + .value( + "Udim", + MultiAtlasPolicy::Udim, + "UVs include integer U offset = atlasIndex; V stays normalized in [0, 1].") + .value( + "ErrorIfMultiple", + MultiAtlasPolicy::ErrorIfMultiple, + "Throw if xatlas produces more than one atlas tile.") + .export_values(); + + static const ChartOptions chart_defaults{}; + static const PackOptions pack_defaults{}; + static const UnwrapOptions unwrap_defaults{}; + static const RepackOptions repack_defaults{}; + + m.def( + "unwrap_mesh", + [](const SurfaceMesh& mesh, + // ChartOptions + float max_chart_area, + float max_boundary_length, + float normal_deviation_weight, + float roundness_weight, + float straightness_weight, + float normal_seam_weight, + float texture_seam_weight, + float max_cost, + uint32_t max_iterations, + bool use_input_mesh_uvs, + bool fix_winding, + // PackOptions + uint32_t max_chart_size, + uint32_t padding, + float texels_per_unit, + uint32_t resolution, + bool bilinear, + bool block_align, + bool brute_force, + bool rotate_charts_to_axis, + bool rotate_charts, + // UnwrapOptions + std::string_view input_normal_attribute_name, + std::string_view input_uv_attribute_name, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name, + MultiAtlasPolicy multi_atlas_policy, + bool enable_sharing_uvs_between_instances) { + auto opts = make_unwrap_options( + max_chart_area, + max_boundary_length, + normal_deviation_weight, + roundness_weight, + straightness_weight, + normal_seam_weight, + texture_seam_weight, + max_cost, + max_iterations, + use_input_mesh_uvs, + fix_winding, + max_chart_size, + padding, + texels_per_unit, + resolution, + bilinear, + block_align, + brute_force, + rotate_charts_to_axis, + rotate_charts, + input_normal_attribute_name, + input_uv_attribute_name, + output_uv_attribute_name, + output_atlas_attribute_name, + output_chart_attribute_name, + multi_atlas_policy, + enable_sharing_uvs_between_instances); + return unwrap_mesh(mesh, opts); + }, + "mesh"_a, + nb::kw_only(), + "max_chart_area"_a = chart_defaults.max_chart_area, + "max_boundary_length"_a = chart_defaults.max_boundary_length, + "normal_deviation_weight"_a = chart_defaults.normal_deviation_weight, + "roundness_weight"_a = chart_defaults.roundness_weight, + "straightness_weight"_a = chart_defaults.straightness_weight, + "normal_seam_weight"_a = chart_defaults.normal_seam_weight, + "texture_seam_weight"_a = chart_defaults.texture_seam_weight, + "max_cost"_a = chart_defaults.max_cost, + "max_iterations"_a = chart_defaults.max_iterations, + "use_input_mesh_uvs"_a = chart_defaults.use_input_mesh_uvs, + "fix_winding"_a = chart_defaults.fix_winding, + "max_chart_size"_a = pack_defaults.max_chart_size, + "padding"_a = pack_defaults.padding, + "texels_per_unit"_a = pack_defaults.texels_per_unit, + "resolution"_a = pack_defaults.resolution, + "bilinear"_a = pack_defaults.bilinear, + "block_align"_a = pack_defaults.block_align, + "brute_force"_a = pack_defaults.brute_force, + "rotate_charts_to_axis"_a = pack_defaults.rotate_charts_to_axis, + "rotate_charts"_a = pack_defaults.rotate_charts, + "input_normal_attribute_name"_a = "", + "input_uv_attribute_name"_a = "", + "output_uv_attribute_name"_a = unwrap_defaults.output_uv_attribute_name, + "output_atlas_attribute_name"_a = unwrap_defaults.output_atlas_attribute_name, + "output_chart_attribute_name"_a = unwrap_defaults.output_chart_attribute_name, + "multi_atlas_policy"_a = unwrap_defaults.multi_atlas_policy, + "enable_sharing_uvs_between_instances"_a = + unwrap_defaults.enable_sharing_uvs_between_instances, + R"(Unwrap a single mesh using xatlas (segmentation + parameterization + packing). + +The mesh must be triangle-only. All option fields are passed as keyword arguments; defaults match +those of the underlying xatlas C library. + +:param mesh: Mesh to unwrap. + +Chart options (segmentation): + +:param max_chart_area: Maximum chart area in input units squared. 0 = no limit. +:param max_boundary_length: Maximum chart boundary length. 0 = no limit. +:param normal_deviation_weight: Cost weight on normal deviation; higher = flatter charts. +:param roundness_weight: Cost weight on roundness; higher = more disk-like charts. +:param straightness_weight: Cost weight on boundary straightness. +:param normal_seam_weight: Cost weight on crossing normal seams. >1000 forbids crossing. +:param texture_seam_weight: Cost weight on crossing input UV seams. +:param max_cost: Maximum cost; faces beyond this are not merged. +:param max_iterations: Number of segmentation iterations. +:param use_input_mesh_uvs: If True, treat the input UV attribute as the chart layout. +:param fix_winding: If True, ensure consistent triangle winding within charts. + +Pack options: + +:param max_chart_size: Maximum chart size in pixels. 0 = no limit. +:param padding: Empty pixels of padding around each chart. +:param texels_per_unit: Texel-to-world ratio. 0 = auto-estimate. +:param resolution: Atlas tile resolution in pixels. 0 = single tile sized by texels_per_unit. +:param bilinear: If True, leave a 1-pixel margin for safe bilinear filtering. +:param block_align: If True, align charts to 4-pixel blocks (BCn-friendly). +:param brute_force: If True, brute-force optimal placement (slow). +:param rotate_charts_to_axis: Rotate charts so longest axis aligns with U or V. +:param rotate_charts: Allow xatlas to rotate charts during packing. + +Output options: + +:param input_normal_attribute_name: Optional vertex normal attribute used as a chart hint. +:param input_uv_attribute_name: Optional UV attribute (vertex or indexed) used as a chart hint. +:param output_uv_attribute_name: Output indexed UV attribute name. +:param output_atlas_attribute_name: Corner-attribute name for the per-corner atlas tile id. +:param output_chart_attribute_name: Corner-attribute name for the per-corner chart id. +:param multi_atlas_policy: How to encode multi-atlas output (see MultiAtlasPolicy). +:param enable_sharing_uvs_between_instances: Scene-only; ignored for single-mesh unwrap. + +:returns: A new mesh with the indexed UV attribute (and optional atlas/chart attributes). +)"); + + m.def( + "repack_mesh", + [](const SurfaceMesh& mesh, + uint32_t max_chart_size, + uint32_t padding, + float texels_per_unit, + uint32_t resolution, + bool bilinear, + bool block_align, + bool brute_force, + bool rotate_charts_to_axis, + bool rotate_charts, + std::string_view input_uv_attribute_name, + std::string_view input_chart_attribute_name, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name, + MultiAtlasPolicy multi_atlas_policy, + bool enable_sharing_uvs_between_instances) { + auto opts = make_repack_options( + max_chart_size, + padding, + texels_per_unit, + resolution, + bilinear, + block_align, + brute_force, + rotate_charts_to_axis, + rotate_charts, + input_uv_attribute_name, + input_chart_attribute_name, + output_uv_attribute_name, + output_atlas_attribute_name, + output_chart_attribute_name, + multi_atlas_policy, + enable_sharing_uvs_between_instances); + return repack_mesh(mesh, opts); + }, + "mesh"_a, + nb::kw_only(), + "max_chart_size"_a = pack_defaults.max_chart_size, + "padding"_a = pack_defaults.padding, + "texels_per_unit"_a = pack_defaults.texels_per_unit, + "resolution"_a = pack_defaults.resolution, + "bilinear"_a = pack_defaults.bilinear, + "block_align"_a = pack_defaults.block_align, + "brute_force"_a = pack_defaults.brute_force, + "rotate_charts_to_axis"_a = pack_defaults.rotate_charts_to_axis, + "rotate_charts"_a = pack_defaults.rotate_charts, + "input_uv_attribute_name"_a = repack_defaults.input_uv_attribute_name, + "input_chart_attribute_name"_a = repack_defaults.input_chart_attribute_name, + "output_uv_attribute_name"_a = repack_defaults.output_uv_attribute_name, + "output_atlas_attribute_name"_a = repack_defaults.output_atlas_attribute_name, + "output_chart_attribute_name"_a = repack_defaults.output_chart_attribute_name, + "multi_atlas_policy"_a = repack_defaults.multi_atlas_policy, + "enable_sharing_uvs_between_instances"_a = + repack_defaults.enable_sharing_uvs_between_instances, + R"(Repack the existing UV islands of a mesh using xatlas. + +The mesh must be triangle-only and have an existing indexed UV attribute. All option fields are +passed as keyword arguments. + +:param mesh: Triangle mesh with an existing indexed UV attribute. +:param max_chart_size: See PackOptions.max_chart_size. +:param padding: See PackOptions.padding. +:param texels_per_unit: See PackOptions.texels_per_unit. +:param resolution: See PackOptions.resolution. +:param bilinear: See PackOptions.bilinear. +:param block_align: See PackOptions.block_align. +:param brute_force: See PackOptions.brute_force. +:param rotate_charts_to_axis: See PackOptions.rotate_charts_to_axis. +:param rotate_charts: See PackOptions.rotate_charts. +:param input_uv_attribute_name: Input indexed UV attribute name. +:param input_chart_attribute_name: Optional per-facet chart id attribute. When set, each input UV island + is pinned to its own chart (forwarded to xatlas as faceMaterialData). If empty (default), + xatlas re-derives charts by flood-filling faces across shared UV edges, which can merge + logically-distinct islands that touch/overlap in UV space and cause the packer to overlap or + flip them. Set this to make repack preserve the existing island decomposition. +:param output_uv_attribute_name: Output indexed UV attribute name. If the attribute already exists, + its value type is preserved; otherwise, a new attribute is created using the mesh Scalar type. +:param output_atlas_attribute_name: Corner-attribute name for the per-corner atlas tile id. +:param output_chart_attribute_name: Corner-attribute name for the per-corner chart id. +:param multi_atlas_policy: How to encode multi-atlas output. +:param enable_sharing_uvs_between_instances: Scene-only; ignored for single-mesh repack. + +:returns: A new mesh with the repacked UV attribute. +)"); + + m.def( + "unwrap_scene", + [](const scene::SimpleScene& scene, + // chart + float max_chart_area, + float max_boundary_length, + float normal_deviation_weight, + float roundness_weight, + float straightness_weight, + float normal_seam_weight, + float texture_seam_weight, + float max_cost, + uint32_t max_iterations, + bool use_input_mesh_uvs, + bool fix_winding, + // pack + uint32_t max_chart_size, + uint32_t padding, + float texels_per_unit, + uint32_t resolution, + bool bilinear, + bool block_align, + bool brute_force, + bool rotate_charts_to_axis, + bool rotate_charts, + // unwrap + std::string_view input_normal_attribute_name, + std::string_view input_uv_attribute_name, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name, + MultiAtlasPolicy multi_atlas_policy, + bool enable_sharing_uvs_between_instances, + // scene + std::vector per_instance_importance) { + auto opts = make_unwrap_options( + max_chart_area, + max_boundary_length, + normal_deviation_weight, + roundness_weight, + straightness_weight, + normal_seam_weight, + texture_seam_weight, + max_cost, + max_iterations, + use_input_mesh_uvs, + fix_winding, + max_chart_size, + padding, + texels_per_unit, + resolution, + bilinear, + block_align, + brute_force, + rotate_charts_to_axis, + rotate_charts, + input_normal_attribute_name, + input_uv_attribute_name, + output_uv_attribute_name, + output_atlas_attribute_name, + output_chart_attribute_name, + multi_atlas_policy, + enable_sharing_uvs_between_instances); + SceneOptions sopts; + sopts.per_instance_importance = std::move(per_instance_importance); + return unwrap_scene(scene, opts, sopts); + }, + "scene"_a, + nb::kw_only(), + "max_chart_area"_a = chart_defaults.max_chart_area, + "max_boundary_length"_a = chart_defaults.max_boundary_length, + "normal_deviation_weight"_a = chart_defaults.normal_deviation_weight, + "roundness_weight"_a = chart_defaults.roundness_weight, + "straightness_weight"_a = chart_defaults.straightness_weight, + "normal_seam_weight"_a = chart_defaults.normal_seam_weight, + "texture_seam_weight"_a = chart_defaults.texture_seam_weight, + "max_cost"_a = chart_defaults.max_cost, + "max_iterations"_a = chart_defaults.max_iterations, + "use_input_mesh_uvs"_a = chart_defaults.use_input_mesh_uvs, + "fix_winding"_a = chart_defaults.fix_winding, + "max_chart_size"_a = pack_defaults.max_chart_size, + "padding"_a = pack_defaults.padding, + "texels_per_unit"_a = pack_defaults.texels_per_unit, + "resolution"_a = pack_defaults.resolution, + "bilinear"_a = pack_defaults.bilinear, + "block_align"_a = pack_defaults.block_align, + "brute_force"_a = pack_defaults.brute_force, + "rotate_charts_to_axis"_a = pack_defaults.rotate_charts_to_axis, + "rotate_charts"_a = pack_defaults.rotate_charts, + "input_normal_attribute_name"_a = "", + "input_uv_attribute_name"_a = "", + "output_uv_attribute_name"_a = unwrap_defaults.output_uv_attribute_name, + "output_atlas_attribute_name"_a = unwrap_defaults.output_atlas_attribute_name, + "output_chart_attribute_name"_a = unwrap_defaults.output_chart_attribute_name, + "multi_atlas_policy"_a = unwrap_defaults.multi_atlas_policy, + "enable_sharing_uvs_between_instances"_a = + unwrap_defaults.enable_sharing_uvs_between_instances, + "per_instance_importance"_a = std::vector(), + R"(Unwrap all meshes in a scene using xatlas. See unwrap_mesh() for parameter docs. + +Scene-specific kwargs: + +:param per_instance_importance: Optional list of per-instance importance weights (>0, finite). +)"); + + m.def( + "repack_scene", + [](const scene::SimpleScene& scene, + uint32_t max_chart_size, + uint32_t padding, + float texels_per_unit, + uint32_t resolution, + bool bilinear, + bool block_align, + bool brute_force, + bool rotate_charts_to_axis, + bool rotate_charts, + std::string_view input_uv_attribute_name, + std::string_view input_chart_attribute_name, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name, + MultiAtlasPolicy multi_atlas_policy, + bool enable_sharing_uvs_between_instances, + std::vector per_instance_importance) { + auto opts = make_repack_options( + max_chart_size, + padding, + texels_per_unit, + resolution, + bilinear, + block_align, + brute_force, + rotate_charts_to_axis, + rotate_charts, + input_uv_attribute_name, + input_chart_attribute_name, + output_uv_attribute_name, + output_atlas_attribute_name, + output_chart_attribute_name, + multi_atlas_policy, + enable_sharing_uvs_between_instances); + SceneOptions sopts; + sopts.per_instance_importance = std::move(per_instance_importance); + return repack_scene(scene, opts, sopts); + }, + "scene"_a, + nb::kw_only(), + "max_chart_size"_a = pack_defaults.max_chart_size, + "padding"_a = pack_defaults.padding, + "texels_per_unit"_a = pack_defaults.texels_per_unit, + "resolution"_a = pack_defaults.resolution, + "bilinear"_a = pack_defaults.bilinear, + "block_align"_a = pack_defaults.block_align, + "brute_force"_a = pack_defaults.brute_force, + "rotate_charts_to_axis"_a = pack_defaults.rotate_charts_to_axis, + "rotate_charts"_a = pack_defaults.rotate_charts, + "input_uv_attribute_name"_a = repack_defaults.input_uv_attribute_name, + "input_chart_attribute_name"_a = repack_defaults.input_chart_attribute_name, + "output_uv_attribute_name"_a = repack_defaults.output_uv_attribute_name, + "output_atlas_attribute_name"_a = repack_defaults.output_atlas_attribute_name, + "output_chart_attribute_name"_a = repack_defaults.output_chart_attribute_name, + "multi_atlas_policy"_a = repack_defaults.multi_atlas_policy, + "enable_sharing_uvs_between_instances"_a = + repack_defaults.enable_sharing_uvs_between_instances, + "per_instance_importance"_a = std::vector(), + R"(Repack all UV islands in a scene using xatlas. See repack_mesh() for parameter docs. + +Scene-specific kwargs: + +:param per_instance_importance: Optional list of per-instance importance weights (>0, finite). +)"); +} + +} // namespace lagrange::python diff --git a/modules/xatlas/python/tests/conftest.py b/modules/xatlas/python/tests/conftest.py new file mode 100644 index 00000000..c5aa4b35 --- /dev/null +++ b/modules/xatlas/python/tests/conftest.py @@ -0,0 +1,46 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +import lagrange +import numpy as np +import pytest + + +@pytest.fixture +def two_triangle_quad(): + mesh = lagrange.SurfaceMesh() + 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) + return mesh + + +@pytest.fixture +def unwrapped_two_triangle_quad(two_triangle_quad): + uv_values = np.array( + [[0, 0], [0.4, 0], [0, 0.4], [0.6, 0], [1, 0], [0.6, 0.4]], + dtype=np.float64, + ) + uv_indices = np.array( + [0, 1, 2, 3, 4, 5], + dtype=np.uint32, + ) + two_triangle_quad.create_attribute( + name="uv", + element=lagrange.AttributeElement.Indexed, + usage=lagrange.AttributeUsage.UV, + initial_values=uv_values, + initial_indices=uv_indices, + ) + return two_triangle_quad diff --git a/modules/xatlas/python/tests/test_repack.py b/modules/xatlas/python/tests/test_repack.py new file mode 100644 index 00000000..327c6230 --- /dev/null +++ b/modules/xatlas/python/tests/test_repack.py @@ -0,0 +1,27 @@ +# +# 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 + + +def test_repack_mesh_kwargs(unwrapped_two_triangle_quad): + out = lagrange.xatlas.repack_mesh( + unwrapped_two_triangle_quad, + input_uv_attribute_name="uv", + output_uv_attribute_name="uv2", + padding=2, + ) + assert out.num_vertices == unwrapped_two_triangle_quad.num_vertices + assert out.num_facets == unwrapped_two_triangle_quad.num_facets + assert unwrapped_two_triangle_quad.has_attribute("uv") + assert not unwrapped_two_triangle_quad.has_attribute("uv2") + assert out.has_attribute("uv") + assert out.has_attribute("uv2") diff --git a/modules/xatlas/python/tests/test_unwrap.py b/modules/xatlas/python/tests/test_unwrap.py new file mode 100644 index 00000000..12de5a39 --- /dev/null +++ b/modules/xatlas/python/tests/test_unwrap.py @@ -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. +# +import lagrange + + +def test_unwrap_mesh_default_kwargs(two_triangle_quad): + out = lagrange.xatlas.unwrap_mesh(two_triangle_quad) + assert out.num_vertices == two_triangle_quad.num_vertices + assert out.num_facets == two_triangle_quad.num_facets + assert out.has_attribute("@uv") + + +def test_unwrap_mesh_custom_kwargs(two_triangle_quad): + out = lagrange.xatlas.unwrap_mesh( + two_triangle_quad, + output_uv_attribute_name="uv", + max_iterations=2, + padding=4, + resolution=512, + ) + assert out.num_vertices == two_triangle_quad.num_vertices + assert out.num_facets == two_triangle_quad.num_facets + assert out.has_attribute("uv") + + +def test_unwrap_mesh_atlas_attribute(two_triangle_quad): + out = lagrange.xatlas.unwrap_mesh( + two_triangle_quad, + output_atlas_attribute_name="uv_atlas", + ) + assert out.num_vertices == two_triangle_quad.num_vertices + assert out.num_facets == two_triangle_quad.num_facets + assert out.has_attribute("uv_atlas") + + +def test_unwrap_mesh_chart_attribute(two_triangle_quad): + out = lagrange.xatlas.unwrap_mesh( + two_triangle_quad, + output_chart_attribute_name="uv_chart", + ) + assert out.num_vertices == two_triangle_quad.num_vertices + assert out.num_facets == two_triangle_quad.num_facets + assert out.has_attribute("uv_chart") + + +def test_unwrap_mesh_multi_atlas_policy(two_triangle_quad): + # Small mesh fits in one atlas; ErrorIfMultiple should not raise. + out = lagrange.xatlas.unwrap_mesh( + two_triangle_quad, + multi_atlas_policy=lagrange.xatlas.MultiAtlasPolicy.ErrorIfMultiple, + ) + assert out.num_vertices == two_triangle_quad.num_vertices + assert out.num_facets == two_triangle_quad.num_facets + assert out.has_attribute("@uv") diff --git a/modules/xatlas/src/AtlasEngine.cpp b/modules/xatlas/src/AtlasEngine.cpp new file mode 100644 index 00000000..b0c2acc2 --- /dev/null +++ b/modules/xatlas/src/AtlasEngine.cpp @@ -0,0 +1,424 @@ +/* + * 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 "AtlasEngine.h" + +#include "internal/atlas_to_mesh.h" +#include "internal/mesh_to_xatlas.h" +#include "internal/progress_bridge.h" + +#include +#include +#include +#include + +#include + +namespace lagrange::xatlas { + +template +struct AtlasEngine::Impl +{ + enum class State { + Empty, ///< No meshes added yet. + UnwrapAdded, ///< add_mesh() called. + UvAdded, ///< add_uv_mesh() called. + ChartsComputed, ///< compute_charts() called (unwrap path). + Packed, ///< pack_charts() called. + }; + + Impl() { atlas = ::xatlas::Create(); } + + ~Impl() + { + if (atlas != nullptr) { + ::xatlas::Destroy(atlas); + atlas = nullptr; + } + } + + Impl(const Impl&) = delete; + Impl& operator=(const Impl&) = delete; + + ::xatlas::Atlas* atlas = nullptr; + State state = State::Empty; + + // Owning storage for the input adapters. The active vector depends on state. + std::vector unwrap_adapters; + std::vector uv_adapters; + + // Original meshes (for extraction). Same order as adapters were added. + std::vector> input_meshes; + + // Optional progress/cancel. + std::function notification_func; + const std::atomic_bool* cancel = nullptr; + + void reset_atlas() + { + if (atlas != nullptr) { + ::xatlas::Destroy(atlas); + } + atlas = ::xatlas::Create(); + state = State::Empty; + input_meshes.clear(); + unwrap_adapters.clear(); + uv_adapters.clear(); + } +}; + +namespace { + +template +void install_progress(Impl& impl, internal::ProgressBridge& bridge) +{ + bridge.install(impl.atlas); +} + +void check_add_mesh_error(::xatlas::AddMeshError err) +{ + if (err != ::xatlas::AddMeshError::Success) { + throw Error( + lagrange::format("lagrange::xatlas: AddMesh failed: {}", ::xatlas::StringForEnum(err))); + } +} + +} // namespace + +template +AtlasEngine::AtlasEngine() + : m_impl(std::make_unique()) +{} + +template +AtlasEngine::~AtlasEngine() = default; + +template +AtlasEngine::AtlasEngine(AtlasEngine&&) noexcept = default; + +template +AtlasEngine& AtlasEngine::operator=(AtlasEngine&&) noexcept = default; + +template +void AtlasEngine::add_mesh(const Mesh& mesh, const UnwrapOptions& options) +{ + auto& impl = *m_impl; + if (impl.state == Impl::State::UvAdded) { + throw Error( + "lagrange::xatlas::AtlasEngine: cannot mix add_mesh() and add_uv_mesh(); call clear() " + "first"); + } + if (impl.state == Impl::State::ChartsComputed || impl.state == Impl::State::Packed) { + throw Error( + "lagrange::xatlas::AtlasEngine: cannot add_mesh() after compute_charts() or " + "pack_charts(); call clear() first"); + } + + // Strong exception safety: append to engine state, then roll back on any failure / cancel + // so callers don't have to call clear() to recover. + impl.unwrap_adapters.emplace_back(internal::build_mesh_adapter(mesh, options)); + bool committed = false; + bool input_appended = false; + auto rollback_guard = [&]() noexcept { + la_debug_assert( + impl.unwrap_adapters.size() == impl.input_meshes.size() + (input_appended ? 0 : 1)); + if (!committed) { + impl.unwrap_adapters.pop_back(); + if (input_appended) { + impl.input_meshes.pop_back(); + } + } + }; + try { + impl.input_meshes.emplace_back(mesh); + input_appended = true; + + internal::ProgressBridge bridge(impl.notification_func, impl.cancel); + install_progress(impl, bridge); + + auto decl = impl.unwrap_adapters.back().as_decl(); + auto err = ::xatlas::AddMesh(impl.atlas, decl, /*meshCountHint=*/0); + if (impl.notification_func || impl.cancel != nullptr) { + ::xatlas::AddMeshJoin(impl.atlas); + } + bridge.rethrow_if_pending(); + if (bridge.was_cancelled()) { + throw Error("xatlas operation cancelled"); + } + check_add_mesh_error(err); + committed = true; + impl.state = Impl::State::UnwrapAdded; + } catch (...) { + rollback_guard(); + throw; + } +} + +template +void AtlasEngine::add_uv_mesh(const Mesh& mesh, const RepackOptions& options) +{ + auto& impl = *m_impl; + if (impl.state == Impl::State::UnwrapAdded || impl.state == Impl::State::ChartsComputed || + impl.state == Impl::State::Packed) { + throw Error( + "lagrange::xatlas::AtlasEngine: cannot mix add_uv_mesh() with the unwrap path; call " + "clear() first"); + } + + impl.uv_adapters.emplace_back( + internal::build_uv_mesh_adapter( + mesh, + options.input_uv_attribute_name, + options.input_chart_attribute_name)); + bool committed = false; + bool input_appended = false; + auto rollback_guard = [&]() noexcept { + la_debug_assert( + impl.uv_adapters.size() == impl.input_meshes.size() + (input_appended ? 0 : 1)); + if (!committed) { + impl.uv_adapters.pop_back(); + if (input_appended) { + impl.input_meshes.pop_back(); + } + } + }; + try { + impl.input_meshes.emplace_back(mesh); + input_appended = true; + + internal::ProgressBridge bridge(impl.notification_func, impl.cancel); + install_progress(impl, bridge); + + auto decl = impl.uv_adapters.back().as_decl(); + auto err = ::xatlas::AddUvMesh(impl.atlas, decl); + bridge.rethrow_if_pending(); + if (bridge.was_cancelled()) { + throw Error("xatlas operation cancelled"); + } + check_add_mesh_error(err); + committed = true; + impl.state = Impl::State::UvAdded; + } catch (...) { + rollback_guard(); + throw; + } +} + +template +void AtlasEngine::clear() +{ + m_impl->reset_atlas(); +} + +template +void AtlasEngine::compute_charts(const ChartOptions& opts) +{ + auto& impl = *m_impl; + if (impl.state != Impl::State::UnwrapAdded && impl.state != Impl::State::ChartsComputed && + impl.state != Impl::State::Packed) { + throw Error( + "lagrange::xatlas::AtlasEngine: compute_charts() requires at least one add_mesh() " + "call; the repack path does not use compute_charts()"); + } + + ::xatlas::ChartOptions x; + x.maxChartArea = opts.max_chart_area; + x.maxBoundaryLength = opts.max_boundary_length; + x.normalDeviationWeight = opts.normal_deviation_weight; + x.roundnessWeight = opts.roundness_weight; + x.straightnessWeight = opts.straightness_weight; + x.normalSeamWeight = opts.normal_seam_weight; + x.textureSeamWeight = opts.texture_seam_weight; + x.maxCost = opts.max_cost; + x.maxIterations = opts.max_iterations; + x.useInputMeshUvs = opts.use_input_mesh_uvs; + x.fixWinding = opts.fix_winding; + + internal::ProgressBridge bridge(impl.notification_func, impl.cancel); + install_progress(impl, bridge); + + ::xatlas::ComputeCharts(impl.atlas, x); + + bridge.rethrow_if_pending(); + if (bridge.was_cancelled()) { + throw Error("xatlas operation cancelled"); + } + impl.state = Impl::State::ChartsComputed; +} + +template +void AtlasEngine::pack_charts(const PackOptions& opts) +{ + auto& impl = *m_impl; + if (impl.state == Impl::State::Empty) { + throw Error("lagrange::xatlas::AtlasEngine: pack_charts() requires meshes to be added"); + } + + if (impl.state == Impl::State::UnwrapAdded) { + throw Error( + "lagrange::xatlas::AtlasEngine: pack_charts() requires compute_charts() to be called " + "first on the unwrap path"); + } + + // xatlas requires ComputeCharts before PackCharts. On the repack/UV-mesh path, callers are + // allowed to go directly from add_uv_mesh() to pack_charts(), so compute charts implicitly + // here once for that flow only. + if (impl.state == Impl::State::UvAdded) { + internal::ProgressBridge cc_bridge(impl.notification_func, impl.cancel); + install_progress(impl, cc_bridge); + ::xatlas::ChartOptions cc_opts; + ::xatlas::ComputeCharts(impl.atlas, cc_opts); + cc_bridge.rethrow_if_pending(); + if (cc_bridge.was_cancelled()) { + throw Error("xatlas operation cancelled"); + } + } + + ::xatlas::PackOptions x; + x.maxChartSize = opts.max_chart_size; + x.padding = opts.padding; + x.texelsPerUnit = opts.texels_per_unit; + x.resolution = opts.resolution; + x.bilinear = opts.bilinear; + x.blockAlign = opts.block_align; + x.bruteForce = opts.brute_force; + x.rotateChartsToAxis = opts.rotate_charts_to_axis; + x.rotateCharts = opts.rotate_charts; + + internal::ProgressBridge bridge(impl.notification_func, impl.cancel); + install_progress(impl, bridge); + + ::xatlas::PackCharts(impl.atlas, x); + + bridge.rethrow_if_pending(); + if (bridge.was_cancelled()) { + throw Error("xatlas operation cancelled"); + } + impl.state = Impl::State::Packed; +} + +template +void AtlasEngine::generate( + const ChartOptions& chart_opts, + const PackOptions& pack_opts) +{ + compute_charts(chart_opts); + pack_charts(pack_opts); +} + +template +void AtlasEngine::generate(const UnwrapOptions& opts) +{ + generate(opts.chart, opts.packing); +} + +template +auto AtlasEngine::extract_mesh( + size_t mesh_index, + MultiAtlasPolicy multi_atlas_policy, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name) const -> Mesh +{ + auto& impl = *m_impl; + if (impl.state != Impl::State::Packed) { + throw Error( + "lagrange::xatlas::AtlasEngine: extract_mesh() requires pack_charts() to have been " + "called"); + } + if (mesh_index >= impl.input_meshes.size()) { + throw Error( + lagrange::format( + "lagrange::xatlas::AtlasEngine: mesh_index {} out of range (added {} meshes)", + mesh_index, + impl.input_meshes.size())); + } + + Mesh out = impl.input_meshes[mesh_index]; + internal::apply_atlas_uvs_to_mesh( + out, + *impl.atlas, + static_cast(mesh_index), + multi_atlas_policy, + output_uv_attribute_name, + output_atlas_attribute_name, + output_chart_attribute_name); + return out; +} + +template +size_t AtlasEngine::mesh_count() const +{ + return m_impl->input_meshes.size(); +} + +template +uint32_t AtlasEngine::atlas_width() const +{ + return m_impl->atlas != nullptr ? m_impl->atlas->width : 0u; +} + +template +uint32_t AtlasEngine::atlas_height() const +{ + return m_impl->atlas != nullptr ? m_impl->atlas->height : 0u; +} + +template +uint32_t AtlasEngine::atlas_count() const +{ + return m_impl->atlas != nullptr ? m_impl->atlas->atlasCount : 0u; +} + +template +uint32_t AtlasEngine::chart_count() const +{ + return m_impl->atlas != nullptr ? m_impl->atlas->chartCount : 0u; +} + +template +float AtlasEngine::texels_per_unit() const +{ + return m_impl->atlas != nullptr ? m_impl->atlas->texelsPerUnit : 0.f; +} + +template +float AtlasEngine::utilization(uint32_t atlas_index) const +{ + auto* atlas = m_impl->atlas; + if (atlas == nullptr || atlas->atlasCount == 0 || atlas->utilization == nullptr) return 0.f; + if (atlas_index >= atlas->atlasCount) { + throw Error( + lagrange::format( + "lagrange::xatlas::AtlasEngine: atlas_index {} out of range (atlas_count = {})", + atlas_index, + atlas->atlasCount)); + } + return atlas->utilization[atlas_index]; +} + +template +void AtlasEngine::set_notification_func(NotificationFunc func) +{ + m_impl->notification_func = std::move(func); +} + +template +void AtlasEngine::set_cancel(const std::atomic_bool* cancel) +{ + m_impl->cancel = cancel; +} + +#define LA_X_atlas_engine(_, S, I) template class AtlasEngine; +LA_SURFACE_MESH_X(atlas_engine, 0) +#undef LA_X_atlas_engine + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/src/AtlasEngine.h b/modules/xatlas/src/AtlasEngine.h new file mode 100644 index 00000000..2b1322ff --- /dev/null +++ b/modules/xatlas/src/AtlasEngine.h @@ -0,0 +1,121 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace lagrange::xatlas { + +/// +/// Reusable xatlas engine. Wraps an `xatlas::Atlas*` so callers can run the chart-then-pack +/// pipeline once and re-pack with different `PackOptions` without rebuilding charts. This is the +/// efficient path for repeated calls; one-shot users should prefer the free functions. +/// +/// State machine: +/// - After construction the atlas is empty. +/// - Call `add_mesh()` (one or more times) for the unwrap path, OR call `add_uv_mesh()` (one or +/// more times) for the repack-only path. Mixing the two is rejected. +/// - For the unwrap path: `compute_charts()` then `pack_charts()` (or `generate()` to do both). +/// `compute_charts()` may be called again to re-segment with new options; this invalidates any +/// previously packed output and requires another `pack_charts()` before extraction. +/// `pack_charts()` may be called repeatedly with different options. +/// - For the repack path: `pack_charts()` directly. +/// - `extract_mesh(i, ...)` reads back the i-th input mesh with the new UVs applied. +/// - `clear()` resets the engine to its initial empty state. +/// +template +class AtlasEngine +{ +public: + using Mesh = SurfaceMesh; + using NotificationFunc = std::function; + + AtlasEngine(); + ~AtlasEngine(); + AtlasEngine(const AtlasEngine&) = delete; + AtlasEngine& operator=(const AtlasEngine&) = delete; + AtlasEngine(AtlasEngine&&) noexcept; + AtlasEngine& operator=(AtlasEngine&&) noexcept; + + /// Add a mesh for unwrapping. Throws if any UV-only mesh has already been added. + void add_mesh(const Mesh& mesh, const UnwrapOptions& options = {}); + + /// Add a mesh with existing UVs for repacking only. Throws if any unwrap mesh has been added. + void add_uv_mesh(const Mesh& mesh, const RepackOptions& options = {}); + + /// Reset the engine to its initial empty state. + void clear(); + + /// Compute charts. Valid only after at least one `add_mesh()` call. + void compute_charts(const ChartOptions& opts = {}); + + /// Pack charts. Valid after `compute_charts()` (unwrap path) or `add_uv_mesh()` (repack path). + void pack_charts(const PackOptions& opts); + + /// Convenience: `compute_charts()` + `pack_charts()`. + void generate(const ChartOptions& chart_opts, const PackOptions& pack_opts); + + /// Convenience: `compute_charts(opts.chart)` + `pack_charts(opts.packing)`. + void generate(const UnwrapOptions& opts); + + /// Read back the i-th input mesh with the new UVs applied as an indexed attribute. + Mesh extract_mesh( + size_t mesh_index, + MultiAtlasPolicy multi_atlas_policy, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name) const; + + /// Number of meshes added to the atlas. + size_t mesh_count() const; + + /// Atlas tile width in pixels (0 before pack). + uint32_t atlas_width() const; + + /// Atlas tile height in pixels (0 before pack). + uint32_t atlas_height() const; + + /// Number of atlas tiles xatlas produced (0 before pack). + uint32_t atlas_count() const; + + /// Total number of charts (0 before chart computation). + uint32_t chart_count() const; + + /// Effective texels-per-unit value used by the most recent pack. + float texels_per_unit() const; + + /// Utilization of the given atlas tile (in `[0, 1]`). + float utilization(uint32_t atlas_index) const; + + /// Set the optional progress callback. xatlas may invoke it from any thread. + void set_notification_func(NotificationFunc func); + + /// Set the optional cancellation flag. + void set_cancel(const std::atomic_bool* cancel); + +private: + struct Impl; + std::unique_ptr m_impl; +}; + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/src/internal/atlas_to_mesh.cpp b/modules/xatlas/src/internal/atlas_to_mesh.cpp new file mode 100644 index 00000000..c37ff481 --- /dev/null +++ b/modules/xatlas/src/internal/atlas_to_mesh.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 "atlas_to_mesh.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace lagrange::xatlas::internal { + +namespace { + +template +inline void normalize_uv( + float u_pixel, + float v_pixel, + int32_t atlas_index, + float atlas_w, + float atlas_h, + MultiAtlasPolicy policy, + UVScalar& u_out, + UVScalar& v_out) +{ + const float inv_w = atlas_w > 0.f ? 1.f / atlas_w : 0.f; + const float inv_h = atlas_h > 0.f ? 1.f / atlas_h : 0.f; + float u_norm = u_pixel * inv_w; + float v_norm = v_pixel * inv_h; + if (policy == MultiAtlasPolicy::Udim && atlas_index > 0) { + u_norm += static_cast(atlas_index); + } + u_out = static_cast(u_norm); + v_out = static_cast(v_norm); +} + +template +void apply_atlas_uvs_to_mesh_typed( + SurfaceMesh& mesh, + const ::xatlas::Mesh& atlas_mesh, + const ::xatlas::Atlas& atlas, + std::string_view output_uv_attribute_name, + MultiAtlasPolicy policy) +{ + const Index num_corners = mesh.get_num_corners(); + if (atlas_mesh.indexCount != static_cast(num_corners)) { + throw Error( + lagrange::format( + "lagrange::xatlas: atlas index count ({}) does not match mesh corner count ({})", + atlas_mesh.indexCount, + static_cast(num_corners))); + } + + const auto uv_attr_id = lagrange::internal::find_or_create_attribute( + mesh, + output_uv_attribute_name, + AttributeElement::Indexed, + AttributeUsage::UV, + 2, + lagrange::internal::ResetToDefault::No); + + auto& uv_attr = mesh.template ref_indexed_attribute(uv_attr_id); + uv_attr.values().resize_elements(static_cast(atlas_mesh.vertexCount)); + + auto values = uv_attr.values().ref_all(); + const float atlas_w = static_cast(atlas.width); + const float atlas_h = static_cast(atlas.height); + for (uint32_t v = 0; v < atlas_mesh.vertexCount; ++v) { + const auto& xv = atlas_mesh.vertexArray[v]; + UVScalar u, vv; + normalize_uv(xv.uv[0], xv.uv[1], xv.atlasIndex, atlas_w, atlas_h, policy, u, vv); + values[2 * v + 0] = u; + values[2 * v + 1] = vv; + } + + auto indices = uv_attr.indices().ref_all(); + for (Index c = 0; c < num_corners; ++c) { + indices[c] = static_cast(atlas_mesh.indexArray[c]); + } +} + +template +void write_corner_int_attribute( + SurfaceMesh& mesh, + const ::xatlas::Mesh& atlas_mesh, + std::string_view name, + bool use_chart_index) +{ + if (name.empty()) return; + const Index num_corners = mesh.get_num_corners(); + + const auto attr_id = lagrange::internal::find_or_create_attribute( + mesh, + name, + AttributeElement::Corner, + AttributeUsage::Scalar, + 1, + lagrange::internal::ResetToDefault::Yes); + + auto& attr = mesh.template ref_attribute(attr_id); + auto out = attr.ref_all(); + for (Index c = 0; c < num_corners; ++c) { + const uint32_t outv = atlas_mesh.indexArray[c]; + const auto& xv = atlas_mesh.vertexArray[outv]; + out[c] = use_chart_index ? xv.chartIndex : xv.atlasIndex; + } +} + +} // namespace + +template +void apply_atlas_uvs_to_mesh( + SurfaceMesh& mesh, + const ::xatlas::Atlas& atlas, + uint32_t mesh_index, + MultiAtlasPolicy policy, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name) +{ + if (mesh.get_num_facets() == 0) return; + + if (mesh_index >= atlas.meshCount) { + throw Error( + lagrange::format( + "lagrange::xatlas: mesh_index {} out of range (atlas has {} meshes)", + mesh_index, + atlas.meshCount)); + } + + if (policy == MultiAtlasPolicy::ErrorIfMultiple && atlas.atlasCount > 1) { + throw Error( + lagrange::format( + "lagrange::xatlas: atlas produced {} tiles but MultiAtlasPolicy::ErrorIfMultiple " + "was requested", + atlas.atlasCount)); + } + + const auto& atlas_mesh = atlas.meshes[mesh_index]; + + // Determine output value type: if attribute exists and is float/double, preserve type; + // else default to Scalar. + if (mesh.has_attribute(output_uv_attribute_name)) { + if (mesh.template is_attribute_type(output_uv_attribute_name)) { + apply_atlas_uvs_to_mesh_typed( + mesh, + atlas_mesh, + atlas, + output_uv_attribute_name, + policy); + } else if (mesh.template is_attribute_type(output_uv_attribute_name)) { + apply_atlas_uvs_to_mesh_typed( + mesh, + atlas_mesh, + atlas, + output_uv_attribute_name, + policy); + } else { + throw Error( + lagrange::format( + "lagrange::xatlas: existing output attribute '{}' must be float or double", + output_uv_attribute_name)); + } + } else { + apply_atlas_uvs_to_mesh_typed( + mesh, + atlas_mesh, + atlas, + output_uv_attribute_name, + policy); + } + + if (!output_atlas_attribute_name.empty()) { + write_corner_int_attribute( + mesh, + atlas_mesh, + output_atlas_attribute_name, + /*use_chart_index=*/false); + } + + if (!output_chart_attribute_name.empty()) { + la_runtime_assert(!output_chart_attribute_name.empty()); + write_corner_int_attribute( + mesh, + atlas_mesh, + output_chart_attribute_name, + /*use_chart_index=*/true); + } +} + +#define LA_X_atlas_to_mesh(_, S, I) \ + template void apply_atlas_uvs_to_mesh( \ + SurfaceMesh&, \ + const ::xatlas::Atlas&, \ + uint32_t, \ + MultiAtlasPolicy, \ + std::string_view, \ + std::string_view, \ + std::string_view); +LA_SURFACE_MESH_X(atlas_to_mesh, 0) +#undef LA_X_atlas_to_mesh + +} // namespace lagrange::xatlas::internal diff --git a/modules/xatlas/src/internal/atlas_to_mesh.h b/modules/xatlas/src/internal/atlas_to_mesh.h new file mode 100644 index 00000000..c683a8bc --- /dev/null +++ b/modules/xatlas/src/internal/atlas_to_mesh.h @@ -0,0 +1,41 @@ +/* + * 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::xatlas::internal { + +/// +/// Apply xatlas output for the i-th input mesh onto a Lagrange mesh as an indexed UV attribute. +/// Preserves vertex/facet topology of `mesh`. UVs are normalized according to `policy`. +/// +/// `mesh` must have the same triangle topology as the input mesh that was added to the atlas at +/// position `mesh_index`. +/// +template +void apply_atlas_uvs_to_mesh( + SurfaceMesh& mesh, + const ::xatlas::Atlas& atlas, + uint32_t mesh_index, + MultiAtlasPolicy policy, + std::string_view output_uv_attribute_name, + std::string_view output_atlas_attribute_name, + std::string_view output_chart_attribute_name); + +} // namespace lagrange::xatlas::internal diff --git a/modules/xatlas/src/internal/mesh_to_xatlas.cpp b/modules/xatlas/src/internal/mesh_to_xatlas.cpp new file mode 100644 index 00000000..3b782214 --- /dev/null +++ b/modules/xatlas/src/internal/mesh_to_xatlas.cpp @@ -0,0 +1,423 @@ +/* + * 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 "mesh_to_xatlas.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace lagrange::xatlas::internal { + +namespace { + +template +void copy_positions_as_float(const SurfaceMesh& mesh, std::vector& out) +{ + const Index n = mesh.get_num_vertices(); + out.resize(static_cast(n) * 3); + for (Index v = 0; v < n; ++v) { + auto p = mesh.get_position(v); + out[3 * v + 0] = static_cast(p[0]); + out[3 * v + 1] = static_cast(p[1]); + out[3 * v + 2] = static_cast(p[2]); + } +} + +template +void check_triangle_only(const SurfaceMesh& mesh) +{ + if (!mesh.is_triangle_mesh()) { + throw Error("lagrange::xatlas: input mesh must be triangle-only"); + } +} + +template +void check_dimension_3d(const SurfaceMesh& mesh) +{ + if (mesh.get_dimension() != Index(3)) { + throw Error( + lagrange::format( + "lagrange::xatlas: input mesh must have dimension 3 (got {})", + static_cast(mesh.get_dimension()))); + } +} + +template +void check_uint32_capacity(const SurfaceMesh& mesh) +{ + constexpr uint64_t kMax = std::numeric_limits::max(); + if (static_cast(mesh.get_num_vertices()) > kMax) { + throw Error( + lagrange::format( + "lagrange::xatlas: mesh has {} vertices, exceeds xatlas UInt32 limit", + mesh.get_num_vertices())); + } + // For triangle-only meshes, num_corners = 3 * num_facets and is also passed to xatlas as + // MeshDecl.indexCount, so check facet count against UINT32_MAX/3 to ensure 3*N still fits. + if (static_cast(mesh.get_num_facets()) > kMax / 3) { + throw Error( + lagrange::format( + "lagrange::xatlas: mesh has {} facets; 3 * facets exceeds xatlas UInt32 index " + "limit", + mesh.get_num_facets())); + } + if (static_cast(mesh.get_num_corners()) > kMax) { + throw Error( + lagrange::format( + "lagrange::xatlas: mesh has {} corners, exceeds xatlas UInt32 index limit", + mesh.get_num_corners())); + } +} + +template +void copy_indices(const SurfaceMesh& mesh, std::vector& out) +{ + const Index num_corners = mesh.get_num_corners(); + out.resize(num_corners); + const auto& facet_indices = mesh.get_corner_to_vertex(); + auto span = facet_indices.get_all(); + constexpr uint64_t kMax = std::numeric_limits::max(); + for (Index c = 0; c < num_corners; ++c) { + const auto v = span[c]; + if (static_cast(v) > kMax) { + throw Error( + lagrange::format( + "lagrange::xatlas: vertex index {} exceeds xatlas UInt32 limit", + static_cast(v))); + } + out[c] = static_cast(v); + } +} + +template +bool try_copy_vertex_attribute_as_float( + const SurfaceMesh& mesh, + std::string_view name, + size_t channels, + std::vector& out) +{ + if (!mesh.template is_attribute_type(name)) return false; + const auto& attr = mesh.template get_attribute(name); + if (attr.get_element_type() != AttributeElement::Vertex) { + throw Error( + lagrange::format( + "lagrange::xatlas: attribute '{}' must be a per-vertex attribute", + name)); + } + if (attr.get_num_channels() != channels) { + throw Error( + lagrange::format( + "lagrange::xatlas: attribute '{}' must have {} channels (got {})", + name, + channels, + attr.get_num_channels())); + } + if (attr.get_num_elements() != mesh.get_num_vertices()) { + throw Error( + lagrange::format( + "lagrange::xatlas: attribute '{}' has {} elements, expected {} (one per vertex)", + name, + attr.get_num_elements(), + mesh.get_num_vertices())); + } + auto values = attr.get_all(); + out.resize(values.size()); + for (size_t i = 0; i < values.size(); ++i) { + out[i] = static_cast(values[i]); + } + return true; +} + +} // namespace + +::xatlas::MeshDecl MeshAdapter::as_decl() const noexcept +{ + ::xatlas::MeshDecl decl{}; + decl.vertexCount = vertex_count; + decl.vertexPositionData = positions.data(); + decl.vertexPositionStride = sizeof(float) * 3; + if (!normals.empty()) { + decl.vertexNormalData = normals.data(); + decl.vertexNormalStride = sizeof(float) * 3; + } + if (!uvs.empty()) { + decl.vertexUvData = uvs.data(); + decl.vertexUvStride = sizeof(float) * 2; + } + decl.indexCount = static_cast(indices.size()); + decl.indexData = indices.data(); + decl.indexFormat = ::xatlas::IndexFormat::UInt32; + decl.faceCount = face_count; + return decl; +} + +template +MeshAdapter build_mesh_adapter(const SurfaceMesh& mesh, const UnwrapOptions& options) +{ + check_triangle_only(mesh); + check_dimension_3d(mesh); + check_uint32_capacity(mesh); + + const auto& uv_name = options.input_uv_attribute_name; + const bool want_uv_hint = !uv_name.empty() || options.chart.use_input_mesh_uvs; + if (options.chart.use_input_mesh_uvs && uv_name.empty()) { + throw Error( + "lagrange::xatlas: chart.use_input_mesh_uvs is true but input_uv_attribute_name is " + "empty"); + } + + // If the input UV attribute is indexed, lower it to per-vertex UVs by unifying. + // Extraction reads atlas output corners back onto the original mesh's corners, so we don't + // need an expanded→original vertex mapping — the unified working_mesh is only used to + // build per-vertex UV hints for xatlas. + SurfaceMesh working_mesh; + const SurfaceMesh* src = &mesh; + + if (want_uv_hint) { + if (!mesh.has_attribute(uv_name)) { + throw Error( + lagrange::format("lagrange::xatlas: input UV attribute '{}' not found", uv_name)); + } + if (mesh.is_attribute_indexed(uv_name)) { + working_mesh = unify_named_index_buffer( + mesh, + std::vector{std::string_view(uv_name)}); + src = &working_mesh; + } + } + + MeshAdapter adapter; + adapter.vertex_count = static_cast(src->get_num_vertices()); + adapter.face_count = static_cast(src->get_num_facets()); + copy_positions_as_float(*src, adapter.positions); + copy_indices(*src, adapter.indices); + + // Optional normal hint. + if (!options.input_normal_attribute_name.empty()) { + const auto& nname = options.input_normal_attribute_name; + if (!src->has_attribute(nname)) { + throw Error( + lagrange::format("lagrange::xatlas: input normal attribute '{}' not found", nname)); + } + if (src->is_attribute_indexed(nname)) { + // Indexed normals not handled: skip with a clear error. + throw Error( + "lagrange::xatlas: indexed normal attribute hints are not supported; " + "convert to a vertex attribute first"); + } + bool ok = try_copy_vertex_attribute_as_float( + *src, + nname, + 3, + adapter.normals) || + try_copy_vertex_attribute_as_float( + *src, + nname, + 3, + adapter.normals); + if (!ok) { + throw Error( + lagrange::format( + "lagrange::xatlas: input normal attribute '{}' must be float or double", + nname)); + } + } + + // Optional UV hint. + if (want_uv_hint) { + bool ok = try_copy_vertex_attribute_as_float( + *src, + uv_name, + 2, + adapter.uvs) || + try_copy_vertex_attribute_as_float( + *src, + uv_name, + 2, + adapter.uvs); + if (!ok) { + throw Error( + lagrange::format( + "lagrange::xatlas: input UV attribute '{}' must be float or double", + uv_name)); + } + } + + return adapter; +} + +::xatlas::UvMeshDecl UvMeshAdapter::as_decl() const noexcept +{ + ::xatlas::UvMeshDecl decl{}; + decl.vertexCount = vertex_count; + decl.vertexUvData = uvs.data(); + decl.vertexStride = sizeof(float) * 2; + decl.indexCount = static_cast(indices.size()); + decl.indexData = indices.data(); + decl.indexFormat = ::xatlas::IndexFormat::UInt32; + // Pin islands to charts when chart ids were provided (otherwise xatlas + // re-derives charts by flood-filling over shared UV edges). + if (!face_materials.empty()) { + decl.faceMaterialData = face_materials.data(); + } + return decl; +} + +template +UvMeshAdapter build_uv_mesh_adapter( + const SurfaceMesh& mesh, + std::string_view uv_attribute_name, + std::string_view chart_attribute_name) +{ + check_triangle_only(mesh); + check_uint32_capacity(mesh); + + if (uv_attribute_name.empty()) { + throw Error("lagrange::xatlas: repack input UV attribute name must not be empty"); + } + if (!mesh.has_attribute(uv_attribute_name)) { + throw Error( + lagrange::format( + "lagrange::xatlas: input UV attribute '{}' not found", + uv_attribute_name)); + } + if (!mesh.is_attribute_indexed(uv_attribute_name)) { + throw Error( + lagrange::format( + "lagrange::xatlas: repack input UV attribute '{}' must be indexed", + uv_attribute_name)); + } + + UvMeshAdapter adapter; + adapter.face_count = static_cast(mesh.get_num_facets()); + adapter.indices.resize(mesh.get_num_corners()); + + auto fill_from = [&](auto* tag) { + using ValueType = std::remove_pointer_t; + const auto& attr = mesh.template get_indexed_attribute(uv_attribute_name); + if (attr.get_num_channels() != 2) { + throw Error("lagrange::xatlas: input UV attribute must have 2 channels"); + } + auto values = attr.values().get_all(); + constexpr uint64_t kMax = std::numeric_limits::max(); + const size_t num_uv_values = values.size() / 2; + if (static_cast(num_uv_values) > kMax) { + throw Error( + lagrange::format( + "lagrange::xatlas: indexed UV attribute has {} values, exceeds xatlas UInt32 " + "limit", + num_uv_values)); + } + adapter.vertex_count = static_cast(num_uv_values); + adapter.uvs.resize(values.size()); + for (size_t i = 0; i < values.size(); ++i) { + adapter.uvs[i] = static_cast(values[i]); + } + auto idx = attr.indices().get_all(); + for (size_t i = 0; i < idx.size(); ++i) { + const auto v = idx[i]; + if (static_cast(v) > kMax) { + throw Error( + lagrange::format( + "lagrange::xatlas: UV index {} exceeds xatlas UInt32 limit", + static_cast(v))); + } + adapter.indices[i] = static_cast(v); + } + }; + + if (mesh.template is_attribute_type(uv_attribute_name)) { + float* tag = nullptr; + fill_from(tag); + } else if (mesh.template is_attribute_type(uv_attribute_name)) { + double* tag = nullptr; + fill_from(tag); + } else { + throw Error( + lagrange::format( + "lagrange::xatlas: input UV attribute '{}' must be float or double", + uv_attribute_name)); + } + + // Optionally pin input islands to charts via xatlas faceMaterialData. + if (!chart_attribute_name.empty()) { + if (!mesh.has_attribute(chart_attribute_name)) { + throw Error( + lagrange::format( + "lagrange::xatlas: chart attribute '{}' not found", + chart_attribute_name)); + } + if (mesh.is_attribute_indexed(chart_attribute_name)) { + throw Error( + lagrange::format( + "lagrange::xatlas: chart attribute '{}' must be a per-facet (non-indexed) " + "attribute", + chart_attribute_name)); + } + adapter.face_materials.resize(adapter.face_count); + auto fill_charts = [&](auto* tag) { + using ChartType = std::remove_pointer_t; + const auto& chart_attr = mesh.template get_attribute(chart_attribute_name); + if (chart_attr.get_element_type() != AttributeElement::Facet) { + throw Error( + lagrange::format( + "lagrange::xatlas: chart attribute '{}' must be a Facet attribute", + chart_attribute_name)); + } + auto data = chart_attr.get_all(); + for (uint32_t f = 0; f < adapter.face_count; ++f) { + adapter.face_materials[f] = static_cast(data[f]); + } + }; + if (mesh.template is_attribute_type(chart_attribute_name)) { + uint32_t* tag = nullptr; + fill_charts(tag); + } else if (mesh.template is_attribute_type(chart_attribute_name)) { + int32_t* tag = nullptr; + fill_charts(tag); + } else if (mesh.template is_attribute_type(chart_attribute_name)) { + uint64_t* tag = nullptr; + fill_charts(tag); + } else if (mesh.template is_attribute_type(chart_attribute_name)) { + int64_t* tag = nullptr; + fill_charts(tag); + } else { + throw Error( + lagrange::format( + "lagrange::xatlas: chart attribute '{}' must be an integer type", + chart_attribute_name)); + } + } + + return adapter; +} + +// Explicit instantiations. +#define LA_X_mesh_to_xatlas(_, Scalar, Index) \ + template MeshAdapter build_mesh_adapter( \ + const SurfaceMesh&, \ + const UnwrapOptions&); \ + template UvMeshAdapter build_uv_mesh_adapter( \ + const SurfaceMesh&, \ + std::string_view, \ + std::string_view); +LA_SURFACE_MESH_X(mesh_to_xatlas, 0) +#undef LA_X_mesh_to_xatlas + +} // namespace lagrange::xatlas::internal diff --git a/modules/xatlas/src/internal/mesh_to_xatlas.h b/modules/xatlas/src/internal/mesh_to_xatlas.h new file mode 100644 index 00000000..94361349 --- /dev/null +++ b/modules/xatlas/src/internal/mesh_to_xatlas.h @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include + +#include +#include +#include + +#include + +namespace lagrange::xatlas::internal { + +/// +/// Owning storage for an `xatlas::MeshDecl` derived from a Lagrange `SurfaceMesh`. Lifetime of all +/// referenced buffers must extend at least until `xatlas::AddMesh()` returns; the recommended +/// usage is to keep the `MeshAdapter` alive until `AddMeshJoin()` for safety. +/// +struct MeshAdapter +{ + /// Float positions tightly packed (3 * vertex_count). Always copied/cast from input. + std::vector positions; + + /// Optional float normals tightly packed (3 * vertex_count). Empty if no normals supplied. + std::vector normals; + + /// Optional float UV hints tightly packed (2 * vertex_count). Empty if no hints supplied. + std::vector uvs; + + /// uint32 indices tightly packed (3 * facet_count for triangle-only meshes). + std::vector indices; + + /// Number of vertices (after optional unify). + uint32_t vertex_count = 0; + + /// Number of facets. + uint32_t face_count = 0; + + /// Build an `xatlas::MeshDecl` referencing this adapter's buffers. + ::xatlas::MeshDecl as_decl() const noexcept; +}; + +/// +/// Build an adapter from a triangle mesh. Throws `lagrange::Error` if the mesh is not triangular. +/// If `options.input_normal_attribute_name` is set, the normals are passed through as a hint. +/// If `options.input_uv_attribute_name` is set OR `options.chart.use_input_mesh_uvs` is true, the +/// UV attribute is passed through as a hint. Indexed UVs are first lowered to per-vertex UVs via +/// `unify_index_buffer()`. The original mesh topology is preserved on the way out via the xatlas +/// output index array (which has the same corner ordering as the original mesh). +/// +template +MeshAdapter build_mesh_adapter( + const SurfaceMesh& mesh, + const UnwrapOptions& options); + +/// +/// Owning storage for an `xatlas::UvMeshDecl` derived from a Lagrange `SurfaceMesh` whose UV +/// attribute is to be repacked. +/// +struct UvMeshAdapter +{ + /// Float UV values tightly packed (2 * vertex_count). + std::vector uvs; + + /// uint32 indices tightly packed (3 * facet_count). + std::vector indices; + + /// Optional per-face chart ids (size == face_count) forwarded to xatlas as + /// `faceMaterialData` to pin input islands to charts. Empty if no chart attribute was given. + std::vector face_materials; + + /// Number of UV "vertices" (= number of unique indexed UV values). + uint32_t vertex_count = 0; + + /// Number of triangles. + uint32_t face_count = 0; + + /// Build an `xatlas::UvMeshDecl` referencing this adapter's buffers. + ::xatlas::UvMeshDecl as_decl() const noexcept; +}; + +/// +/// Build a UV adapter from a triangle mesh's existing indexed UV attribute. Throws +/// `lagrange::Error` if the mesh is not triangular or the named attribute is missing/invalid. +/// +/// @param chart_attribute_name Optional per-facet chart id attribute. When non-empty, its values +/// are copied into `face_materials` so xatlas pins each island to its +/// own chart instead of re-floodfilling charts from UV adjacency. +/// +template +UvMeshAdapter build_uv_mesh_adapter( + const SurfaceMesh& mesh, + std::string_view uv_attribute_name, + std::string_view chart_attribute_name); + +} // namespace lagrange::xatlas::internal diff --git a/modules/xatlas/src/internal/progress_bridge.cpp b/modules/xatlas/src/internal/progress_bridge.cpp new file mode 100644 index 00000000..bc882587 --- /dev/null +++ b/modules/xatlas/src/internal/progress_bridge.cpp @@ -0,0 +1,115 @@ +/* + * 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 "progress_bridge.h" + +#include + +namespace lagrange::xatlas::internal { + +namespace { + +const char* stage_name(::xatlas::ProgressCategory category) noexcept +{ + switch (category) { + case ::xatlas::ProgressCategory::AddMesh: return "xatlas: Add mesh"; + case ::xatlas::ProgressCategory::ComputeCharts: return "xatlas: Compute charts"; + case ::xatlas::ProgressCategory::PackCharts: return "xatlas: Pack charts"; + case ::xatlas::ProgressCategory::BuildOutputMeshes: return "xatlas: Build output meshes"; + } + return "xatlas"; +} + +int category_index(::xatlas::ProgressCategory category) noexcept +{ + switch (category) { + case ::xatlas::ProgressCategory::AddMesh: return 0; + case ::xatlas::ProgressCategory::ComputeCharts: return 1; + case ::xatlas::ProgressCategory::PackCharts: return 2; + case ::xatlas::ProgressCategory::BuildOutputMeshes: return 3; + } + return -1; +} + +} // namespace + +ProgressBridge::ProgressBridge( + std::function notification_func, + const std::atomic_bool* cancel) noexcept + : m_notification_func(std::move(notification_func)) + , m_cancel(cancel) +{ + for (auto& last : m_last_progress) { + last = -1; + } +} + +void ProgressBridge::install(::xatlas::Atlas* atlas) noexcept +{ + if (atlas == nullptr) return; + if (!m_notification_func && m_cancel == nullptr) return; + ::xatlas::SetProgressCallback(atlas, &ProgressBridge::progress_thunk, this); +} + +void ProgressBridge::rethrow_if_pending() +{ + if (m_pending_exception) { + auto e = m_pending_exception; + m_pending_exception = nullptr; + std::rethrow_exception(e); + } +} + +bool ProgressBridge::progress_thunk( + ::xatlas::ProgressCategory category, + int progress, + void* user_data) +{ + auto* self = static_cast(user_data); + if (self == nullptr) return true; + + if (self->m_cancel != nullptr && self->m_cancel->load(std::memory_order_relaxed)) { + self->m_was_cancelled.store(true, std::memory_order_relaxed); + return false; + } + + if (self->m_notification_func) { + std::unique_lock lock(self->m_callback_mutex, std::try_to_lock); + if (!lock.owns_lock()) { + return true; + } + + try { + const int index = category_index(category); + bool should_notify = true; + if (index >= 0) { + auto& last = self->m_last_progress[static_cast(index)]; + should_notify = progress > last; + if (should_notify) last = progress; + } + if (should_notify) { + self->m_notification_func( + stage_name(category), + static_cast(progress) / 100.f); + } + } catch (...) { + if (!self->m_pending_exception) { + self->m_pending_exception = std::current_exception(); + } + self->m_was_cancelled.store(true, std::memory_order_relaxed); + return false; + } + } + + return true; +} + +} // namespace lagrange::xatlas::internal diff --git a/modules/xatlas/src/internal/progress_bridge.h b/modules/xatlas/src/internal/progress_bridge.h new file mode 100644 index 00000000..66d33c9d --- /dev/null +++ b/modules/xatlas/src/internal/progress_bridge.h @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace lagrange::xatlas::internal { + +/// +/// Bridge between xatlas's C-style progress callback and the Lagrange notification + cancel API. +/// +/// xatlas may call its progress callback from worker threads. The bridge forwards monotonic +/// per-category progress to the user callback and never lets exceptions propagate across the C ABI. +/// If another thread is already notifying the callback, the new signal is dropped. Any exception +/// thrown by the user callback is captured and rethrown by `rethrow_if_pending()` after the xatlas +/// call returns. +/// +class ProgressBridge +{ +public: + ProgressBridge( + std::function notification_func, + const std::atomic_bool* cancel) noexcept; + + /// Install on an atlas (no-op if both members are null). + void install(::xatlas::Atlas* atlas) noexcept; + + /// True if the user requested cancellation, or the bridge cancelled due to a user exception. + bool was_cancelled() const noexcept { return m_was_cancelled.load(); } + + /// Rethrow any exception captured from the user notification callback. + void rethrow_if_pending(); + +private: + static bool progress_thunk(::xatlas::ProgressCategory category, int progress, void* user_data); + + std::function m_notification_func; + const std::atomic_bool* m_cancel; + std::atomic_bool m_was_cancelled{false}; + + static constexpr size_t kNumCategories = 4; + std::array m_last_progress; + + std::mutex m_callback_mutex; + std::exception_ptr m_pending_exception; +}; + +} // namespace lagrange::xatlas::internal diff --git a/modules/xatlas/src/repack_mesh.cpp b/modules/xatlas/src/repack_mesh.cpp new file mode 100644 index 00000000..a1661462 --- /dev/null +++ b/modules/xatlas/src/repack_mesh.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 "AtlasEngine.h" + +namespace lagrange::xatlas { + +template +SurfaceMesh repack_mesh( + const SurfaceMesh& mesh, + const RepackOptions& options, + std::function notification_func, + const std::atomic_bool* cancel) +{ + if (mesh.get_num_facets() == 0) { + logger().warn("lagrange::xatlas::repack_mesh: input mesh is empty; returning unchanged"); + return mesh; + } + + AtlasEngine engine; + engine.set_notification_func(notification_func); + engine.set_cancel(cancel); + engine.add_uv_mesh(mesh, options); + engine.pack_charts(options.packing); + return engine.extract_mesh( + 0, + options.multi_atlas_policy, + options.output_uv_attribute_name, + options.output_atlas_attribute_name, + options.output_chart_attribute_name); +} + +#define LA_X_repack_mesh(_, S, I) \ + template SurfaceMesh repack_mesh( \ + const SurfaceMesh&, \ + const RepackOptions&, \ + std::function, \ + const std::atomic_bool*); +LA_SURFACE_MESH_X(repack_mesh, 0) +#undef LA_X_repack_mesh + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/src/repack_scene.cpp b/modules/xatlas/src/repack_scene.cpp new file mode 100644 index 00000000..ab372d21 --- /dev/null +++ b/modules/xatlas/src/repack_scene.cpp @@ -0,0 +1,132 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include +#include + +#include + +namespace lagrange::xatlas { + +namespace { + +void validate_scene_options(size_t num_instances, const SceneOptions& scene_options) +{ + // Note: `repack_scene` ignores `per_instance_importance` (xatlas repacking uses existing + // UVs only and is not affected by importance). We still validate sizes so callers catch + // mismatched arrays early. + if (!scene_options.per_instance_importance.empty() && + scene_options.per_instance_importance.size() != num_instances) { + throw Error( + lagrange::format( + "lagrange::xatlas: per_instance_importance size ({}) must equal number of " + "instances ({})", + scene_options.per_instance_importance.size(), + num_instances)); + } + for (float w : scene_options.per_instance_importance) { + if (!(w > 0.f) || !std::isfinite(w)) { + throw Error( + lagrange::format( + "lagrange::xatlas: per_instance_importance values must be > 0 and finite (got " + "{})", + w)); + } + } +} + +} // namespace + +template +scene::SimpleScene repack_scene( + const scene::SimpleScene& scene, + const RepackOptions& repack_options, + const SceneOptions& scene_options, + std::function notification_func, + const std::atomic_bool* cancel) +{ + using SceneType = scene::SimpleScene; + using InstanceType = typename SceneType::InstanceType; + + const auto num_meshes = static_cast(scene.get_num_meshes()); + const auto num_instances = static_cast(scene.compute_num_instances()); + validate_scene_options(num_instances, scene_options); + + if (num_meshes == 0) return scene; + + SceneType result; + + if (repack_options.enable_sharing_uvs_between_instances) { + for (Index m = 0; m < scene.get_num_meshes(); ++m) { + auto mesh_copy = scene.get_mesh(m); + if (mesh_copy.get_num_facets() == 0) { + const Index new_idx = result.add_mesh(std::move(mesh_copy)); + scene.foreach_instances_for_mesh(m, [&](const InstanceType& inst) { + InstanceType ni = inst; + ni.mesh_index = new_idx; + result.add_instance(ni); + }); + continue; + } + auto repacked = repack_mesh( + std::move(mesh_copy), + repack_options, + notification_func, + cancel); + const Index new_idx = result.add_mesh(std::move(repacked)); + scene.foreach_instances_for_mesh(m, [&](const InstanceType& inst) { + InstanceType ni = inst; + ni.mesh_index = new_idx; + result.add_instance(ni); + }); + } + } else { + scene.foreach_instances([&](const InstanceType& inst) { + const auto m = inst.mesh_index; + auto mesh_copy = scene.get_mesh(m); + if (mesh_copy.get_num_facets() == 0) { + const Index new_idx = result.add_mesh(std::move(mesh_copy)); + InstanceType ni = inst; + ni.mesh_index = new_idx; + result.add_instance(ni); + return; + } + auto repacked = repack_mesh( + std::move(mesh_copy), + repack_options, + notification_func, + cancel); + const Index new_idx = result.add_mesh(std::move(repacked)); + InstanceType ni = inst; + ni.mesh_index = new_idx; + result.add_instance(ni); + }); + } + + return result; +} + +#define LA_X_repack_scene(_, S, I) \ + template scene::SimpleScene repack_scene( \ + const scene::SimpleScene&, \ + const RepackOptions&, \ + const SceneOptions&, \ + std::function, \ + const std::atomic_bool*); +LA_SURFACE_MESH_X(repack_scene, 0) +#undef LA_X_repack_scene + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/src/unwrap_mesh.cpp b/modules/xatlas/src/unwrap_mesh.cpp new file mode 100644 index 00000000..1088f1da --- /dev/null +++ b/modules/xatlas/src/unwrap_mesh.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 "AtlasEngine.h" + +namespace lagrange::xatlas { + +template +SurfaceMesh unwrap_mesh( + const SurfaceMesh& mesh, + const UnwrapOptions& options, + std::function notification_func, + const std::atomic_bool* cancel) +{ + if (mesh.get_num_facets() == 0) { + logger().warn("lagrange::xatlas::unwrap_mesh: input mesh is empty; returning unchanged"); + return mesh; + } + + AtlasEngine engine; + engine.set_notification_func(notification_func); + engine.set_cancel(cancel); + engine.add_mesh(mesh, options); + engine.generate(options); + return engine.extract_mesh( + 0, + options.multi_atlas_policy, + options.output_uv_attribute_name, + options.output_atlas_attribute_name, + options.output_chart_attribute_name); +} + +#define LA_X_unwrap_mesh(_, S, I) \ + template SurfaceMesh unwrap_mesh( \ + const SurfaceMesh&, \ + const UnwrapOptions&, \ + std::function, \ + const std::atomic_bool*); +LA_SURFACE_MESH_X(unwrap_mesh, 0) +#undef LA_X_unwrap_mesh + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/src/unwrap_scene.cpp b/modules/xatlas/src/unwrap_scene.cpp new file mode 100644 index 00000000..56ef7afb --- /dev/null +++ b/modules/xatlas/src/unwrap_scene.cpp @@ -0,0 +1,244 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace lagrange::xatlas { + +namespace { + +void validate_scene_options(size_t num_instances, const SceneOptions& scene_options) +{ + if (!scene_options.per_instance_importance.empty() && + scene_options.per_instance_importance.size() != num_instances) { + throw Error( + lagrange::format( + "lagrange::xatlas: per_instance_importance size ({}) must equal number of " + "instances ({})", + scene_options.per_instance_importance.size(), + num_instances)); + } + for (float w : scene_options.per_instance_importance) { + if (!(w > 0.f) || !std::isfinite(w)) { + throw Error( + lagrange::format( + "lagrange::xatlas: per_instance_importance values must be > 0 and finite (got " + "{})", + w)); + } + } +} + +/// Apply a 4x4 affine transform to all mesh vertex positions in place. +/// Requires the mesh to be 3D; throws lagrange::Error otherwise. +template +void apply_transform(SurfaceMesh& mesh, const Eigen::Matrix& xf) +{ + if (mesh.get_dimension() != Index(3)) { + throw Error( + lagrange::format( + "lagrange::xatlas: scene mesh must have dimension 3 (got {})", + static_cast(mesh.get_dimension()))); + } + const Index n = mesh.get_num_vertices(); + auto positions = mesh.ref_vertex_to_position().ref_all(); + for (Index v = 0; v < n; ++v) { + Eigen::Matrix p; + p << positions[3 * v + 0], positions[3 * v + 1], positions[3 * v + 2], Scalar(1); + Eigen::Matrix q = xf * p; + positions[3 * v + 0] = q[0]; + positions[3 * v + 1] = q[1]; + positions[3 * v + 2] = q[2]; + } +} + +template +Scalar uniform_scale_from_transform( + const typename scene::SimpleScene::AffineTransform& xf) +{ + // Conservative scale: max of the three column norms of the linear part. This + // upper-bounds per-axis scaling so packing density is sized for the largest axis (e.g. + // when scaleY > scaleX) instead of underestimating from one column. + const auto m = xf.matrix(); + const Scalar n0 = m.template block<3, 1>(0, 0).norm(); + const Scalar n1 = m.template block<3, 1>(0, 1).norm(); + const Scalar n2 = m.template block<3, 1>(0, 2).norm(); + return std::max({n0, n1, n2}); +} + +} // namespace + +template +scene::SimpleScene unwrap_scene( + const scene::SimpleScene& scene_, + const UnwrapOptions& unwrap_options, + const SceneOptions& scene_options, + std::function notification_func, + const std::atomic_bool* cancel) +{ + using SceneType = scene::SimpleScene; + using InstanceType = typename SceneType::InstanceType; + + const auto num_meshes = static_cast(scene_.get_num_meshes()); + const auto num_instances = static_cast(scene_.compute_num_instances()); + validate_scene_options(num_instances, scene_options); + + if (num_meshes == 0) return scene_; + + // Bake per-instance importance into the instance transforms via the shared + // bake_scaling/unbake_scaling utilities. Importance is passed through directly (linear in + // scale, matching lagrange::anorigami) so callers can swap implementations without changing + // packing density semantics. The original transforms (and any pre-existing user_data) are + // restored at the end via unbake_scaling. + const bool is_scaling_baked = !scene_options.per_instance_importance.empty(); + const SceneType& scene = + is_scaling_baked + ? scene::internal::bake_scaling(scene_, scene_options.per_instance_importance) + : scene_; + + SceneType result; + + if (unwrap_options.enable_sharing_uvs_between_instances) { + // Shared UVs path: unwrap each mesh once. Per-mesh scale is the max over all (importance- + // baked) instance scales, so packing density is sized for the largest instance. + std::vector per_mesh_scale(num_meshes, Scalar(0)); + scene.foreach_instances([&](const InstanceType& inst) { + const Scalar s = uniform_scale_from_transform(inst.transform); + const auto m = inst.mesh_index; + per_mesh_scale[m] = std::max(per_mesh_scale[m], s); + }); + for (Index m = 0; m < scene.get_num_meshes(); ++m) { + if (per_mesh_scale[m] <= Scalar(0)) per_mesh_scale[m] = Scalar(1); + } + + std::vector> unwrapped_meshes; + unwrapped_meshes.reserve(num_meshes); + for (Index m = 0; m < scene.get_num_meshes(); ++m) { + auto mesh_copy = scene.get_mesh(m); + if (mesh_copy.get_num_facets() == 0) { + unwrapped_meshes.emplace_back(std::move(mesh_copy)); + continue; + } + // Bake the representative uniform scale into the mesh so packing density accounts + // for the largest instance. + Eigen::Matrix sxf = Eigen::Matrix::Identity(); + sxf(0, 0) = sxf(1, 1) = sxf(2, 2) = per_mesh_scale[m]; + apply_transform(mesh_copy, sxf); + unwrapped_meshes.emplace_back( + unwrap_mesh( + std::move(mesh_copy), + unwrap_options, + notification_func, + cancel)); + // Restore positions (we only wanted scale to influence packing). + unwrapped_meshes.back() = [&](SurfaceMesh m_unwrapped) { + // Replace positions with the original ones to undo the bake. + const auto& orig = scene.get_mesh(m); + auto src = orig.get_vertex_to_position().get_all(); + auto dst = m_unwrapped.ref_vertex_to_position().ref_all(); + std::copy(src.begin(), src.end(), dst.begin()); + return m_unwrapped; + }(std::move(unwrapped_meshes.back())); + } + + for (Index m = 0; m < scene.get_num_meshes(); ++m) { + const Index new_idx = result.add_mesh(std::move(unwrapped_meshes[m])); + scene.foreach_instances_for_mesh(m, [&](const InstanceType& inst) { + InstanceType ni = inst; + ni.mesh_index = new_idx; + result.add_instance(ni); + }); + } + } else { + // Per-instance UVs path: each instance becomes its own mesh in the output. + scene.foreach_instances([&](const InstanceType& inst) { + const auto m = inst.mesh_index; + auto mesh_copy = scene.get_mesh(m); + if (mesh_copy.get_num_facets() == 0) { + const Index new_idx = result.add_mesh(std::move(mesh_copy)); + InstanceType ni = inst; + ni.mesh_index = new_idx; + // Preserve original transform; we only avoid baking when there is no geometry. + result.add_instance(ni); + return; + } + + // Bake instance linear transform (rotation + scale; importance is already folded in + // by bake_scaling above) into vertex positions so packing reflects the world-space + // size of this instance. Translation is intentionally NOT baked: it doesn't influence + // chart size and including it would reduce float precision in xatlas inputs for + // instances placed far from the origin. + Eigen::Matrix xf = Eigen::Matrix::Identity(); + xf.template block<3, 3>(0, 0) = inst.transform.matrix().template block<3, 3>(0, 0); + apply_transform(mesh_copy, xf); + + // Baking transforms positions but leaves any input normal hint in the original space, + // which would feed xatlas inconsistent data (rotation / non-uniform scale would make + // normals stale relative to the transformed geometry). Drop the normal hint for the + // per-instance path; transforming + renormalizing normals here is a future enhancement. + auto unwrap_options_for_instance = unwrap_options; + if (!unwrap_options_for_instance.input_normal_attribute_name.empty()) { + logger().warn( + "lagrange::xatlas: ignoring input_normal_attribute_name for per-instance " + "unwrap because instance transforms are baked into positions only."); + unwrap_options_for_instance.input_normal_attribute_name = ""; + } + + auto unwrapped = unwrap_mesh( + std::move(mesh_copy), + unwrap_options_for_instance, + notification_func, + cancel); + + // Restore positions to their original (unbaked) values. + const auto& orig = scene.get_mesh(m); + auto src = orig.get_vertex_to_position().get_all(); + auto dst = unwrapped.ref_vertex_to_position().ref_all(); + std::copy(src.begin(), src.end(), dst.begin()); + + const Index new_idx = result.add_mesh(std::move(unwrapped)); + InstanceType ni = inst; + ni.mesh_index = new_idx; + result.add_instance(ni); + }); + } + + // Restore original instance transforms / user_data if we baked importance. + if (is_scaling_baked) { + result = scene::internal::unbake_scaling(std::move(result)); + } + + return result; +} + +#define LA_X_unwrap_scene(_, S, I) \ + template scene::SimpleScene unwrap_scene( \ + const scene::SimpleScene&, \ + const UnwrapOptions&, \ + const SceneOptions&, \ + std::function, \ + const std::atomic_bool*); +LA_SURFACE_MESH_X(unwrap_scene, 0) +#undef LA_X_unwrap_scene + +} // namespace lagrange::xatlas diff --git a/modules/xatlas/tests/CMakeLists.txt b/modules/xatlas/tests/CMakeLists.txt new file mode 100644 index 00000000..2181b4d0 --- /dev/null +++ b/modules/xatlas/tests/CMakeLists.txt @@ -0,0 +1,12 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +lagrange_add_test() diff --git a/modules/xatlas/tests/test_atlas_engine.cpp b/modules/xatlas/tests/test_atlas_engine.cpp new file mode 100644 index 00000000..1d3db2d7 --- /dev/null +++ b/modules/xatlas/tests/test_atlas_engine.cpp @@ -0,0 +1,204 @@ +/* + * 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 "../src/AtlasEngine.h" + +#include +#include + +using Scalar = float; +using Index = uint32_t; + +using SurfaceMesh = lagrange::SurfaceMesh; +using AtlasEngine = lagrange::xatlas::AtlasEngine; +using ChartOptions = lagrange::xatlas::ChartOptions; +using PackOptions = lagrange::xatlas::PackOptions; +using RepackOptions = lagrange::xatlas::RepackOptions; +using MultiAtlasPolicy = lagrange::xatlas::MultiAtlasPolicy; +using Error = lagrange::Error; + +TEST_CASE("add_mesh -> generate -> extract", "[xatlas][engine]") +{ + const ChartOptions chart_opts; + const PackOptions pack_opts; + const MultiAtlasPolicy policy = MultiAtlasPolicy::NormalizePerTile; + + 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); + + AtlasEngine engine; + engine.add_mesh(mesh); + engine.generate(chart_opts, pack_opts); + + REQUIRE(engine.mesh_count() == 1); + REQUIRE(engine.atlas_count() >= 1); + REQUIRE(engine.chart_count() >= 1); + + auto out = engine.extract_mesh(0, policy, "uv", "uv_atlas", "uv_chart"); + REQUIRE(out.has_attribute("uv")); + REQUIRE(out.is_attribute_indexed("uv")); + REQUIRE(out.has_attribute("uv_atlas")); + REQUIRE(out.has_attribute("uv_chart")); +} + +TEST_CASE("re-pack with new options", "[xatlas][engine]") +{ + const MultiAtlasPolicy policy = MultiAtlasPolicy::NormalizePerTile; + + 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); + + AtlasEngine engine; + engine.add_mesh(mesh); + engine.compute_charts(); + + PackOptions opts1; + opts1.resolution = 256; + engine.pack_charts(opts1); + auto out1 = engine.extract_mesh(0, policy, "uv1", "", ""); + + PackOptions opts2; + opts2.resolution = 512; + engine.pack_charts(opts2); + auto out2 = engine.extract_mesh(0, policy, "uv2", "", ""); + + REQUIRE(out1.has_attribute("uv1")); + REQUIRE(out2.has_attribute("uv2")); + REQUIRE(out1.get_num_vertices() == out2.get_num_vertices()); + REQUIRE(out1.get_num_facets() == out2.get_num_facets()); +} + +TEST_CASE("clear() allows reuse", "[xatlas][engine]") +{ + const ChartOptions chart_opts; + const PackOptions pack_opts; + + 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); + + AtlasEngine engine; + engine.add_mesh(mesh); + engine.generate(chart_opts, pack_opts); + REQUIRE(engine.mesh_count() == 1); + + engine.clear(); + REQUIRE(engine.mesh_count() == 0); + REQUIRE(engine.atlas_count() == 0); + + engine.add_mesh(mesh); + engine.generate(chart_opts, pack_opts); + REQUIRE(engine.mesh_count() == 1); +} + +TEST_CASE("cannot mix add modes", "[xatlas][engine]") +{ + 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); + + std::vector uv_values = {0, 0, 1, 0, 0, 1}; + std::vector uv_indices = {0, 1, 2}; + mesh.template create_attribute( + "uv", + lagrange::AttributeElement::Indexed, + lagrange::AttributeUsage::UV, + 2, + {uv_values.data(), uv_values.size()}, + {uv_indices.data(), uv_indices.size()}); + + AtlasEngine engine; + engine.add_mesh(mesh); + + RepackOptions opts; + opts.input_uv_attribute_name = "uv"; + REQUIRE_THROWS_AS(engine.add_uv_mesh(mesh, opts), Error); +} + +TEST_CASE("pack_charts before compute_charts throws on unwrap path", "[xatlas][engine]") +{ + const PackOptions pack_opts; + + 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); + + AtlasEngine engine; + engine.add_mesh(mesh); + REQUIRE_THROWS_AS(engine.pack_charts(pack_opts), Error); +} + +TEST_CASE("extract before pack throws", "[xatlas][engine]") +{ + const MultiAtlasPolicy policy = MultiAtlasPolicy::NormalizePerTile; + + 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); + + AtlasEngine engine; + engine.add_mesh(mesh); + REQUIRE_THROWS_AS(engine.extract_mesh(0, policy, "uv", "", ""), Error); +} + +TEST_CASE("notification exceptions are rethrown and rolled back", "[xatlas][engine][progress]") +{ + 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); + + AtlasEngine engine; + engine.set_notification_func( + [](const std::string&, float) { throw std::runtime_error("progress failed"); }); + + REQUIRE_THROWS_AS(engine.add_mesh(mesh), std::runtime_error); + REQUIRE(engine.mesh_count() == 0); +} + +TEST_CASE("cancelled add_mesh is rolled back", "[xatlas][engine][progress]") +{ + 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); + + std::atomic_bool cancel{true}; + + AtlasEngine engine; + engine.set_cancel(&cancel); + + REQUIRE_THROWS_AS(engine.add_mesh(mesh), Error); + REQUIRE(engine.mesh_count() == 0); +} diff --git a/modules/xatlas/tests/test_repack_mesh.cpp b/modules/xatlas/tests/test_repack_mesh.cpp new file mode 100644 index 00000000..83062523 --- /dev/null +++ b/modules/xatlas/tests/test_repack_mesh.cpp @@ -0,0 +1,247 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include + +namespace { + +template +lagrange::SurfaceMesh make_two_islands() +{ + lagrange::SurfaceMesh mesh; + // Island 1: triangle 0 + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({0, 1, 0}); + // Island 2: triangle 1 + mesh.add_vertex({0, 0, 1}); + mesh.add_vertex({1, 0, 1}); + mesh.add_vertex({0, 1, 1}); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(3, 4, 5); + + std::vector uv_values = {0, 0, 0.4f, 0, 0, 0.4f, 0.6f, 0, 1, 0, 0.6f, 0.4f}; + std::vector uv_indices = {0, 1, 2, 3, 4, 5}; + mesh.template create_attribute( + "uv", + lagrange::AttributeElement::Indexed, + lagrange::AttributeUsage::UV, + 2, + {uv_values.data(), uv_values.size()}, + {uv_indices.data(), uv_indices.size()}); + return mesh; +} + +} // namespace + +using Scalar = float; +using Index = uint32_t; + +using SurfaceMesh = lagrange::SurfaceMesh; +using RepackOptions = lagrange::xatlas::RepackOptions; +using Error = lagrange::Error; + +TEST_CASE("basic", "[xatlas][repack_mesh]") +{ + auto mesh = make_two_islands(); + + RepackOptions options; + options.input_uv_attribute_name = "uv"; + options.output_uv_attribute_name = "uv2"; + + auto result = lagrange::xatlas::repack_mesh(mesh, options); + + REQUIRE(result.has_attribute("uv2")); + REQUIRE(result.is_attribute_indexed("uv2")); + REQUIRE(result.template is_attribute_type("uv2")); + REQUIRE(result.get_num_vertices() == mesh.get_num_vertices()); + REQUIRE(result.get_num_facets() == mesh.get_num_facets()); +} + +TEST_CASE("rejects missing attribute", "[xatlas][repack_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); + + RepackOptions options; + options.input_uv_attribute_name = "uv"; + REQUIRE_THROWS_AS(lagrange::xatlas::repack_mesh(mesh, options), Error); +} + +TEST_CASE("new output attribute defaults to mesh Scalar type", "[xatlas][repack_mesh]") +{ + lagrange::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); + + std::vector uv_values = {0, 0, 1, 0, 0, 1}; + std::vector uv_indices = {0, 1, 2}; + mesh.template create_attribute( + "uv_in", + lagrange::AttributeElement::Indexed, + lagrange::AttributeUsage::UV, + 2, + {uv_values.data(), uv_values.size()}, + {uv_indices.data(), uv_indices.size()}); + REQUIRE(mesh.has_attribute("uv_in")); + REQUIRE(mesh.is_attribute_indexed("uv_in")); + REQUIRE(mesh.template is_attribute_type("uv_in")); + + RepackOptions options; + options.input_uv_attribute_name = "uv_in"; + options.output_uv_attribute_name = "uv_out"; + auto result = lagrange::xatlas::repack_mesh(mesh, options); + REQUIRE(result.has_attribute("uv_out")); + REQUIRE(result.is_attribute_indexed("uv_out")); + REQUIRE(result.template is_attribute_type("uv_out")); +} + +TEST_CASE("existing output attribute type is preserved", "[xatlas][repack_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); + + std::vector uv_values = {0, 0, 1, 0, 0, 1}; + std::vector uv_indices = {0, 1, 2}; + mesh.template create_attribute( + "uv_in", + lagrange::AttributeElement::Indexed, + lagrange::AttributeUsage::UV, + 2, + {uv_values.data(), uv_values.size()}, + {uv_indices.data(), uv_indices.size()}); + mesh.template create_attribute( + "uv_out", + lagrange::AttributeElement::Indexed, + lagrange::AttributeUsage::UV, + 2); + REQUIRE(mesh.has_attribute("uv_in")); + REQUIRE(mesh.is_attribute_indexed("uv_in")); + REQUIRE(mesh.template is_attribute_type("uv_in")); + REQUIRE(mesh.has_attribute("uv_out")); + REQUIRE(mesh.is_attribute_indexed("uv_out")); + REQUIRE(mesh.template is_attribute_type("uv_out")); + + RepackOptions options; + options.input_uv_attribute_name = "uv_in"; + options.output_uv_attribute_name = "uv_out"; + auto result = lagrange::xatlas::repack_mesh(mesh, options); + REQUIRE(result.has_attribute("uv_out")); + REQUIRE(result.is_attribute_indexed("uv_out")); + REQUIRE(result.template is_attribute_type("uv_out")); +} + +TEST_CASE("rejects non-indexed attribute", "[xatlas][repack_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); + + std::vector uv_values = {0, 0, 1, 0, 0, 1}; + mesh.create_attribute( + "uv", + lagrange::AttributeElement::Vertex, + lagrange::AttributeUsage::UV, + 2, + {uv_values.data(), uv_values.size()}); + + RepackOptions options; + options.input_uv_attribute_name = "uv"; + REQUIRE_THROWS_AS(lagrange::xatlas::repack_mesh(mesh, options), Error); +} + +TEST_CASE("chart attribute pins islands to charts", "[xatlas][repack_mesh]") +{ + auto mesh = make_two_islands(); + + // One distinct chart id per island. + std::vector charts = {0, 1}; + mesh.template create_attribute( + "chart", + lagrange::AttributeElement::Facet, + lagrange::AttributeUsage::Scalar, + 1, + {charts.data(), charts.size()}); + + RepackOptions options; + options.input_uv_attribute_name = "uv"; + options.input_chart_attribute_name = "chart"; + options.output_uv_attribute_name = "uv2"; + + auto result = lagrange::xatlas::repack_mesh(mesh, options); + + REQUIRE(result.has_attribute("uv2")); + REQUIRE(result.is_attribute_indexed("uv2")); + REQUIRE(result.get_num_facets() == mesh.get_num_facets()); +} + +TEST_CASE("rejects missing chart attribute", "[xatlas][repack_mesh]") +{ + auto mesh = make_two_islands(); + + RepackOptions options; + options.input_uv_attribute_name = "uv"; + options.input_chart_attribute_name = "chart"; + REQUIRE_THROWS_AS(lagrange::xatlas::repack_mesh(mesh, options), Error); +} + +TEST_CASE("rejects indexed chart attribute", "[xatlas][repack_mesh]") +{ + auto mesh = make_two_islands(); + + std::vector chart_values = {0, 1}; + std::vector chart_indices = {0, 0, 0, 1, 1, 1}; + mesh.template create_attribute( + "chart", + lagrange::AttributeElement::Indexed, + lagrange::AttributeUsage::Scalar, + 1, + {chart_values.data(), chart_values.size()}, + {chart_indices.data(), chart_indices.size()}); + + RepackOptions options; + options.input_uv_attribute_name = "uv"; + options.input_chart_attribute_name = "chart"; + REQUIRE_THROWS_AS(lagrange::xatlas::repack_mesh(mesh, options), Error); +} + +TEST_CASE("rejects non-integer chart attribute", "[xatlas][repack_mesh]") +{ + auto mesh = make_two_islands(); + + std::vector charts = {0.f, 1.f}; + mesh.template create_attribute( + "chart", + lagrange::AttributeElement::Facet, + lagrange::AttributeUsage::Scalar, + 1, + {charts.data(), charts.size()}); + + RepackOptions options; + options.input_uv_attribute_name = "uv"; + options.input_chart_attribute_name = "chart"; + REQUIRE_THROWS_AS(lagrange::xatlas::repack_mesh(mesh, options), Error); +} diff --git a/modules/xatlas/tests/test_unwrap_mesh.cpp b/modules/xatlas/tests/test_unwrap_mesh.cpp new file mode 100644 index 00000000..7dae00a9 --- /dev/null +++ b/modules/xatlas/tests/test_unwrap_mesh.cpp @@ -0,0 +1,175 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include + +#include +#include +#include +#include + +#include +#include + +namespace { + +template +lagrange::SurfaceMesh make_two_triangle_quad() +{ + lagrange::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); + return mesh; +} + +template +lagrange::SurfaceMesh make_cube() +{ + using Mesh = lagrange::SurfaceMesh; + Mesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_vertex({0, 0, 1}); + mesh.add_vertex({1, 0, 1}); + mesh.add_vertex({1, 1, 1}); + mesh.add_vertex({0, 1, 1}); + // -Z, +Z, -Y, +Y, -X, +X + mesh.add_triangle(0, 2, 1); + mesh.add_triangle(0, 3, 2); + mesh.add_triangle(4, 5, 6); + mesh.add_triangle(4, 6, 7); + mesh.add_triangle(0, 1, 5); + mesh.add_triangle(0, 5, 4); + mesh.add_triangle(2, 3, 7); + mesh.add_triangle(2, 7, 6); + mesh.add_triangle(0, 4, 7); + mesh.add_triangle(0, 7, 3); + mesh.add_triangle(1, 2, 6); + mesh.add_triangle(1, 6, 5); + return mesh; +} + +} // namespace + +using Scalar = float; +using Index = uint32_t; + +using SurfaceMesh = lagrange::SurfaceMesh; +using UnwrapOptions = lagrange::xatlas::UnwrapOptions; +using Error = lagrange::Error; + +TEST_CASE("two triangles", "[xatlas][unwrap_mesh]") +{ + auto mesh = make_two_triangle_quad(); + + const UnwrapOptions options; + auto result = lagrange::xatlas::unwrap_mesh(mesh, options); + + REQUIRE(result.get_num_vertices() == mesh.get_num_vertices()); + REQUIRE(result.get_num_facets() == mesh.get_num_facets()); + REQUIRE(result.has_attribute(options.output_uv_attribute_name)); + REQUIRE(result.is_attribute_indexed(options.output_uv_attribute_name)); + REQUIRE(result.template is_attribute_type(options.output_uv_attribute_name)); + + const auto& uv = + result.template get_indexed_attribute(options.output_uv_attribute_name); + REQUIRE(uv.get_num_channels() == 2u); + + auto values = uv.values().get_all(); + for (Scalar v : values) { + REQUIRE(std::isfinite(v)); + REQUIRE(v >= Scalar(-1e-4)); + REQUIRE(v <= Scalar(1) + Scalar(1e-4)); + } +} + +TEST_CASE("cube", "[xatlas][unwrap_mesh]") +{ + auto mesh = make_cube(); + + UnwrapOptions options; + options.output_uv_attribute_name = "uv"; + auto result = lagrange::xatlas::unwrap_mesh(mesh, options); + + REQUIRE(result.has_attribute("uv")); + REQUIRE(result.is_attribute_indexed("uv")); + REQUIRE(result.template is_attribute_type("uv")); +} + +TEST_CASE("empty", "[xatlas][unwrap_mesh]") +{ + SurfaceMesh mesh; + + auto result = lagrange::xatlas::unwrap_mesh(mesh); + + REQUIRE(result.get_num_vertices() == 0); + REQUIRE(result.get_num_facets() == 0); +} + +TEST_CASE("rejects non-triangle mesh", "[xatlas][unwrap_mesh]") +{ + SurfaceMesh mesh; + mesh.add_vertex({0, 0, 0}); + mesh.add_vertex({1, 0, 0}); + mesh.add_vertex({1, 1, 0}); + mesh.add_vertex({0, 1, 0}); + mesh.add_quad(0, 1, 2, 3); + + REQUIRE_THROWS_AS(lagrange::xatlas::unwrap_mesh(mesh), Error); +} + +TEST_CASE("ErrorIfMultiple atlas policy", "[xatlas][unwrap_mesh]") +{ + auto mesh = make_two_triangle_quad(); + + // The mesh is small enough that it fits in a single atlas, so this should succeed. + UnwrapOptions options; + options.multi_atlas_policy = lagrange::xatlas::MultiAtlasPolicy::ErrorIfMultiple; + options.packing.resolution = 1024; + options.packing.texels_per_unit = 1.f; + auto result = lagrange::xatlas::unwrap_mesh(mesh, options); + + REQUIRE(result.has_attribute(options.output_uv_attribute_name)); + REQUIRE(result.is_attribute_indexed(options.output_uv_attribute_name)); + REQUIRE(result.template is_attribute_type(options.output_uv_attribute_name)); +} + +TEST_CASE("writes atlas index attribute", "[xatlas][unwrap_mesh]") +{ + auto mesh = make_two_triangle_quad(); + + UnwrapOptions options; + options.output_atlas_attribute_name = "uv_atlas"; + auto result = lagrange::xatlas::unwrap_mesh(mesh, options); + + REQUIRE(result.has_attribute(options.output_atlas_attribute_name)); + REQUIRE(!result.is_attribute_indexed(options.output_atlas_attribute_name)); + REQUIRE(result.template is_attribute_type(options.output_atlas_attribute_name)); +} + +TEST_CASE("writes chart index attribute", "[xatlas][unwrap_mesh]") +{ + auto mesh = make_two_triangle_quad(); + + UnwrapOptions options; + options.output_chart_attribute_name = "uv_chart"; + auto result = lagrange::xatlas::unwrap_mesh(mesh, options); + + REQUIRE(result.has_attribute(options.output_chart_attribute_name)); + REQUIRE(!result.is_attribute_indexed(options.output_chart_attribute_name)); + REQUIRE(result.template is_attribute_type(options.output_chart_attribute_name)); +} diff --git a/modules/xatlas/tests/test_unwrap_scene.cpp b/modules/xatlas/tests/test_unwrap_scene.cpp new file mode 100644 index 00000000..fbaa32b3 --- /dev/null +++ b/modules/xatlas/tests/test_unwrap_scene.cpp @@ -0,0 +1,98 @@ +/* + * 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 + +namespace { + +template +lagrange::SurfaceMesh make_triangle() +{ + lagrange::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); + return mesh; +} + +template +lagrange::scene::SimpleScene make_test_scene() +{ + lagrange::scene::SimpleScene scene; + auto m = make_triangle(); + auto idx = scene.add_mesh(m); + typename lagrange::scene::SimpleScene::InstanceType inst; + inst.mesh_index = idx; + scene.add_instance(inst); + typename lagrange::scene::SimpleScene::InstanceType inst2; + inst2.mesh_index = idx; + inst2.transform.translate(Eigen::Matrix(2, 0, 0)); + scene.add_instance(inst2); + return scene; +} + +} // namespace + +TEST_CASE("empty unwrap", "[xatlas][unwrap_scene]") +{ + lagrange::scene::SimpleScene scene; + auto out = lagrange::xatlas::unwrap_scene(scene); + REQUIRE(out.get_num_meshes() == 0); +} + +TEST_CASE("empty repack", "[xatlas][unwrap_scene]") +{ + lagrange::scene::SimpleScene scene; + auto out = lagrange::xatlas::repack_scene(scene); + REQUIRE(out.get_num_meshes() == 0); +} + +TEST_CASE("shared UVs", "[xatlas][unwrap_scene]") +{ + auto scene = make_test_scene(); + lagrange::xatlas::UnwrapOptions options; + options.enable_sharing_uvs_between_instances = true; + auto out = lagrange::xatlas::unwrap_scene(scene, options); + REQUIRE(out.get_num_meshes() == scene.get_num_meshes()); + REQUIRE(out.compute_num_instances() == scene.compute_num_instances()); + REQUIRE(out.get_mesh(0).has_attribute(options.output_uv_attribute_name)); + REQUIRE(out.get_mesh(0).is_attribute_indexed(options.output_uv_attribute_name)); +} + +TEST_CASE("per-instance UVs", "[xatlas][unwrap_scene]") +{ + auto scene = make_test_scene(); + lagrange::xatlas::UnwrapOptions options; + options.enable_sharing_uvs_between_instances = false; + auto out = lagrange::xatlas::unwrap_scene(scene, options); + REQUIRE(out.get_num_meshes() == scene.compute_num_instances()); + REQUIRE(out.compute_num_instances() == scene.compute_num_instances()); + for (uint32_t i = 0; i < out.get_num_meshes(); ++i) { + REQUIRE(out.get_mesh(i).has_attribute(options.output_uv_attribute_name)); + REQUIRE(out.get_mesh(i).is_attribute_indexed(options.output_uv_attribute_name)); + } +} + +TEST_CASE("per_instance_importance size mismatch throws", "[xatlas][unwrap_scene]") +{ + auto scene = make_test_scene(); + lagrange::xatlas::UnwrapOptions opts; + lagrange::xatlas::SceneOptions sopts; + sopts.per_instance_importance = {1.f}; // scene has 2 instances + REQUIRE_THROWS_AS(lagrange::xatlas::unwrap_scene(scene, opts, sopts), lagrange::Error); +} diff --git a/modules/xatlas/xatlas.md b/modules/xatlas/xatlas.md new file mode 100644 index 00000000..fccfac20 --- /dev/null +++ b/modules/xatlas/xatlas.md @@ -0,0 +1,15 @@ +# lagrange::xatlas + +Open-source UV unwrap and repack module backed by [xatlas](https://github.com/jpcy/xatlas). + +## Capabilities + +- `unwrap_mesh()` / `unwrap_scene()` — segment + parameterize + pack a triangle mesh / scene. +- `repack_mesh()` / `repack_scene()` — repack existing UV islands. +- Optional progress and cancellation via the `notification_func` and `cancel` parameters. + +## Limitations + +- Triangle meshes only; non-triangle inputs throw `lagrange::Error`. +- Custom `ParameterizeFunc` is not exposed; xatlas's default LSCM is used. +- Progress / cancellation are not exposed in the Python bindings. diff --git a/pyproject.toml b/pyproject.toml index 0f8d44f9..47e33f43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,12 +101,14 @@ LAGRANGE_MODULE_POLYDDG = true LAGRANGE_MODULE_PRIMITIVE = true LAGRANGE_MODULE_PYTHON = true LAGRANGE_MODULE_RAYCASTING = true +LAGRANGE_MODULE_SAMPLING = true LAGRANGE_MODULE_SCENE = true LAGRANGE_MODULE_SERIALIZATION2 = true LAGRANGE_MODULE_SOLVER = true LAGRANGE_MODULE_SUBDIVISION = true LAGRANGE_MODULE_TEXPROC = true LAGRANGE_MODULE_VOLUME = true +LAGRANGE_MODULE_XATLAS = true LAGRANGE_UNIT_TESTS = false LAGRANGE_WITH_ASSIMP = true TBB_PREFER_STATIC = false