From 4758e5ed329a199a2248c89da0dc15bca89643f4 Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Thu, 5 Feb 2026 15:01:34 -0500 Subject: [PATCH 01/21] Sync with 301a764 --- CMakeLists.txt | 5 + cmake/recipes/external/MKL.cmake | 7 +- cmake/recipes/external/OpenVDB.cmake | 15 +- cmake/recipes/external/entt.cmake | 2 +- .../external/instant-meshes-core.cmake | 27 + cmake/recipes/external/pcg.cmake | 17 +- cmake/recipes/external/tinynpy.cmake | 62 ++ cmake/recipes/external/ufbx.cmake | 2 +- modules/bvh/CMakeLists.txt | 6 +- modules/bvh/examples/CMakeLists.txt | 3 + .../bvh/examples/remove_interior_shells.cpp | 60 ++ .../lagrange/bvh/remove_interior_shells.h | 33 + modules/bvh/python/src/bvh.cpp | 13 + .../tests/test_remove_interior_shells.py | 31 + modules/bvh/src/remove_interior_shells.cpp | 285 ++++++++ modules/bvh/src/weld_vertices.cpp | 1 - modules/bvh/tests/CMakeLists.txt | 3 + modules/bvh/tests/test_interior_shells.cpp | 57 ++ .../core/include/lagrange/ExactPredicates.h | 6 +- modules/core/python/scripts/meshstat.py | 7 + modules/core/python/tests/test_stubs.py | 26 + modules/core/src/compute_components.cpp | 54 +- modules/core/src/compute_vertex_valence.cpp | 43 +- .../src/mesh_cleanup/unflip_uv_triangles.cpp | 6 +- modules/core/src/separate_by_facet_groups.cpp | 2 + .../core/tests/test_compute_components.cpp | 17 +- .../include/lagrange/image_io/load_image.h | 18 +- modules/image_io/src/load_image.cpp | 47 +- modules/io/examples/mesh_convert.cpp | 6 +- modules/io/examples/scene_convert.cpp | 10 +- modules/io/python/src/io.cpp | 41 +- modules/io/src/internal/scene_utils.cpp | 5 +- modules/io/src/load_fbx.cpp | 29 +- modules/io/src/save_gltf.cpp | 28 +- modules/io/src/save_obj.cpp | 29 +- modules/io/tests/test_fbx.cpp | 49 ++ .../lagrange/primitive/legacy/SweepPath.h | 13 + .../primitive/src/generate_rounded_cube.cpp | 1 + modules/python/CMakeLists.txt | 6 + .../include/lagrange/scene/scene_convert.h | 16 + .../lagrange/scene/simple_scene_convert.h | 17 + .../scene/python/scripts/extract_texture.py | 2 +- modules/scene/python/src/bind_scene.h | 19 + modules/scene/python/src/bind_simple_scene.h | 19 + modules/scene/python/src/scene.cpp | 27 +- modules/scene/python/tests/assets.py | 24 + modules/scene/python/tests/test_scene.py | 21 +- .../scene/python/tests/test_simple_scene.py | 19 +- modules/scene/src/scene_convert.cpp | 23 +- modules/scene/src/simple_scene_convert.cpp | 40 +- .../subdivision/examples/mesh_subdivision.cpp | 95 +-- .../lagrange/subdivision/compute_sharpness.h | 87 +++ .../subdivision/python/src/subdivision.cpp | 149 +++- .../python/tests/test_mesh_subdivision.py | 5 +- modules/subdivision/src/compute_sharpness.cpp | 91 +++ .../tests/test_mesh_subdivision.cpp | 214 ++++++ modules/testing/CMakeLists.txt | 4 +- modules/texproc/examples/CMakeLists.txt | 5 +- .../examples/texture_stitching_gui.cpp | 471 ++++++++++++ modules/ui/examples/CMakeLists.txt | 1 - modules/volume/CMakeLists.txt | 4 + modules/volume/examples/CMakeLists.txt | 31 +- modules/volume/examples/grid_viewer.cpp | 87 +++ modules/volume/examples/register_grid.h | 80 ++ modules/volume/examples/voxelize_cli.cpp | 149 ++++ modules/volume/examples/voxelize_gui.cpp | 161 ++++ modules/volume/examples/voxelize_mesh.cpp | 95 --- .../lagrange/volume/fill_with_spheres.h | 4 + .../include/lagrange/volume/internal/utils.h | 71 ++ .../lagrange/volume/legacy/mesh_to_volume.h | 5 + .../lagrange/volume/legacy/volume_to_mesh.h | 4 + .../include/lagrange/volume/mesh_to_volume.h | 1 + .../volume/include/lagrange/volume/types.h | 4 + modules/volume/python/CMakeLists.txt | 31 + .../python/include/lagrange/python/volume.h | 22 + modules/volume/python/src/volume.cpp | 686 ++++++++++++++++++ modules/volume/python/tests/assets.py | 47 ++ modules/volume/python/tests/test_volume.py | 140 ++++ modules/volume/src/mesh_to_volume.cpp | 95 +-- modules/volume/src/sample_vertex_normal.cpp | 5 +- modules/volume/src/volume_to_mesh.cpp | 4 + 81 files changed, 3744 insertions(+), 403 deletions(-) create mode 100644 cmake/recipes/external/instant-meshes-core.cmake create mode 100644 cmake/recipes/external/tinynpy.cmake create mode 100644 modules/bvh/examples/remove_interior_shells.cpp create mode 100644 modules/bvh/include/lagrange/bvh/remove_interior_shells.h create mode 100644 modules/bvh/python/tests/test_remove_interior_shells.py create mode 100644 modules/bvh/src/remove_interior_shells.cpp create mode 100644 modules/bvh/tests/test_interior_shells.cpp create mode 100644 modules/core/python/tests/test_stubs.py create mode 100644 modules/io/tests/test_fbx.cpp create mode 100644 modules/scene/python/tests/assets.py create mode 100644 modules/subdivision/include/lagrange/subdivision/compute_sharpness.h create mode 100644 modules/subdivision/src/compute_sharpness.cpp create mode 100644 modules/texproc/examples/texture_stitching_gui.cpp create mode 100644 modules/volume/examples/grid_viewer.cpp create mode 100644 modules/volume/examples/register_grid.h create mode 100644 modules/volume/examples/voxelize_cli.cpp create mode 100644 modules/volume/examples/voxelize_gui.cpp delete mode 100644 modules/volume/examples/voxelize_mesh.cpp create mode 100644 modules/volume/include/lagrange/volume/internal/utils.h create mode 100644 modules/volume/python/CMakeLists.txt create mode 100644 modules/volume/python/include/lagrange/python/volume.h create mode 100644 modules/volume/python/src/volume.cpp create mode 100644 modules/volume/python/tests/assets.py create mode 100644 modules/volume/python/tests/test_volume.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e1ec358..ea054963 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -292,6 +292,11 @@ else() set(LAGRANGE_IDE_PREFIX_DEFAULT "third_party/") endif() set(LAGRANGE_IDE_PREFIX ${LAGRANGE_IDE_PREFIX_DEFAULT} CACHE STRING "Folder prefix for Lagrange targets in MSVC/Xcode") +foreach(target IN ITEMS Lagrange_DynamicVersion Lagrange_GitHash Lagrange_Version) + if(TARGET ${target}) + set_target_properties(${target} PROPERTIES FOLDER "${LAGRANGE_IDE_PREFIX}Lagrange/Utils") + endif() +endforeach() # When building python module, compile MKL/TBB as a shared library if(LAGRANGE_MODULE_PYTHON OR LAGRANGE_ALL OR BUILD_SHARED_LIBS) diff --git a/cmake/recipes/external/MKL.cmake b/cmake/recipes/external/MKL.cmake index b41dcf3e..b96d6766 100644 --- a/cmake/recipes/external/MKL.cmake +++ b/cmake/recipes/external/MKL.cmake @@ -207,7 +207,11 @@ function(mkl_add_imported_library name) set(OLD_CMAKE_FIND_LIBRARY_SUFFIXES ${CMAKE_FIND_LIBRARY_SUFFIXES}) if(LINUX) set(CMAKE_FIND_LIBRARY_SUFFIXES "") - set(mkl_search_name mkl_${name}${MKL_LIB_SUFFIX}.so${MKL_DLL_SUFFIX}) + if(MKL_LINKING STREQUAL static) + set(mkl_search_name mkl_${name}${MKL_LIB_SUFFIX}.a) + else() + set(mkl_search_name mkl_${name}${MKL_LIB_SUFFIX}.so${MKL_DLL_SUFFIX}) + endif() else() set(mkl_search_name mkl_${name}${MKL_LIB_SUFFIX}) endif() @@ -385,7 +389,6 @@ add_library(MKL::MKL INTERFACE IMPORTED GLOBAL) # Find header directory file(GLOB MKL_INCLUDE_HINTS LIST_DIRECTORIES true "${mkl-include_SOURCE_DIR}/*.data") -message("MKL_INCLUDE_HINTS: ${MKL_INCLUDE_HINTS}") find_path(MKL_INCLUDE_DIR NAMES mkl.h HINTS diff --git a/cmake/recipes/external/OpenVDB.cmake b/cmake/recipes/external/OpenVDB.cmake index a2dcc03d..0ae3f431 100644 --- a/cmake/recipes/external/OpenVDB.cmake +++ b/cmake/recipes/external/OpenVDB.cmake @@ -27,6 +27,12 @@ endif() option(OPENVDB_BUILD_CORE "" ON) option(OPENVDB_BUILD_BINARIES "" OFF) option(OPENVDB_ENABLE_RPATH "" OFF) +option(USE_NANOVDB "" ON) +option(NANOVDB_BUILD_TOOLS "" OFF) +option(NANOVDB_USE_BLOSC "" ON) +option(NANOVDB_USE_OPENVDB "" ON) +option(NANOVDB_USE_TBB "" ON) +option(NANOVDB_USE_ZLIB "" ON) # option(USE_EXPLICIT_INSTANTIATION "" ON) @@ -161,7 +167,7 @@ function(openvdb_import_target) CPMAddPackage( NAME openvdb GITHUB_REPOSITORY AcademySoftwareFoundation/openvdb - GIT_TAG v12.0.1 + GIT_TAG v13.0.0 ) unignore_package(TBB) @@ -191,6 +197,9 @@ function(openvdb_import_target) else() add_library(OpenVDB::openvdb ALIAS openvdb) endif() + if(TARGET nanovdb) + add_library(OpenVDB::nanovdb ALIAS nanovdb) + endif() # Copy miniz.h as zlib.h to have blosc use miniz symbols (which are aliased through #define in miniz.h) if(USE_ZLIB AND TARGET miniz::miniz) @@ -202,6 +211,10 @@ function(openvdb_import_target) include(GNUInstallDirs) target_include_directories(${_aliased} SYSTEM BEFORE PRIVATE $) + if(TARGET nanovdb) + target_include_directories(nanovdb SYSTEM BEFORE INTERFACE + $) + endif() endif() endfunction() diff --git a/cmake/recipes/external/entt.cmake b/cmake/recipes/external/entt.cmake index 66e7bb5e..af03b442 100644 --- a/cmake/recipes/external/entt.cmake +++ b/cmake/recipes/external/entt.cmake @@ -20,5 +20,5 @@ include(CPM) CPMAddPackage( NAME entt GITHUB_REPOSITORY skypjack/entt - GIT_TAG v3.16.0 + GIT_TAG v3.15.0 ) diff --git a/cmake/recipes/external/instant-meshes-core.cmake b/cmake/recipes/external/instant-meshes-core.cmake new file mode 100644 index 00000000..e7c7d8ac --- /dev/null +++ b/cmake/recipes/external/instant-meshes-core.cmake @@ -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. +# +if (TARGET instant-meshes-core::instant-meshes-core) + return() +endif() + +message(STATUS "Third-party (external): creating target 'instant-meshes-core::instant-meshes-core'") + +include(CPM) +CPMAddPackage( + NAME instant-meshes-core + GITHUB_REPOSITORY qnzhou/instant-meshes-core + GIT_TAG 7e2b804d533e10578a730bb9d06dee2a5418730d +) + +add_library(instant-meshes-core::instant-meshes-core ALIAS instant-meshes-core) +set_target_properties(instant-meshes-core PROPERTIES FOLDER third_party) +set_target_properties(instant-meshes-core PROPERTIES POSITION_INDEPENDENT_CODE ON) diff --git a/cmake/recipes/external/pcg.cmake b/cmake/recipes/external/pcg.cmake index 0c4aa3c6..e31e93bd 100644 --- a/cmake/recipes/external/pcg.cmake +++ b/cmake/recipes/external/pcg.cmake @@ -9,26 +9,27 @@ # OF ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. # -if(TARGET pcg::pcg) +if(TARGET pcg_cpp::pcg_cpp) return() endif() -message(STATUS "Third-party (external): creating target 'pcg::pcg'") +message(STATUS "Third-party (external): creating target 'pcg_cpp::pcg_cpp'") include(CPM) CPMAddPackage( NAME pcg - GITHUB_REPOSITORY imneme/pcg-cpp - GIT_TAG 428802d1a5634f96bcd0705fab379ff0113bcf13 + GITHUB_REPOSITORY Total-Random/pcg-cpp + GIT_TAG d91bcf5ae5559361ac0d3319d4ab95a231b32984 + DOWNLOAD_ONLY ON ) -add_library(pcg::pcg INTERFACE IMPORTED GLOBAL) -target_include_directories(pcg::pcg INTERFACE "${pcg_SOURCE_DIR}/include") +add_library(pcg_cpp::pcg_cpp INTERFACE IMPORTED GLOBAL) +target_include_directories(pcg_cpp::pcg_cpp INTERFACE "${pcg_SOURCE_DIR}/include") # PCG's auto-detection of endianness does not work properly for Windows on ARM. # Set the endianness explicitly if CMake knows it. if(CMAKE_CXX_BYTE_ORDER STREQUAL "BIG_ENDIAN") - target_compile_definitions(pcg::pcg INTERFACE PCG_LITTLE_ENDIAN=0) + target_compile_definitions(pcg_cpp::pcg_cpp INTERFACE PCG_LITTLE_ENDIAN=0) elseif(CMAKE_CXX_BYTE_ORDER STREQUAL "LITTLE_ENDIAN") - target_compile_definitions(pcg::pcg INTERFACE PCG_LITTLE_ENDIAN=1) + target_compile_definitions(pcg_cpp::pcg_cpp INTERFACE PCG_LITTLE_ENDIAN=1) endif() diff --git a/cmake/recipes/external/tinynpy.cmake b/cmake/recipes/external/tinynpy.cmake new file mode 100644 index 00000000..5aea9c3a --- /dev/null +++ b/cmake/recipes/external/tinynpy.cmake @@ -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. +# +if(TARGET TinyNPY::TinyNPY) + return() +endif() + +message(STATUS "Third-party (external): creating target 'TinyNPY::TinyNPY'") + +include(CPM) +CPMAddPackage( + NAME tinynpy + GITHUB_REPOSITORY cdcseacave/TinyNPY + GIT_TAG v1.1 + DOWNLOAD_ONLY ON +) + +add_library(TinyNPY "${tinynpy_SOURCE_DIR}/TinyNPY.cpp" "${tinynpy_SOURCE_DIR}/TinyNPY.h") +add_library(TinyNPY::TinyNPY ALIAS TinyNPY) + +include(miniz) +target_link_libraries(TinyNPY PUBLIC miniz::miniz) + +# Copy miniz.h as zlib.h to have TinyNPY use miniz symbols (which are aliased through #define in miniz.h) +FetchContent_GetProperties(miniz) +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/include") +configure_file(${miniz_SOURCE_DIR}/miniz.h ${CMAKE_CURRENT_BINARY_DIR}/include/zlib.h COPYONLY) + +include(GNUInstallDirs) +target_include_directories(TinyNPY SYSTEM BEFORE PRIVATE + $) + +set_target_properties(TinyNPY PROPERTIES + DEFINE_SYMBOL "TINYNPY_EXPORT" + VERSION "1.1.0" + SOVERSION "1" +) + +target_include_directories(TinyNPY PUBLIC + "$" + "$" +) + +target_compile_definitions(TinyNPY + INTERFACE $<$:TINYNPY_IMPORT> + PRIVATE $<$:_CRT_SECURE_NO_WARNINGS> +) + +set(unix_compilers "AppleClang;Clang;GNU") +if(CMAKE_CXX_COMPILER_ID IN_LIST unix_compilers) # IN_LIST wants the second arg to be a var + target_compile_options(TinyNPY PRIVATE "-frtti") +endif() + +set_target_properties(TinyNPY PROPERTIES FOLDER third_party) diff --git a/cmake/recipes/external/ufbx.cmake b/cmake/recipes/external/ufbx.cmake index 4a75d5cd..e4208361 100644 --- a/cmake/recipes/external/ufbx.cmake +++ b/cmake/recipes/external/ufbx.cmake @@ -18,7 +18,7 @@ include(CPM) CPMAddPackage( NAME ufbx GITHUB_REPOSITORY ufbx/ufbx - GIT_TAG e6bd7ea0833f1bd8732812100b69c596e1f69b64 + GIT_TAG v0.21.2 ) include(GNUInstallDirs) diff --git a/modules/bvh/CMakeLists.txt b/modules/bvh/CMakeLists.txt index b64d9368..03ad3c72 100644 --- a/modules/bvh/CMakeLists.txt +++ b/modules/bvh/CMakeLists.txt @@ -17,9 +17,9 @@ lagrange_find_package(nanoflann REQUIRED) include(libigl) target_link_libraries(lagrange_bvh PUBLIC - lagrange::core - nanoflann::nanoflann - igl::core + lagrange::core + nanoflann::nanoflann + igl::core ) # 3. unit tests and examples diff --git a/modules/bvh/examples/CMakeLists.txt b/modules/bvh/examples/CMakeLists.txt index 9a927819..74963230 100644 --- a/modules/bvh/examples/CMakeLists.txt +++ b/modules/bvh/examples/CMakeLists.txt @@ -14,3 +14,6 @@ lagrange_include_modules(io) lagrange_add_example(weld_vertices weld_vertices.cpp) target_link_libraries(weld_vertices lagrange::bvh lagrange::io CLI11::CLI11) + +lagrange_add_example(remove_interior_shells remove_interior_shells.cpp) +target_link_libraries(remove_interior_shells lagrange::bvh lagrange::io CLI11::CLI11) diff --git a/modules/bvh/examples/remove_interior_shells.cpp b/modules/bvh/examples/remove_interior_shells.cpp new file mode 100644 index 00000000..369e4e23 --- /dev/null +++ b/modules/bvh/examples/remove_interior_shells.cpp @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include +#include +#include +#include +#include + +#include + +int main(int argc, char** argv) +{ + struct + { + std::string input; + std::string output = "output.ply"; + int log_level = 1; // debug + } args; + + CLI::App app{argv[0]}; + app.option_defaults()->always_capture_default(); + app.add_option("input", args.input, "Input mesh.")->required()->check(CLI::ExistingFile); + app.add_option("output", args.output, "Output mesh."); + app.add_option("-l,--level", args.log_level, "Log level (0 = most verbose, 6 = off)."); + CLI11_PARSE(app, argc, argv) + + args.log_level = std::max(0, std::min(6, args.log_level)); + spdlog::set_level(static_cast(args.log_level)); + + lagrange::logger().info("Loading input mesh: {}", args.input); + auto mesh = lagrange::io::load_mesh(args.input); + + lagrange::logger().info("Triangulating mesh..."); + lagrange::triangulate_polygonal_facets(mesh); + + lagrange::logger().info( + "Mesh has {} vertices and {} facets.", + mesh.get_num_vertices(), + mesh.get_num_facets()); + + lagrange::logger().info("Removing interior shells..."); + lagrange::VerboseTimer timer("remove_interior_shells"); + timer.tick(); + mesh = lagrange::bvh::remove_interior_shells(mesh); + timer.tock(); + + lagrange::logger().info("Saving result: {}", args.output); + lagrange::io::save_mesh(args.output, mesh); + + return 0; +} diff --git a/modules/bvh/include/lagrange/bvh/remove_interior_shells.h b/modules/bvh/include/lagrange/bvh/remove_interior_shells.h new file mode 100644 index 00000000..e7b945e3 --- /dev/null +++ b/modules/bvh/include/lagrange/bvh/remove_interior_shells.h @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#pragma once + +#include + +namespace lagrange::bvh { + +/// +/// Removes interior shells from a (manifold, non-intersecting) mesh. +/// +/// This function assumes that the connected components are manifold without open boundaries (e.g. +/// such as isosurfaces extracted from a SDF). It removes all shells that are fully enclosed by +/// other shells, keeping only the outermost shells. +/// +/// @param mesh Input mesh to process. +/// +/// @return A new mesh containing only the exterior shells. +/// +template +SurfaceMesh remove_interior_shells(const SurfaceMesh& mesh); + +} // namespace lagrange::bvh diff --git a/modules/bvh/python/src/bvh.cpp b/modules/bvh/python/src/bvh.cpp index 12b1ee10..af607c83 100644 --- a/modules/bvh/python/src/bvh.cpp +++ b/modules/bvh/python/src/bvh.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -386,6 +387,18 @@ void populate_bvh_module(nb::module_& m) :param boundary_only: If true, only boundary vertices will be considered for welding. Defaults to False. .. warning:: This method may introduce non-manifoldness and degeneracy in the mesh.)"); + + m.def( + "remove_interior_shells", + bvh::remove_interior_shells, + "mesh"_a, + R"(Removes interior shells from a (manifold, non-intersecting) mesh + +.. warning:: This method assumes that the input mesh is closed, manifold and has no self-intersections. + The result may be invalid if these conditions are not met. + +:param mesh: Input mesh to process. +:return: A new mesh with interior shells removed.)"); } } // namespace lagrange::python diff --git a/modules/bvh/python/tests/test_remove_interior_shells.py b/modules/bvh/python/tests/test_remove_interior_shells.py new file mode 100644 index 00000000..b6f01daa --- /dev/null +++ b/modules/bvh/python/tests/test_remove_interior_shells.py @@ -0,0 +1,31 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +import lagrange +import pytest + +from .asset import cube + + +class TestRemoveInteriorShells: + def test_remove_interior_shells(self, cube): + mesh = cube.clone() + + # Create an interior shell by duplicating and scaling down the cube + interior_shell = cube.clone() + interior_shell.vertices = 0.5 * (interior_shell.vertices - 0.5) + 0.5 + mesh = lagrange.combine_meshes([mesh, interior_shell]) + + assert mesh.num_facets == 24 # 12 facets per cube + + cleaned_mesh = lagrange.bvh.remove_interior_shells(mesh) + + assert cleaned_mesh.num_facets == 12 # Only the outer shell remains diff --git a/modules/bvh/src/remove_interior_shells.cpp b/modules/bvh/src/remove_interior_shells.cpp new file mode 100644 index 00000000..08adac0c --- /dev/null +++ b/modules/bvh/src/remove_interior_shells.cpp @@ -0,0 +1,285 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include +#include + +#include +#include + +namespace lagrange::bvh { + +namespace { + +// Intersect a 3D segment with a plane Z=c. While not exact, this will always produce watertight +// polygons for closed shells. It is possible that floating point rounding causes 2D polygons to +// intersect if they are *very* close to begin with. But in practice this should not be an issue. +Eigen::Vector2d +segment_plane_intersection(const Eigen::Vector3d& a, const Eigen::Vector3d& b, double z) +{ + if (a.z() < b.z()) { + const double t = (z - b.z()) / (a.z() - b.z()); + return (t * a + (1.0 - t) * b).head<2>(); + } else { + const double t = (z - a.z()) / (b.z() - a.z()); + return (t * b + (1.0 - t) * a).head<2>(); + } +} + +std::optional> facet_plane_intersection( + const Eigen::RowVector3d& a, + const Eigen::RowVector3d& b, + const Eigen::RowVector3d& c, + double z) +{ + const int sa = (a.z() > z ? 1 : -1); + const int sb = (b.z() > z ? 1 : -1); + const int sc = (c.z() > z ? 1 : -1); + + if (sa * sb < 0 && sb * sc < 0) { + const auto u = segment_plane_intersection(a, b, z); + const auto v = segment_plane_intersection(b, c, z); + return {{u, v}}; + } else if (sb * sc < 0 && sc * sa < 0) { + const auto u = segment_plane_intersection(b, c, z); + const auto v = segment_plane_intersection(c, a, z); + return {{u, v}}; + } else if (sc * sa < 0 && sa * sb < 0) { + const auto u = segment_plane_intersection(c, a, z); + const auto v = segment_plane_intersection(a, b, z); + return {{u, v}}; + } else { + // No intersection with this triangle + return std::nullopt; + } +} + +} // namespace + +template +SurfaceMesh remove_interior_shells(const SurfaceMesh& mesh_) +{ + // Only implemented for triangles meshes for now + la_runtime_assert( + mesh_.is_triangle_mesh(), + "remove_interior_shells: Input mesh must be a triangle mesh."); + la_runtime_assert(mesh_.get_dimension() == 3, "remove_interior_shells: Input mesh must be 3D."); + + // 1. Compute connected components and facet groups + logger().debug("[remove_interior_shells] Computing connected components..."); + SurfaceMesh mesh = mesh_; + ComponentOptions comp_options; + comp_options.connectivity_type = ComponentOptions::ConnectivityType::Vertex; + size_t num_components = compute_components(mesh, comp_options); + auto component_indices = + mesh.template get_attribute(comp_options.output_attribute_name).get_all(); + + // 2. Pick representative vertex for each component + logger().debug("[remove_interior_shells] Computing representative vertices..."); + auto vertices = vertex_view(mesh); + auto facets = facet_view(mesh); + std::vector representative_vertices(num_components, invalid()); + std::vector representative_positions(num_components); + for (Index f = 0; f < mesh.get_num_facets(); f++) { + Index comp_id = component_indices[f]; + if (representative_vertices[comp_id] == invalid()) { + Index v0 = mesh.get_facet_vertex(f, 0); + representative_vertices[comp_id] = v0; + representative_positions[comp_id] = vertices.row(v0).template cast(); + } + } + + // 3. Build AABB tree + logger().debug("[remove_interior_shells] Building repr BVH..."); + using Tree = AABB; + using Box2d = typename Tree::Box; + AABB bvh; + std::vector boxes(num_components); + for (Index i = 0; i < num_components; i++) { + Index v = representative_vertices[i]; + boxes[i] = Box2d(vertices.row(v).template tail<2>()); // YZ coords + } + bvh.build(boxes); + + // 4. Iterate over triangles in parallel + // + // For each triangle, we find all representative vertices that lie within its Z bounds, + // and cast a ray from each representative vertex to +X direction, counting intersections. + // + // We use the standard "point in polygon" ray casting algorithm to determine which + // representative vertices are inside which shells. We use exact predicates to ensure + // robustness. + // + logger().debug("[remove_interior_shells] Casting rays..."); + struct Data + { + std::vector intersecting_vertices; + }; + tbb::enumerable_thread_specific data; + ExactPredicatesShewchuk pred; + Eigen::MatrixX is_inside_of(num_components, num_components); + std::fill_n(is_inside_of.data(), is_inside_of.size(), 0); + using Box3d = Eigen::AlignedBox; + tbb::parallel_for( + tbb::blocked_range(0, mesh.get_num_facets()), + [&](const tbb::blocked_range& r) { + auto& loc = data.local(); + for (Index f = r.begin(); f != r.end(); ++f) { + const Index f_comp = component_indices[f]; + Box3d bbox; + bbox.extend(vertices.row(facets(f, 0)).transpose().template head<3>()); + bbox.extend(vertices.row(facets(f, 1)).transpose().template head<3>()); + bbox.extend(vertices.row(facets(f, 2)).transpose().template head<3>()); + Box2d query(bbox.min().template tail<2>(), bbox.max().template tail<2>()); + bvh.intersect(query, loc.intersecting_vertices); + for (uint32_t i : loc.intersecting_vertices) { + if (f_comp == static_cast(i)) { + continue; // Skip triangles from the same component + } + double vz = representative_positions[i].z(); + auto res = facet_plane_intersection( + vertices.row(facets(f, 0)).template cast(), + vertices.row(facets(f, 1)).template cast(), + vertices.row(facets(f, 2)).template cast(), + vz); + if (!res.has_value()) { + continue; // No intersection with this triangle + } + auto [pi, pj] = res.value(); + auto v_pos = representative_positions[i].template head<2>(); + if ((pi.y() > v_pos.y()) == (pj.y() > v_pos.y())) { + continue; // No intersection with horizontal ray + } + // Test whether v is to the *left* of the edge pi-pj + short o = pred.orient2D(pi.data(), pj.data(), v_pos.data()); + if (pj.y() > pi.y()) { + // Predicates tell us about clockwise orientation of (pi, pj, v). + // We need to adjust the result depending on whether yi < yj. + o = -o; + } + if (o > 0) { + is_inside_of(i, f_comp).fetch_xor(1); + } + } + } + }); + + // 6. Iteratively prune shells that are inside other shells + // + // - Build adjacency lists of which shells contain which other shells + // - We need to visit shells from the outermost to the innermost. + // - When visiting a shell, we count the number of (visited) exterior shells that contain it. + // - If this number is odd, mark the shell as interior. + // - If this number is even, mark the shell as exterior. + // - Add any contained shell to the queue for shells to visit + logger().debug("[remove_interior_shells] Pruning shells..."); + std::vector is_exterior(num_components, false); + { + // Build adjacency lists of which shells contain which other shells + std::vector> contains(num_components); + std::vector> contained_by(num_components); + tbb::parallel_for(size_t(0), num_components, [&](size_t i) { + for (size_t j = 0; j < num_components; ++j) { + if (is_inside_of(i, j)) { + contained_by[i].push_back(j); + } + if (is_inside_of(j, i)) { + contains[i].push_back(j); + } + } + }); + // Start traversal from outermost shells (not contained by any other) + std::vector marked(num_components, false); + std::stack pending; + for (size_t i = 0; i < num_components; ++i) { + if (contained_by[i].empty()) { + pending.push(i); + marked[i] = true; + is_exterior[i] = true; + } + } + // Graph traversal + while (!pending.empty()) { + size_t curr = pending.top(); + pending.pop(); + // Mark current shell as exterior based on the number of exterior shells that contain it + bool exterior = true; + for (size_t j : contained_by[curr]) { + if (is_exterior[j]) { + la_debug_assert(marked[j]); + exterior = !exterior; + } + } + if (exterior) { + is_exterior[curr] = true; + } + // Visit contained shells next + for (size_t j : contains[curr]) { + if (!marked[j]) { + pending.push(j); + marked[j] = true; + } + } + } + } + + // 7. Combine meshes from non-pruned exterior shells + logger().debug("[remove_interior_shells] Selecting facets..."); + size_t remaining_components = num_components; + for (Index i = 0; i < num_components; i++) { + if (!is_exterior[i]) { + remaining_components--; + } + } + std::vector selected_facets; + selected_facets.reserve(mesh.get_num_facets()); + for (Index f = 0; f < mesh.get_num_facets(); ++f) { + if (is_exterior[component_indices[f]]) { + selected_facets.push_back(f); + } + } + + logger().debug( + "[remove_interior_shells] Removed {} interior shells. Remaining components: {}", + num_components - remaining_components, + remaining_components); + + return extract_submesh(mesh, selected_facets); +} + +#define LA_X_remove_interior_shells(_, Scalar, Index) \ + template LA_BVH_API SurfaceMesh remove_interior_shells( \ + const SurfaceMesh&); +LA_SURFACE_MESH_X(remove_interior_shells, 0) + +} // namespace lagrange::bvh diff --git a/modules/bvh/src/weld_vertices.cpp b/modules/bvh/src/weld_vertices.cpp index b8d6883c..149110b4 100644 --- a/modules/bvh/src/weld_vertices.cpp +++ b/modules/bvh/src/weld_vertices.cpp @@ -164,7 +164,6 @@ void weld_vertices(SurfaceMesh& mesh, WeldOptions options) remap_vertices(mesh, {vertex_mapping.data(), vertex_mapping.size()}, remap_options); } - #define LA_X_weld_vertices(_, Scalar, Index) \ template LA_BVH_API void weld_vertices(SurfaceMesh&, WeldOptions); LA_SURFACE_MESH_X(weld_vertices, 0) diff --git a/modules/bvh/tests/CMakeLists.txt b/modules/bvh/tests/CMakeLists.txt index 568dde1d..66bfdf19 100644 --- a/modules/bvh/tests/CMakeLists.txt +++ b/modules/bvh/tests/CMakeLists.txt @@ -10,3 +10,6 @@ # governing permissions and limitations under the License. # lagrange_add_test() + +lagrange_include_modules(primitive) +target_link_libraries(test_lagrange_bvh PRIVATE lagrange::primitive) diff --git a/modules/bvh/tests/test_interior_shells.cpp b/modules/bvh/tests/test_interior_shells.cpp new file mode 100644 index 00000000..e56a45ec --- /dev/null +++ b/modules/bvh/tests/test_interior_shells.cpp @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include + +#include +#include +#include +#include +#include + +TEST_CASE("remove_interior_shells", "[bvh][surface]") +{ + using Scalar = float; + using Index = uint32_t; + + auto make_cube = [](std::array center, std::array size) { + lagrange::primitive::RoundedCubeOptions options; + options.center = center; + options.width = size[0]; + options.height = size[1]; + options.depth = size[2]; + options.triangulate = true; + return lagrange::primitive::generate_rounded_cube(options); + }; + + std::vector> shells; + shells.push_back(make_cube({-1, -1, 0}, {1.f, 1.f, 1.f})); + shells.push_back(make_cube({1, -1, 0}, {1.f, 1.f, 1.f})); + shells.push_back(make_cube({1, 1, 0}, {1.f, 1.f, 1.f})); + shells.push_back(make_cube({-1, 1, 0}, {1.f, 1.f, 1.f})); + shells.push_back(make_cube({3, -1, 0}, {1.f, 1.f, 1.f})); + shells.push_back(make_cube({3, 1, 0}, {1.f, 1.f, 1.f})); + shells.push_back(make_cube({0, -1, 0}, {3.1f, 1.1f, 1.1f})); + shells.push_back(make_cube({0, 0, 0}, {3.2f, 3.2f, 3.2f})); + shells.push_back(make_cube({3, 0, 0}, {1.1f, 3.1f, 1.1f})); + + auto mesh = lagrange::combine_meshes(shells); + + auto expected = lagrange::combine_meshes({&shells[7], &shells[8]}); + auto cleaned = lagrange::bvh::remove_interior_shells(mesh); + + lagrange::reorder_mesh(cleaned, lagrange::ReorderingMethod::Lexicographic); + lagrange::reorder_mesh(expected, lagrange::ReorderingMethod::Lexicographic); + + REQUIRE(vertex_view(cleaned) == vertex_view(expected)); + REQUIRE(facet_view(cleaned) == facet_view(expected)); +} diff --git a/modules/core/include/lagrange/ExactPredicates.h b/modules/core/include/lagrange/ExactPredicates.h index 9bbe577a..efcf8e6b 100644 --- a/modules/core/include/lagrange/ExactPredicates.h +++ b/modules/core/include/lagrange/ExactPredicates.h @@ -70,9 +70,9 @@ class LA_CORE_API ExactPredicates /// @param p3 Third 3D point. /// @param p4 Fourth 3D point. /// - /// @return Return a positive value if the point pd lies below the plane passing through p1, + /// @return Return a positive value if the point p4 lies below the plane passing through p1, /// p2, and p3; "below" is defined so that p1, p2, and p3 appear in counterclockwise - /// order when viewed from above the plane. Returns a negative value if pd lies + /// order when viewed from above the plane. Returns a negative value if p4 lies /// above the plane. Returns zero if the points are coplanar. /// virtual short orient3D(double p1[3], double p2[3], double p3[3], double p4[3]) const = 0; @@ -85,7 +85,7 @@ class LA_CORE_API ExactPredicates /// @param p3 Third 2D point. /// @param p4 Fourth 3D point. /// - /// @return Return a positive value if the point pd lies inside the circle passing through + /// @return Return a positive value if the point p4 lies inside the circle passing through /// p1, p2, and p3; a negative value if it lies outside; and zero if the four points /// are cocircular. The points p1, p2, and p3 must be in counterclockwise order, or /// the sign of the result will be reversed. diff --git a/modules/core/python/scripts/meshstat.py b/modules/core/python/scripts/meshstat.py index 6f34db36..34532ca3 100755 --- a/modules/core/python/scripts/meshstat.py +++ b/modules/core/python/scripts/meshstat.py @@ -177,6 +177,13 @@ def print_extra_info(mesh, info): info["degenerate_facets"] = num_degenerate_facets print_property("num degenerate facets", len(num_degenerate_facets), 0) + # Isolated vertices check + vertex_valence_id = lagrange.compute_vertex_valence(mesh) + vertex_valence = mesh.attribute(vertex_valence_id).data + num_isolated_vertices = int(np.sum(vertex_valence == 0)) + info["num_isolated_vertices"] = num_isolated_vertices + print_property("num isolated vertices", num_isolated_vertices, 0) + # UV check if ( mesh.is_triangle_mesh diff --git a/modules/core/python/tests/test_stubs.py b/modules/core/python/tests/test_stubs.py new file mode 100644 index 00000000..c655d876 --- /dev/null +++ b/modules/core/python/tests/test_stubs.py @@ -0,0 +1,26 @@ +# +# Copyright 2026 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +import lagrange + +import pytest +from pathlib import Path + + +class TestStubs: + def test_stubs(self): + paths = lagrange.__path__ + found = False + for path in paths: + stub = Path(path) / "core.pyi" + if stub.exists(): + found = True + assert found, "Missing core.pyi stub file" diff --git a/modules/core/src/compute_components.cpp b/modules/core/src/compute_components.cpp index 5c8588ea..f443a9ff 100644 --- a/modules/core/src/compute_components.cpp +++ b/modules/core/src/compute_components.cpp @@ -41,22 +41,48 @@ size_t compute_vertex_based_components( is_blocked[vi] = true; }); - DisjointSets components(num_facets); - for (auto vi : range(num_vertices)) { - if (is_blocked[vi]) continue; - Index rep_facet_id = invalid_index; - for (Index ci = mesh.get_first_corner_around_vertex(vi); ci != invalid_index; - ci = mesh.get_next_corner_around_vertex(ci)) { - Index fi = mesh.get_corner_facet(ci); - if (rep_facet_id == invalid_index) { - rep_facet_id = fi; - } else { - components.merge(rep_facet_id, fi); + DisjointSets components(num_vertices); + for (auto fi : range(num_facets)) { + auto v = mesh.get_facet_vertices(fi); + for (size_t lv = 0; lv < v.size(); ++lv) { + Index v0 = v[lv]; + Index v1 = v[(lv + 1) % v.size()]; + if (is_blocked[v0] || is_blocked[v1]) continue; + components.merge(v0, v1); + } + } + + std::vector per_vertex_component_id(num_vertices, invalid_index); + size_t num_components = components.extract_disjoint_set_indices(per_vertex_component_id); + + // Transfer per-vertex component ids to per-facet component ids. Because blocked vertices form + // their own vertex-connected component, we need to renumber component ids excluding blocked + // vertices. + std::vector old_to_new_component_id(num_components, invalid_index); + size_t new_num_components = 0; + for (auto fi : range(num_facets)) { + bool is_set = false; + for (auto vi : mesh.get_facet_vertices(fi)) { + if (is_blocked[vi]) { + continue; + } + auto& new_id = old_to_new_component_id[per_vertex_component_id[vi]]; + if (new_id == invalid_index) { + new_id = static_cast(new_num_components); + ++new_num_components; } + component_id[fi] = new_id; + is_set = true; + break; + } + if (!is_set) { + // all vertices are blocked, assign a new component id for the facet + component_id[fi] = static_cast(new_num_components); + ++new_num_components; } } - return components.extract_disjoint_set_indices(component_id); + return new_num_components; }; template @@ -65,6 +91,8 @@ size_t compute_edge_based_components( AttributeId id, span blocker_edges) { + mesh.initialize_edges(); + constexpr Index invalid_index = invalid(); auto& attr = mesh.template ref_attribute(id); auto component_id = attr.ref_all(); @@ -121,8 +149,6 @@ size_t compute_components( 1); } - mesh.initialize_edges(); - switch (options.connectivity_type) { case ComponentOptions::ConnectivityType::Vertex: return compute_vertex_based_components(mesh, id, blocker_elements); diff --git a/modules/core/src/compute_vertex_valence.cpp b/modules/core/src/compute_vertex_valence.cpp index 444de9ef..5942c1ba 100644 --- a/modules/core/src/compute_vertex_valence.cpp +++ b/modules/core/src/compute_vertex_valence.cpp @@ -19,43 +19,46 @@ #include #include +#include + namespace lagrange { template AttributeId compute_vertex_valence(SurfaceMesh& mesh, VertexValenceOptions options) { + std::optional induced_by; + if (!options.induced_by_attribute.empty()) { + induced_by = mesh.get_attribute_id(options.induced_by_attribute); + } + AttributeId id = internal::find_or_create_attribute( mesh, options.output_attribute_name, Vertex, AttributeUsage::Scalar, 1, - options.induced_by_attribute.empty() ? internal::ResetToDefault::No - : internal::ResetToDefault::Yes); + induced_by.has_value() ? internal::ResetToDefault::Yes : internal::ResetToDefault::No); auto valence = mesh.template ref_attribute(id).ref_all(); la_debug_assert(static_cast(valence.size()) == mesh.get_num_vertices()); - if (!options.induced_by_attribute.empty()) { + if (induced_by.has_value()) { // Using the graph induced by the provided edge attribute - internal::visit_attribute_read( - mesh, - mesh.get_attribute_id(options.induced_by_attribute), - [&](auto&& attr) { - using AttributeType = std::decay_t; - if constexpr (!AttributeType::IsIndexed) { - if (attr.get_element_type() != AttributeElement::Edge) { - throw Error("`induced_by_attribute` must be an edge attribute"); - } - auto is_candidate = attr.get_all(); - for (Index e = 0; e < mesh.get_num_edges(); ++e) { - if (is_candidate[e]) { - auto [v0, v1] = mesh.get_edge_vertices(e); - ++valence[v0]; - ++valence[v1]; - } + internal::visit_attribute_read(mesh, induced_by.value(), [&](auto&& attr) { + using AttributeType = std::decay_t; + if constexpr (!AttributeType::IsIndexed) { + if (attr.get_element_type() != AttributeElement::Edge) { + throw Error("`induced_by_attribute` must be an edge attribute"); + } + auto is_candidate = attr.get_all(); + for (Index e = 0; e < mesh.get_num_edges(); ++e) { + if (is_candidate[e]) { + auto [v0, v1] = mesh.get_edge_vertices(e); + ++valence[v0]; + ++valence[v1]; } } - }); + } + }); } else { // Default implementation using the vertex-vertex graph for valence computation const auto adjacency_list = compute_vertex_vertex_adjacency(mesh); diff --git a/modules/core/src/mesh_cleanup/unflip_uv_triangles.cpp b/modules/core/src/mesh_cleanup/unflip_uv_triangles.cpp index 411fa6e7..fe3cef9e 100644 --- a/modules/core/src/mesh_cleanup/unflip_uv_triangles.cpp +++ b/modules/core/src/mesh_cleanup/unflip_uv_triangles.cpp @@ -445,9 +445,9 @@ void unflip_uv_triangles(SurfaceMesh& mesh, const UnflipUVOptions weld_indexed_attribute(mesh, uv_attr_id); } -#define LA_X_unflip_uv_triangles(_, Scalar, Index) \ - template void unflip_uv_triangles( \ - SurfaceMesh&, \ +#define LA_X_unflip_uv_triangles(_, Scalar, Index) \ + template LA_CORE_API void unflip_uv_triangles( \ + SurfaceMesh&, \ const UnflipUVOptions&); LA_SURFACE_MESH_X(unflip_uv_triangles, 0) diff --git a/modules/core/src/separate_by_facet_groups.cpp b/modules/core/src/separate_by_facet_groups.cpp index a5671ddf..b2766b9c 100644 --- a/modules/core/src/separate_by_facet_groups.cpp +++ b/modules/core/src/separate_by_facet_groups.cpp @@ -51,6 +51,8 @@ std::vector> separate_by_facet_groups( std::vector> results(num_groups); + // Note: When extracting many small submeshes, this does not scale very well (does a pass over + // the whole mesh for each component to extract...). SubmeshOptions submesh_options(options); tbb::parallel_for((size_t)0, num_groups, [&](size_t i) { span selected_facets( diff --git a/modules/core/tests/test_compute_components.cpp b/modules/core/tests/test_compute_components.cpp index 83b608e1..9b21082c 100644 --- a/modules/core/tests/test_compute_components.cpp +++ b/modules/core/tests/test_compute_components.cpp @@ -324,7 +324,7 @@ TEST_CASE("compute_components benchmark", "[surface][components][utilities][!ben return tmp_mesh; }; - SECTION("Without initial computation") + SECTION("Edge components (excluding edge computation)") { BENCHMARK_ADVANCED("compute_components (disjoint sets)") (Catch::Benchmark::Chronometer meter) @@ -347,7 +347,7 @@ TEST_CASE("compute_components benchmark", "[surface][components][utilities][!ben #endif } - SECTION("With initial computation") + SECTION("Edge components (including edge computation)") { BENCHMARK_ADVANCED("compute_components (disjoint sets)") (Catch::Benchmark::Chronometer meter) @@ -370,6 +370,19 @@ TEST_CASE("compute_components benchmark", "[surface][components][utilities][!ben }; #endif } + + SECTION("Vertex components (excluding edge computation)") + { + BENCHMARK_ADVANCED("compute_components (disjoint sets)") + (Catch::Benchmark::Chronometer meter) + { + auto tmp_mesh = wrap_copy(); + tmp_mesh.initialize_edges(); + ComponentOptions opt; + opt.connectivity_type = ComponentOptions::ConnectivityType::Vertex; + meter.measure([&]() { return compute_components(tmp_mesh, opt); }); + }; + } } #ifdef LAGRANGE_ENABLE_LEGACY_FUNCTIONS diff --git a/modules/image_io/include/lagrange/image_io/load_image.h b/modules/image_io/include/lagrange/image_io/load_image.h index c3641f51..296050c1 100644 --- a/modules/image_io/include/lagrange/image_io/load_image.h +++ b/modules/image_io/include/lagrange/image_io/load_image.h @@ -15,6 +15,12 @@ #include #include +// clang-format off +#include +#include +#include +// clang-format on + namespace lagrange { namespace image_io { @@ -29,17 +35,21 @@ struct LoadImageResult }; // Load image. Storage type is determined by the image file type. -LA_IMAGE_IO_API LoadImageResult load_image(const fs::path& path); +LA_IMAGE_IO_API LoadImageResult +load_image(const fs::path& path, spdlog::level::level_enum error_lvl = spdlog::level::err); // Load png or jpg image using stb library. Produces uint8 data. -LA_IMAGE_IO_API LoadImageResult load_image_stb(const fs::path& path); +LA_IMAGE_IO_API LoadImageResult +load_image_stb(const fs::path& path, spdlog::level::level_enum error_lvl = spdlog::level::err); // Note: #include to use the full load_image_exr directly // Load exr image using tinyexr. Produces multiple data types. -LA_IMAGE_IO_API LoadImageResult load_image_exr(const fs::path& path); +LA_IMAGE_IO_API LoadImageResult +load_image_exr(const fs::path& path, spdlog::level::level_enum error_lvl = spdlog::level::err); // Load image from our custom binary format. -LA_IMAGE_IO_API LoadImageResult load_image_bin(const fs::path& path); +LA_IMAGE_IO_API LoadImageResult +load_image_bin(const fs::path& path, spdlog::level::level_enum error_lvl = spdlog::level::err); // Load image as the provided type/view. Converts if needed/possible. Returns true on success. template diff --git a/modules/image_io/src/load_image.cpp b/modules/image_io/src/load_image.cpp index 7aa5b2b2..9e5048eb 100644 --- a/modules/image_io/src/load_image.cpp +++ b/modules/image_io/src/load_image.cpp @@ -9,23 +9,25 @@ * 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 { namespace image_io { -LoadImageResult load_image(const fs::path& path) +LoadImageResult load_image(const fs::path& path, spdlog::level::level_enum error_lvl) { LoadImageResult rtn; // basic sanity check if (path.empty()) { - logger().error("load_image error: empty path '{}'", path.string()); + logger().log(error_lvl, "load_image error: empty path '{}'", path.string()); return rtn; } @@ -34,22 +36,23 @@ LoadImageResult load_image(const fs::path& path) std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); const auto type = file_extension_to_file_type(ext); if (FileType::unknown == type) { - logger().error("load_image error: invalid extension '{}'", ext); + logger().log(error_lvl, "load_image error: invalid extension '{}'", ext); return rtn; } // load if (FileType::png == type || FileType::jpg == type) { - return load_image_stb(path); + return load_image_stb(path, error_lvl); } else if (FileType::exr == type) { - return load_image_exr(path); + return load_image_exr(path, error_lvl); } else if (FileType::bin == type) { - return load_image_bin(path); + return load_image_bin(path, error_lvl); } else { - logger().error( + logger().log( + error_lvl, "load_image error, unknown file type: {}, {}", static_cast(type), path.string()); @@ -57,8 +60,10 @@ LoadImageResult load_image(const fs::path& path) } } -LoadImageResult load_image_stb(const fs::path& path) +LoadImageResult load_image_stb(const fs::path& path, spdlog::level::level_enum error_lvl) { + LA_IGNORE(error_lvl); + LoadImageResult rtn; rtn.precision = image::ImagePrecision::uint8; int w, h, ch; @@ -81,8 +86,10 @@ LoadImageResult load_image_stb(const fs::path& path) return rtn; } -LoadImageResult load_image_exr(const fs::path& path) +LoadImageResult load_image_exr(const fs::path& path, spdlog::level::level_enum error_lvl) { + LA_IGNORE(error_lvl); + LoadImageResult rtn; void* out = nullptr; @@ -132,13 +139,13 @@ LoadImageResult load_image_exr(const fs::path& path) return rtn; } -LoadImageResult load_image_bin(const fs::path& path) +LoadImageResult load_image_bin(const fs::path& path, spdlog::level::level_enum error_lvl) { LoadImageResult rtn; std::ifstream ifs(path, std::ios_base::binary); if (!ifs.is_open()) { - logger().error("load_image error: cannot open file '{}'", path.string()); + logger().log(error_lvl, "load_image error: cannot open file '{}'", path.string()); return rtn; } @@ -151,7 +158,8 @@ LoadImageResult load_image_bin(const fs::path& path) ss << buf; ss >> header >> width >> height >> components; if (!ss.good() || ss.eof()) { - logger().error( + logger().log( + error_lvl, "load_image error, cannot parse the header of *.bin: {}, {}", buf, path.string()); @@ -161,12 +169,17 @@ LoadImageResult load_image_bin(const fs::path& path) rtn.precision = bin_header_to_precision(header); if (image::ImagePrecision::unknown == rtn.precision) { - logger().error("load_image error, invalid header of *.bin: {}, {}", header, path.string()); + logger().log( + error_lvl, + "load_image error, invalid header of *.bin: {}, {}", + header, + path.string()); return rtn; } if ((1 != components && 3 != components && 4 != components) || 0 >= width || 0 >= height) { - logger().error( + logger().log( + error_lvl, "load_image error, bad parameters of *.bin: {}, {}, {}, {}", path.string(), width, @@ -187,7 +200,8 @@ LoadImageResult load_image_bin(const fs::path& path) reinterpret_cast(rtn.storage->data()), _width * _height * _components * size_of_precision(rtn.precision)); if (ifs.eof() || !ifs.good()) { - logger().error( + logger().log( + error_lvl, "load_image error, failed in reading data block for *.bin: {}", path.string()); return rtn; @@ -196,7 +210,8 @@ LoadImageResult load_image_bin(const fs::path& path) char tag; ifs.read(&tag, 1); if (!ifs.eof()) { - logger().error( + logger().log( + error_lvl, "load_image error, the data block is larger than expected for *.bin: {}", path.string()); return rtn; diff --git a/modules/io/examples/mesh_convert.cpp b/modules/io/examples/mesh_convert.cpp index 7783eed4..85b11a0f 100644 --- a/modules/io/examples/mesh_convert.cpp +++ b/modules/io/examples/mesh_convert.cpp @@ -59,7 +59,11 @@ void convert(const lagrange::fs::path& input_filename, const lagrange::fs::path& lagrange::io::save_simple_scene(output_filename, scene); } else { lagrange::logger().info("Saving output mesh: {}", output_filename.string()); - lagrange::io::save_mesh(output_filename, lagrange::scene::simple_scene_to_mesh(scene)); + lagrange::TransformOptions options; + options.reorient = true; + lagrange::io::save_mesh( + output_filename, + lagrange::scene::simple_scene_to_mesh(scene, options)); } } else { // Load mesh diff --git a/modules/io/examples/scene_convert.cpp b/modules/io/examples/scene_convert.cpp index c0ce9225..07555564 100644 --- a/modules/io/examples/scene_convert.cpp +++ b/modules/io/examples/scene_convert.cpp @@ -30,6 +30,7 @@ int main(int argc, char** argv) std::string output; bool verbose = false; } args; + lagrange::io::LoadOptions load_options; lagrange::logger().set_level(spdlog::level::info); @@ -51,6 +52,10 @@ int main(int argc, char** argv) "Output scene file. Supported formats: .gltf, .glb, .obj.") ->required(); app.add_flag("-v,--verbose", args.verbose, "Verbose output."); + app.add_flag( + "-s,--stitch-vertices", + load_options.stitch_vertices, + "Stitch input boundary vertices."); CLI11_PARSE(app, argc, argv) if (args.verbose) { @@ -61,10 +66,9 @@ int main(int argc, char** argv) std::string input_ext = to_lower(fs::path(args.input).extension().string()); std::string output_ext = to_lower(fs::path(args.output).extension().string()); - // Load scene lagrange::logger().info("Loading scene: {}", args.input); - auto scene = io::load_scene(args.input); + auto scene = io::load_scene(args.input, load_options); // Display scene info lagrange::logger().info( @@ -78,6 +82,8 @@ int main(int argc, char** argv) lagrange::logger().info("Saving scene: {}", args.output); io::SaveOptions save_options; save_options.encoding = io::FileEncoding::Ascii; + save_options.attribute_conversion_policy = + io::SaveOptions::AttributeConversionPolicy::ConvertAsNeeded; io::save_scene(args.output, scene, save_options); lagrange::logger().info("Conversion completed successfully!"); diff --git a/modules/io/python/src/io.cpp b/modules/io/python/src/io.cpp index 5c7e35cc..085fed30 100644 --- a/modules/io/python/src/io.cpp +++ b/modules/io/python/src/io.cpp @@ -192,7 +192,7 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl bool load_images, bool stitch_vertices, bool quiet, - const fs::path& search_path) { + std::optional search_path) { io::LoadOptions opts; opts.triangulate = triangulate; opts.load_normals = load_normals; @@ -205,7 +205,7 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl opts.load_images = load_images; opts.stitch_vertices = stitch_vertices; opts.quiet = quiet; - opts.search_path = search_path; + if (search_path.has_value()) opts.search_path = search_path.value(); return io::load_mesh(filename, opts); }, "filename"_a, @@ -220,7 +220,7 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl "load_images"_a = io::LoadOptions().load_images, "stitch_vertices"_a = io::LoadOptions().stitch_vertices, "quiet"_a = io::LoadOptions().quiet, - "search_path"_a = io::LoadOptions().search_path, + "search_path"_a = nb::none(), R"(Load mesh from a file. :param filename: The input file name. @@ -231,13 +231,13 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl :param load_weights: Whether to load skinning weights attributes from mesh if available. Defaults to True. :param load_materials: Whether to load material ids from mesh if available. Defaults to True. :param load_vertex_colors: Whether to load vertex colors from mesh if available. Defaults to True. -:param load_object_id: Whether to load object ids from mesh if available. Defaults to True. +:param load_object_ids: Whether to load object ids from mesh if available. Defaults to True. :param load_images: Whether to load external images if available. Defaults to True. :param stitch_vertices: Whether to stitch boundary vertices based on position. Defaults to False. :param quiet: Whether to silence warnings during loading. Defaults to False. -:param search_path: Optional search path for external references (e.g. .mtl, .bin, etc.). Defaults to None. +:param search_path: Search path for external references (e.g. .mtl, .bin, etc.). Defaults to "". -:return SurfaceMesh: The mesh object extracted from the input string.)"); +:return SurfaceMesh: The mesh object loaded from the file.)"); m.def( "load_simple_scene", @@ -253,7 +253,7 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl bool load_images, bool stitch_vertices, bool quiet, - const fs::path& search_path) { + std::optional search_path) { io::LoadOptions opts; opts.triangulate = triangulate; opts.load_normals = load_normals; @@ -266,7 +266,7 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl opts.load_images = load_images; opts.stitch_vertices = stitch_vertices; opts.quiet = quiet; - opts.search_path = search_path; + if (search_path.has_value()) opts.search_path = search_path.value(); return io::load_simple_scene(filename, opts); }, "filename"_a, @@ -281,7 +281,7 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl "load_images"_a = io::LoadOptions().load_images, "stitch_vertices"_a = io::LoadOptions().stitch_vertices, "quiet"_a = io::LoadOptions().quiet, - "search_path"_a = io::LoadOptions().search_path, + "search_path"_a = nb::none(), R"(Load a simple scene from file. :param filename: The input file name. @@ -292,13 +292,13 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl :param load_weights: Whether to load skinning weights attributes from mesh if available. Defaults to True. :param load_materials: Whether to load material ids from mesh if available. Defaults to True. :param load_vertex_colors: Whether to load vertex colors from mesh if available. Defaults to True. -:param load_object_id: Whether to load object ids from mesh if available. Defaults to True. +:param load_object_ids: Whether to load object ids from mesh if available. Defaults to True. :param load_images: Whether to load external images if available. Defaults to True. :param stitch_vertices: Whether to stitch boundary vertices based on position. Defaults to False. :param quiet: Whether to silence warnings during loading. Defaults to False. -:param search_path: Optional search path for external references (e.g. .mtl, .bin, etc.). Defaults to None. +:param search_path: Search path for external references (e.g. .mtl, .bin, etc.). Defaults to "". -:return SimpleScene: The scene object extracted from the input string.)"); +:return SimpleScene: The scene object loaded from the file.)"); m.def( "save_simple_scene", @@ -364,7 +364,7 @@ Filename extension determines the file format. Supported formats are: `obj`, `pl R"(Convert a mesh to a binary string based on specified format. :param mesh: The input mesh. -:param format: Format to use. Supported formats are "obj", "ply", "gltf" and "msh". +:param format: Format to use. Supported formats are "obj", "ply", "msh", "gltf" and "glb". :param binary: Whether to save the mesh in binary format if supported. Defaults to True. Only `msh`, `ply` and `glb` support binary format. :param exact_match: Whether to save attributes in their exact form. Some mesh formats may not support all the attribute types. If set to False, attributes will be converted to the closest supported attribute type. Defaults to True. :param selected_attributes: A list of attribute ids to save. If not specified, all attributes will be saved. Defaults to None. @@ -419,7 +419,7 @@ The binary string should use one of the supported formats. Supported formats inc bool load_images, bool stitch_vertices, bool quiet, - const fs::path& search_path) { + std::optional search_path) { io::LoadOptions opts; opts.triangulate = triangulate; opts.load_normals = load_normals; @@ -432,7 +432,7 @@ The binary string should use one of the supported formats. Supported formats inc opts.load_images = load_images; opts.stitch_vertices = stitch_vertices; opts.quiet = quiet; - opts.search_path = search_path; + if (search_path.has_value()) opts.search_path = search_path.value(); return io::load_scene(filename, opts); }, "filename"_a, @@ -447,7 +447,7 @@ The binary string should use one of the supported formats. Supported formats inc "load_images"_a = io::LoadOptions().load_images, "stitch_vertices"_a = io::LoadOptions().stitch_vertices, "quiet"_a = io::LoadOptions().quiet, - "search_path"_a = io::LoadOptions().search_path, + "search_path"_a = nb::none(), R"(Load a scene. :param filename: The input file name. @@ -458,11 +458,11 @@ The binary string should use one of the supported formats. Supported formats inc :param load_weights: Whether to load skinning weights attributes from mesh if available. Defaults to True. :param load_materials: Whether to load material ids from mesh if available. Defaults to True. :param load_vertex_colors: Whether to load vertex colors from mesh if available. Defaults to True. -:param load_object_id: Whether to load object ids from mesh if available. Defaults to True. +:param load_object_ids: Whether to load object ids from mesh if available. Defaults to True. :param load_images: Whether to load external images if available. Defaults to True. :param stitch_vertices: Whether to stitch boundary vertices based on position. Defaults to False. :param quiet: Whether to silence warnings during loading. Defaults to False. -:param search_path: Optional search path for external references (e.g. .mtl, .bin, etc.). Defaults to None. +:param search_path: Search path for external references (e.g. .mtl, .bin, etc.). Defaults to "". :return Scene: The loaded scene object.)"); @@ -536,9 +536,9 @@ The binary string should use one of the supported formats (i.e. `gltf`, `glb` an :param scene: The scene to save. :param binary: Whether to save the scene in binary format if supported. Defaults to True. Only `glb` supports binary format. :param exact_match: Whether to save attributes in their exact form. Some mesh formats may not support all the attribute types. If set to False, attributes will be converted to the closest supported attribute type. Defaults to True. +:param embed_images: Whether to embed images in the output file when supported. :param selected_attributes: A list of attribute ids to save. If not specified, all attributes will be saved. Defaults to None. - -:return str: The string representing the input scene.)"); +)"); m.def( "scene_to_string", @@ -588,6 +588,7 @@ The binary string should use one of the supported formats (i.e. `gltf`, `glb` an :param format: Format to use. Supported formats are "gltf" and "glb". :param binary: Whether to save the scene in binary format if supported. Defaults to True. Only `glb` supports binary format. :param exact_match: Whether to save attributes in their exact form. Some mesh formats may not support all the attribute types. If set to False, attributes will be converted to the closest supported attribute type. Defaults to True. +:param embed_images: Whether to embed images in the output file when supported. :param selected_attributes: A list of attribute ids to save. If not specified, all attributes will be saved. Defaults to None. :return str: The string representing the input scene.)"); diff --git a/modules/io/src/internal/scene_utils.cpp b/modules/io/src/internal/scene_utils.cpp index da3aa0d9..a554723d 100644 --- a/modules/io/src/internal/scene_utils.cpp +++ b/modules/io/src/internal/scene_utils.cpp @@ -25,7 +25,10 @@ bool try_load_image( if (path.is_relative() && !options.search_path.empty()) path = options.search_path / name; if (path.empty()) return false; - image_io::LoadImageResult result = image_io::load_image(path); + spdlog::level::level_enum error_lvl = spdlog::level::err; + if (options.quiet) error_lvl = spdlog::level::off; + + image_io::LoadImageResult result = image_io::load_image(path, error_lvl); if (!result.valid) return false; scene::ImageBufferExperimental& buffer = image.image; diff --git a/modules/io/src/load_fbx.cpp b/modules/io/src/load_fbx.cpp index 0fc1a689..38c96bef 100644 --- a/modules/io/src/load_fbx.cpp +++ b/modules/io/src/load_fbx.cpp @@ -21,6 +21,7 @@ // ==== +#include #include #include #include @@ -188,7 +189,7 @@ MeshType convert_mesh_ufbx_to_lagrange(const ufbx_mesh* mesh, const LoadOptions& auto id = lmesh.template create_attribute( AttributeName::tangent, AttributeElement::Indexed, - AttributeUsage::Vector, + AttributeUsage::Tangent, dim); auto& attr = lmesh.template ref_indexed_attribute(id); attr.indices().resize_elements(mesh->vertex_tangent.indices.count); @@ -208,7 +209,7 @@ MeshType convert_mesh_ufbx_to_lagrange(const ufbx_mesh* mesh, const LoadOptions& auto id = lmesh.template create_attribute( AttributeName::bitangent, AttributeElement::Indexed, - AttributeUsage::Vector, + AttributeUsage::Bitangent, dim); auto& attr = lmesh.template ref_indexed_attribute(id); attr.indices().resize_elements(mesh->vertex_bitangent.indices.count); @@ -272,6 +273,12 @@ UfbxScene load_ufbx(const fs::path& filename) std::string filename_s = filename.string(); ufbx_load_opts opts{}; ufbx_error error{}; + + opts.target_axes.right = UFBX_COORDINATE_AXIS_POSITIVE_X; + opts.target_axes.front = UFBX_COORDINATE_AXIS_NEGATIVE_Y; + opts.target_axes.up = UFBX_COORDINATE_AXIS_POSITIVE_Z; + opts.target_unit_meters = 1.0; + return ufbx_load_file(filename_s.c_str(), &opts, &error); } @@ -282,6 +289,12 @@ UfbxScene load_ufbx(std::istream& input_stream) ufbx_load_opts opts{}; ufbx_error error{}; + + opts.target_axes.right = UFBX_COORDINATE_AXIS_POSITIVE_X; + opts.target_axes.front = UFBX_COORDINATE_AXIS_NEGATIVE_Y; + opts.target_axes.up = UFBX_COORDINATE_AXIS_POSITIVE_Z; + opts.target_unit_meters = 1.0; + return ufbx_load_memory(data.data(), data.size(), &opts, &error); } @@ -439,6 +452,7 @@ SceneType load_scene_fbx(const ufbx_scene* scene, const LoadOptions& opt) scene::ImageExperimental limage; limage.name = texture->name.data; + limage.image.element_type = AttributeValueType::e_uint8_t; // default for empty images limage.uri = texture->relative_filename.data; // note: there is no width/height anywhere. read the image from disk, or read png from texture->content. bool loaded = false; @@ -449,16 +463,19 @@ SceneType load_scene_fbx(const ufbx_scene* scene, const LoadOptions& opt) "Loading fbx embedded textures is currently unsupported, missing data for {}", limage.name); } else if (opt.load_images) { + loaded |= internal::try_load_image(texture->filename.data, opt, limage); loaded |= internal::try_load_image(texture->relative_filename.data, opt, limage); loaded |= internal::try_load_image(texture->absolute_filename.data, opt, limage); + if (!loaded && !opt.quiet) { + logger().warn("Failed to load texture image for texture '{}'", limage.name); + } } else { // opt.load_images is false: don't load, but consider it ok. loaded = true; } - if (loaded) { - lscene.add(std::move(limage)); - } + // Append the image to the scene even if not loaded, so that references are valid. + lscene.add(std::move(limage)); } auto try_load_texture = [&](const ufbx_texture* texture, scene::TextureInfo& tex_info) -> bool { @@ -547,8 +564,6 @@ void display_ufbx_scene_warnings(const ufbx_scene* scene) } if (metadata.may_contain_no_index) logger().warn("fbx warning: index arrays may contain invalid indices"); - if (metadata.may_contain_null_materials) - logger().warn("fbx warning: file may contain null materials"); if (metadata.may_contain_missing_vertex_position) logger().warn("fbx warning: vertex positions may be missing"); if (metadata.may_contain_broken_elements) diff --git a/modules/io/src/save_gltf.cpp b/modules/io/src/save_gltf.cpp index 21e249d3..0d5893ef 100644 --- a/modules/io/src/save_gltf.cpp +++ b/modules/io/src/save_gltf.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +53,7 @@ namespace lagrange::io { // namespace { + tinygltf::Value convert_value(const scene::Value& value) { switch (value.get_type_index()) { @@ -540,6 +542,25 @@ tinygltf::Mesh create_gltf_mesh( return mesh; } +template +SurfaceMesh ensure_triangulated( + std::string_view name, + const SurfaceMesh& lmesh, + const SaveOptions& options) +{ + if (lmesh.is_triangle_mesh()) { + return lmesh; + } + if (!options.quiet) { + logger().warn( + "Mesh `{}` is not a triangle mesh. Triangulating before saving to gltf.", + name); + } + auto mesh = lmesh; // copy + triangulate_polygonal_facets(mesh); + return mesh; +} + } // namespace // ===================================== @@ -614,8 +635,8 @@ tinygltf::Model lagrange_simple_scene_to_gltf_model( if (lmesh.get_num_vertices() == 0) continue; if (lscene.get_num_instances(i) == 0) continue; - la_runtime_assert(lmesh.is_triangle_mesh()); // only support triangles - model.meshes.push_back(create_gltf_mesh(model, lmesh, options)); + const auto& tmesh = ensure_triangulated(fmt::format("#{}", i), lmesh, options); + model.meshes.push_back(create_gltf_mesh(model, tmesh, options)); for (Index j = 0; j < lscene.get_num_instances(i); ++j) { const auto& instance = lscene.get_instance(i, j); @@ -914,7 +935,8 @@ tinygltf::Model lagrange_scene_to_gltf_model( tinygltf::Mesh mesh; for (const auto& mesh_instance : lnode.meshes) { const auto& lmesh = lscene.meshes[mesh_instance.mesh]; - tinygltf::Primitive prim = create_gltf_primitive(model, lmesh, options); + const auto& tmesh = ensure_triangulated(node.name, lmesh, options); + tinygltf::Primitive prim = create_gltf_primitive(model, tmesh, options); if (options.export_materials) { la_runtime_assert(mesh_instance.materials.size() == 1); prim.material = lagrange::safe_cast(mesh_instance.materials.front()); diff --git a/modules/io/src/save_obj.cpp b/modules/io/src/save_obj.cpp index 1cf9814a..ffdb1999 100644 --- a/modules/io/src/save_obj.cpp +++ b/modules/io/src/save_obj.cpp @@ -270,7 +270,8 @@ void write_texture_to_mtl( const scene::Scene& scene, const scene::TextureInfo& texture_info, const fs::path& base_dir, - const std::string& map_directive) + const std::string& map_directive, + bool quiet) { if (texture_info.index == scene::invalid_element) return; la_debug_assert(texture_info.index < scene.textures.size()); @@ -318,9 +319,11 @@ void write_texture_to_mtl( // Write the texture map directive fmt::print(mtl_stream, "{} {}\n", map_directive, image_filename.string()); - } else { - throw std::runtime_error( - fmt::format("Texture file not found: {}", source_path.string())); + } else if (!quiet) { + // Allow saving scenes with invalid texture paths + logger().warn( + "Texture file not found at URI: {}. Skipping texture.", + source_path.string()); } } else { // Neither image data nor URI exists @@ -330,7 +333,10 @@ void write_texture_to_mtl( } template -void write_mtl_file(const fs::path& mtl_filename, const scene::Scene& scene) +void write_mtl_file( + const fs::path& mtl_filename, + const scene::Scene& scene, + bool quiet) { fs::ofstream mtl_stream(mtl_filename); if (!mtl_stream) { @@ -390,12 +396,19 @@ void write_mtl_file(const fs::path& mtl_filename, const scene::Scene +#include +#include +#include +#include + +#include + +TEST_CASE("load_fbx", "[io][fbx]" LA_CORP_FLAG) +{ + lagrange::io::LoadOptions options; + options.quiet = true; + auto mesh = lagrange::io::load_mesh_fbx( + lagrange::testing::get_data_path("corp/io/cgt_c_table_coffee_table_003.fbx"), + options); + auto expected = lagrange::io::load_mesh_ply( + lagrange::testing::get_data_path("corp/io/cgt_c_table_coffee_table_003.ply"), + options); + REQUIRE(vertex_view(mesh) == vertex_view(expected)); + REQUIRE(facet_view(mesh) == facet_view(expected)); +} + +TEST_CASE("load_fbx_and_save", "[io][fbx]" LA_CORP_FLAG) +{ + lagrange::io::LoadOptions load_options; + lagrange::io::SaveOptions save_options; + load_options.quiet = true; + save_options.quiet = true; + auto scene = lagrange::io::load_scene_fbx( + lagrange::testing::get_data_path("corp/io/buffet_gray_001.fbx"), + load_options); + lagrange::fs::path output_glb = + lagrange::testing::get_test_output_path("test_io/buffet_gray_001.glb"); + lagrange::fs::path output_obj = + lagrange::testing::get_test_output_path("test_io/buffet_gray_001.obj"); + REQUIRE_NOTHROW(lagrange::io::save_scene(output_glb, scene, save_options)); + REQUIRE_NOTHROW(lagrange::io::save_scene(output_obj, scene, save_options)); +} diff --git a/modules/primitive/include/lagrange/primitive/legacy/SweepPath.h b/modules/primitive/include/lagrange/primitive/legacy/SweepPath.h index 9d1518f3..91915253 100644 --- a/modules/primitive/include/lagrange/primitive/legacy/SweepPath.h +++ b/modules/primitive/include/lagrange/primitive/legacy/SweepPath.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -369,12 +370,16 @@ class LinearSweepPath final : public SweepPath<_Scalar> bool operator==(const SweepPath& other) const override { +#if LAGRANGE_TARGET_FEATURE(RTTI) if (const auto* other_linear = dynamic_cast*>(&other)) { if (Parent::operator==(other)) { constexpr Scalar TOL = std::numeric_limits::epsilon() * 100; return (m_direction - other_linear->m_direction).norm() < TOL; } } +#else + la_runtime_assert(false, "RTTI is required for comparing LinearSweepPath"); +#endif return false; } @@ -506,6 +511,7 @@ class CircularArcSweepPath final : public SweepPath<_Scalar> bool operator==(const SweepPath& other) const override { +#if LAGRANGE_TARGET_FEATURE(RTTI) if (const auto* other_circular = dynamic_cast*>(&other)) { if (Parent::operator==(other)) { @@ -514,6 +520,9 @@ class CircularArcSweepPath final : public SweepPath<_Scalar> std::abs(m_theta - other_circular->m_theta) < TOL; } } +#else + la_runtime_assert(false, "RTTI is required for comparing LinearSweepPath"); +#endif return false; } @@ -733,6 +742,7 @@ class PolylineSweepPath final : public SweepPath bool operator==(const SweepPath& other) const override { +#if LAGRANGE_TARGET_FEATURE(RTTI) if (const auto* other_polyline = dynamic_cast*>(&other)) { if (Parent::operator==(other)) { @@ -742,6 +752,9 @@ class PolylineSweepPath final : public SweepPath TOL); } } +#else + la_runtime_assert(false, "RTTI is required for comparing LinearSweepPath"); +#endif return false; } diff --git a/modules/primitive/src/generate_rounded_cube.cpp b/modules/primitive/src/generate_rounded_cube.cpp index 70de9f41..b8995621 100644 --- a/modules/primitive/src/generate_rounded_cube.cpp +++ b/modules/primitive/src/generate_rounded_cube.cpp @@ -784,6 +784,7 @@ SurfaceMesh generate_rounded_cube_v0(RoundedCubeOptions setting) bvh::weld_vertices(mesh, weld_options); if (setting.triangulate) { + mesh.clear_edges(); triangulate_polygonal_facets(mesh); } diff --git a/modules/python/CMakeLists.txt b/modules/python/CMakeLists.txt index 9e55942a..693dcba4 100644 --- a/modules/python/CMakeLists.txt +++ b/modules/python/CMakeLists.txt @@ -201,3 +201,9 @@ function(lagrange_generate_python_binding_module) endforeach() endif() endfunction() + +# 5. download unit test data +if(NOT TARGET lagrange_download_data) + lagrange_download_data() +endif() +add_dependencies(lagrange_python lagrange_download_data) diff --git a/modules/scene/include/lagrange/scene/scene_convert.h b/modules/scene/include/lagrange/scene/scene_convert.h index c6ad2432..f10f60f7 100644 --- a/modules/scene/include/lagrange/scene/scene_convert.h +++ b/modules/scene/include/lagrange/scene/scene_convert.h @@ -64,4 +64,20 @@ SurfaceMesh scene_to_mesh( const TransformOptions& transform_options = {}, bool preserve_attributes = true); +/// +/// Converts a scene into a list of meshes with all the transforms applied. +/// +/// @param[in] scene Scene to convert. +/// @param[in] transform_options Options to use when applying mesh transformations. +/// +/// @tparam Scalar Input scene scalar type. +/// @tparam Index Input scene index type. +/// +/// @return List of meshes with transforms applied. +/// +template +std::vector> scene_to_meshes( + const Scene& scene, + const TransformOptions& transform_options = {}); + } // namespace lagrange::scene diff --git a/modules/scene/include/lagrange/scene/simple_scene_convert.h b/modules/scene/include/lagrange/scene/simple_scene_convert.h index 1492405c..db78a710 100644 --- a/modules/scene/include/lagrange/scene/simple_scene_convert.h +++ b/modules/scene/include/lagrange/scene/simple_scene_convert.h @@ -68,4 +68,21 @@ SurfaceMesh simple_scene_to_mesh( const TransformOptions& transform_options = {}, bool preserve_attributes = true); +/// +/// Converts a scene into a list of meshes with all the transforms applied. +/// +/// @param[in] scene Scene to convert. +/// @param[in] transform_options Options to use when applying mesh transformations. +/// +/// @tparam Scalar Input scene scalar type. +/// @tparam Index Input scene index type. +/// @tparam Dimension Input scene dimension. +/// +/// @return List of meshes with transforms applied. +/// +template +std::vector> simple_scene_to_meshes( + const SimpleScene& scene, + const TransformOptions& transform_options = {}); + } // namespace lagrange::scene diff --git a/modules/scene/python/scripts/extract_texture.py b/modules/scene/python/scripts/extract_texture.py index b4b41ff3..3f6365ac 100755 --- a/modules/scene/python/scripts/extract_texture.py +++ b/modules/scene/python/scripts/extract_texture.py @@ -28,7 +28,7 @@ def parse_args(): def dump_texture(img, filename): - img.uri = filename + img.uri = str(filename) img_buffer = img.image buffer = img_buffer.data.reshape((img_buffer.height, img_buffer.width, img_buffer.num_channels)) if img_buffer.num_channels == 4: diff --git a/modules/scene/python/src/bind_scene.h b/modules/scene/python/src/bind_scene.h index 5aa073bd..5589a542 100644 --- a/modules/scene/python/src/bind_scene.h +++ b/modules/scene/python/src/bind_scene.h @@ -703,6 +703,25 @@ void bind_scene(nb::module_& m) :return: Concatenated mesh.)"); + m.def( + "scene_to_meshes", + [](const SceneType& scene, bool normalize_normals, bool normalize_tangents_bitangents) { + TransformOptions transform_options; + transform_options.normalize_normals = normalize_normals; + transform_options.normalize_tangents_bitangents = normalize_tangents_bitangents; + return scene::scene_to_meshes(scene, transform_options); + }, + "scene"_a, + "normalize_normals"_a = TransformOptions{}.normalize_normals, + "normalize_tangents_bitangents"_a = TransformOptions{}.normalize_tangents_bitangents, + R"(Converts a scene into a list of meshes with all the transforms applied. + +:param scene: Scene to convert. +:param normalize_normals: If enabled, normals are normalized after transformation. +:param normalize_tangents_bitangents: If enabled, tangents and bitangents are normalized after transformation. + +:return: List of transformed meshes.)"); + m.def( "mesh_to_scene", [](const SceneType::MeshType& mesh) { return scene::mesh_to_scene(mesh); }, diff --git a/modules/scene/python/src/bind_simple_scene.h b/modules/scene/python/src/bind_simple_scene.h index f0fd754b..f40e0b56 100644 --- a/modules/scene/python/src/bind_simple_scene.h +++ b/modules/scene/python/src/bind_simple_scene.h @@ -194,6 +194,25 @@ input tensors are supported for setting the transform.)"); :return: Concatenated mesh.)"); + m.def( + "simple_scene_to_meshes", + [](const SimpleScene3D& scene, bool normalize_normals, bool normalize_tangents_bitangents) { + TransformOptions transform_options; + transform_options.normalize_normals = normalize_normals; + transform_options.normalize_tangents_bitangents = normalize_tangents_bitangents; + return scene::simple_scene_to_meshes(scene, transform_options); + }, + "scene"_a, + "normalize_normals"_a = TransformOptions{}.normalize_normals, + "normalize_tangents_bitangents"_a = TransformOptions{}.normalize_tangents_bitangents, + R"(Converts a scene into a list of meshes with all the transforms applied. + +:param scene: Scene to convert. +:param normalize_normals: If enabled, normals are normalized after transformation. +:param normalize_tangents_bitangents: If enabled, tangents and bitangents are normalized after transformation. + +:return: List of transformed meshes.)"); + using MeshType = lagrange::SurfaceMesh; m.def( "mesh_to_simple_scene", diff --git a/modules/scene/python/src/scene.cpp b/modules/scene/python/src/scene.cpp index 847ae24d..89485b91 100644 --- a/modules/scene/python/src/scene.cpp +++ b/modules/scene/python/src/scene.cpp @@ -49,6 +49,23 @@ void populate_scene_module(nb::module_& m) "the best result in terms of facet budget allocation, but is a bit slower than other " "options."); + nb::enum_( + m, + "UninstantiatedMeshesStrategy", + "Strategy for meshes without instances in a scene.") + .value( + "NONE", + lagrange::scene::UninstantiatedMeshesStrategy::None, + "Use backend-specific default behavior.") + .value( + "Skip", + lagrange::scene::UninstantiatedMeshesStrategy::Skip, + "Skip meshes with zero instances and keep originals in the output.") + .value( + "ReplaceWithEmpty", + lagrange::scene::UninstantiatedMeshesStrategy::ReplaceWithEmpty, + "Replace meshes with zero instances with empty meshes."); + nb::class_(m, "RemeshingOptions") .def(nb::init<>()) .def_rw( @@ -58,7 +75,15 @@ void populate_scene_module(nb::module_& m) .def_rw( "min_facets", &lagrange::scene::RemeshingOptions::min_facets, - "Minimum amount of facets for meshes in the scene."); + "Minimum amount of facets for meshes in the scene.") + .def_rw( + "uninstantiated_meshes_strategy", + &lagrange::scene::RemeshingOptions::uninstantiated_meshes_strategy, + "Behavior for meshes without instances in the scene.") + .def_rw( + "per_instance_importance", + &lagrange::scene::RemeshingOptions::per_instance_importance, + "Optional per-instance weights/importance. Must be > 0."); bind_scene(m); } diff --git a/modules/scene/python/tests/assets.py b/modules/scene/python/tests/assets.py new file mode 100644 index 00000000..e3e74b07 --- /dev/null +++ b/modules/scene/python/tests/assets.py @@ -0,0 +1,24 @@ +# +# Copyright 2023 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 single_triangle(): + mesh = lagrange.SurfaceMesh() + mesh.add_vertices(np.eye(3)) + mesh.add_triangle(0, 1, 2) + assert mesh.num_vertices == 3 + assert mesh.num_facets == 1 + return mesh diff --git a/modules/scene/python/tests/test_scene.py b/modules/scene/python/tests/test_scene.py index d807f3af..8e2a386e 100644 --- a/modules/scene/python/tests/test_scene.py +++ b/modules/scene/python/tests/test_scene.py @@ -12,10 +12,10 @@ import math import lagrange - -import pytest import numpy as np +from .assets import single_triangle + class TestScene: def test_empty_scene(self): @@ -209,3 +209,20 @@ def test_scene_extension(self): assert scene.extensions.data["extension0"] == {"key": [10, 1, 2]} scene.extensions.data["extension0"]["key"] = [3, 4, 5] assert scene.extensions.data["extension0"] == {"key": [3, 4, 5]} + + def test_scene_convert(self, single_triangle): + scene = lagrange.scene.mesh_to_scene(single_triangle) + scene2 = lagrange.scene.meshes_to_scene([single_triangle, single_triangle]) + mesh = lagrange.scene.scene_to_mesh(scene) + mesh_alt = lagrange.combine_meshes(lagrange.scene.scene_to_meshes(scene)) + mesh2 = lagrange.scene.scene_to_mesh(scene2) + mesh2_alt = lagrange.combine_meshes(lagrange.scene.scene_to_meshes(scene2)) + + assert mesh.num_vertices == 3 + assert mesh.num_facets == 1 + assert mesh2.num_vertices == 6 + assert mesh2.num_facets == 2 + assert np.all(mesh.vertices == mesh_alt.vertices) and np.all(mesh.facets == mesh_alt.facets) + assert np.all(mesh2.vertices == mesh2_alt.vertices) and np.all( + mesh2.facets == mesh2_alt.facets + ) diff --git a/modules/scene/python/tests/test_simple_scene.py b/modules/scene/python/tests/test_simple_scene.py index a5a5daab..42b27ad4 100644 --- a/modules/scene/python/tests/test_simple_scene.py +++ b/modules/scene/python/tests/test_simple_scene.py @@ -10,19 +10,9 @@ # governing permissions and limitations under the License. # import lagrange - -import pytest import numpy as np - -@pytest.fixture -def single_triangle(): - mesh = lagrange.SurfaceMesh() - mesh.add_vertices(np.eye(3)) - mesh.add_triangle(0, 1, 2) - assert mesh.num_vertices == 3 - assert mesh.num_facets == 1 - return mesh +from .assets import single_triangle class TestSimpleScene: @@ -80,11 +70,16 @@ def test_multiple_instances(self): def test_scene_convert(self, single_triangle): scene = lagrange.scene.mesh_to_simple_scene(single_triangle) scene2 = lagrange.scene.meshes_to_simple_scene([single_triangle, single_triangle]) - print(scene, type(scene)) mesh = lagrange.scene.simple_scene_to_mesh(scene) + mesh_alt = lagrange.combine_meshes(lagrange.scene.simple_scene_to_meshes(scene)) mesh2 = lagrange.scene.simple_scene_to_mesh(scene2) + mesh2_alt = lagrange.combine_meshes(lagrange.scene.simple_scene_to_meshes(scene2)) assert mesh.num_vertices == 3 assert mesh.num_facets == 1 assert mesh2.num_vertices == 6 assert mesh2.num_facets == 2 + assert np.all(mesh.vertices == mesh_alt.vertices) and np.all(mesh.facets == mesh_alt.facets) + assert np.all(mesh2.vertices == mesh2_alt.vertices) and np.all( + mesh2.facets == mesh2_alt.facets + ) diff --git a/modules/scene/src/scene_convert.cpp b/modules/scene/src/scene_convert.cpp index c27d399a..4b10fd4b 100644 --- a/modules/scene/src/scene_convert.cpp +++ b/modules/scene/src/scene_convert.cpp @@ -41,10 +41,9 @@ Scene meshes_to_scene(std::vector> mes } template -SurfaceMesh scene_to_mesh( +std::vector> scene_to_meshes( const Scene& scene, - const TransformOptions& transform_options, - bool preserve_attributes) + const TransformOptions& transform_options) { std::vector> meshes; @@ -65,7 +64,18 @@ SurfaceMesh scene_to_mesh( } } - return combine_meshes(meshes, preserve_attributes); + return meshes; +} + +template +SurfaceMesh scene_to_mesh( + const Scene& scene, + const TransformOptions& transform_options, + bool preserve_attributes) +{ + return combine_meshes( + scene_to_meshes(scene, transform_options), + preserve_attributes); } #define LA_X_scene_convert(_, Scalar, Index) \ @@ -75,7 +85,10 @@ SurfaceMesh scene_to_mesh( template LA_SCENE_API SurfaceMesh scene_to_mesh( \ const Scene& scene, \ const TransformOptions& transform_options, \ - bool preserve_attributes); + bool preserve_attributes); \ + template LA_SCENE_API std::vector> scene_to_meshes( \ + const Scene& scene, \ + const TransformOptions& transform_options); LA_SURFACE_MESH_X(scene_convert, 0) } // namespace lagrange::scene diff --git a/modules/scene/src/simple_scene_convert.cpp b/modules/scene/src/simple_scene_convert.cpp index d2e2f45f..b2432ef8 100644 --- a/modules/scene/src/simple_scene_convert.cpp +++ b/modules/scene/src/simple_scene_convert.cpp @@ -41,10 +41,9 @@ SimpleScene meshes_to_simple_scene( } template -SurfaceMesh simple_scene_to_mesh( +std::vector> simple_scene_to_meshes( const SimpleScene& scene, - const TransformOptions& transform_options, - bool preserve_attributes) + const TransformOptions& transform_options) { std::vector> meshes; meshes.reserve(scene.compute_num_instances()); @@ -56,18 +55,33 @@ SurfaceMesh simple_scene_to_mesh( instance.transform, transform_options)); }); - return combine_meshes(meshes, preserve_attributes); + + return meshes; +} + +template +SurfaceMesh simple_scene_to_mesh( + const SimpleScene& scene, + const TransformOptions& transform_options, + bool preserve_attributes) +{ + return combine_meshes( + simple_scene_to_meshes(scene, transform_options), + preserve_attributes); } -#define LA_X_simple_scene_convert(_, Scalar, Index, Dimension) \ - template LA_SCENE_API SimpleScene mesh_to_simple_scene( \ - SurfaceMesh mesh); \ - template LA_SCENE_API SimpleScene meshes_to_simple_scene( \ - std::vector> meshes); \ - template LA_SCENE_API SurfaceMesh simple_scene_to_mesh( \ - const SimpleScene& scene, \ - const TransformOptions& transform_options, \ - bool preserve_attributes); +#define LA_X_simple_scene_convert(_, Scalar, Index, Dimension) \ + template LA_SCENE_API SimpleScene mesh_to_simple_scene( \ + SurfaceMesh mesh); \ + template LA_SCENE_API SimpleScene meshes_to_simple_scene( \ + std::vector> meshes); \ + template LA_SCENE_API SurfaceMesh simple_scene_to_mesh( \ + const SimpleScene& scene, \ + const TransformOptions& transform_options, \ + bool preserve_attributes); \ + template LA_SCENE_API std::vector> simple_scene_to_meshes( \ + const SimpleScene& scene, \ + const TransformOptions& transform_options); LA_SIMPLE_SCENE_X(simple_scene_convert, 0) } // namespace lagrange::scene diff --git a/modules/subdivision/examples/mesh_subdivision.cpp b/modules/subdivision/examples/mesh_subdivision.cpp index fce1a173..22897740 100644 --- a/modules/subdivision/examples/mesh_subdivision.cpp +++ b/modules/subdivision/examples/mesh_subdivision.cpp @@ -14,22 +14,17 @@ #include #include -#include #include -#include -#include -#include #include #include -#include -#include -#include +#include #include #include #include #include #include #include +#include #include #include @@ -45,11 +40,12 @@ int main(int argc, char** argv) std::string output = "output.obj"; std::string scheme = "auto"; bool output_btn = false; - std::optional autodetect_normal_threshold; + std::optional autodetect_normal_threshold_deg; int log_level = 1; // debug } args; - lagrange::subdivision::SubdivisionOptions options; + lagrange::subdivision::SubdivisionOptions subdivision_options; + lagrange::subdivision::SharpnessOptions sharpness_options; std::map map{ {"Uniform", lagrange::subdivision::RefinementType::Uniform}, @@ -61,20 +57,23 @@ int main(int argc, char** argv) app.add_option("output", args.output, "Output mesh."); app.add_option("-s,--scheme", args.scheme, "Subdivision scheme") ->check(CLI::IsMember({"auto", "bilinear", "loop", "catmark", "sqrt", "midpoint"})); - app.add_option("-n,--num-levels", options.num_levels, "Number of subdivision levels"); + app.add_option( + "-n,--num-levels", + subdivision_options.num_levels, + "Number of subdivision levels"); app.add_option( "-a,--autodetect-normal-threshold", - args.autodetect_normal_threshold, + args.autodetect_normal_threshold_deg, "Normal angle threshold (in degree) for autodetecting sharp edges"); app.add_flag( "--limit", - options.use_limit_surface, + subdivision_options.use_limit_surface, "Project vertex attributes to the limit surface"); - app.add_option("--refinement", options.refinement, "Mesh refinement method") + app.add_option("--refinement", subdivision_options.refinement, "Mesh refinement method") ->transform(CLI::CheckedTransformer(map, CLI::ignore_case)); app.add_option( "--edge-length", - options.max_edge_length, + subdivision_options.max_edge_length, "Max edge length target for adaptive refinement"); app.add_flag("--normal", args.output_btn, "Compute limit normal as a vertex attribute"); app.add_option("-l,--level", args.log_level, "Log level (0 = most verbose, 6 = off)."); @@ -123,46 +122,14 @@ int main(int argc, char** argv) lagrange::weld_indexed_attribute(mesh, mesh.get_attribute_id(name), w_opts); } }); - // Find an attribute to use as facet normal if possible (defines sharp edges) - std::optional normal_id = - lagrange::find_matching_attribute(mesh, lagrange::AttributeUsage::Normal); - if (normal_id.has_value()) { - lagrange::logger().info( - "Found indexed normal attribute: {}", - mesh.get_attribute_name(normal_id.value())); - } - // If autosmooth normals are requested by user, compute them (unless input asset already has - // normals) - if (!normal_id.has_value() && args.autodetect_normal_threshold.has_value()) { - lagrange::logger().info( - "Computing autosmooth normals with a threshold of {} degrees", - args.autodetect_normal_threshold.value()); - float feature_angle_threshold = - args.autodetect_normal_threshold.value() * lagrange::internal::pi / 180.f; - normal_id = lagrange::compute_normal(mesh, feature_angle_threshold); - } - // Finally, compute edge sharpness info based on indexed normal topology - if (normal_id.has_value()) { - lagrange::logger().info("Using mesh normals to set sharpness flag."); - auto seam_id = lagrange::compute_seam_edges(mesh, normal_id.value()); - auto edge_sharpness_id = lagrange::cast_attribute(mesh, seam_id, "edge_sharpness"); - options.edge_sharpness_attr = edge_sharpness_id; - - // Set vertex sharpness to 1 for leaf and junction vertices - lagrange::VertexValenceOptions v_opts; - v_opts.induced_by_attribute = mesh.get_attribute_name(seam_id); - auto valence_id = lagrange::compute_vertex_valence(mesh, v_opts); - auto valence = lagrange::attribute_vector_view(mesh, valence_id); - auto vertex_sharpness_id = mesh.create_attribute( - "vertex_sharpness", - lagrange::AttributeElement::Vertex, - lagrange::AttributeUsage::Scalar); - auto vertex_sharpness = lagrange::attribute_vector_ref(mesh, vertex_sharpness_id); - for (uint32_t v = 0; v < mesh.get_num_vertices(); ++v) { - vertex_sharpness[v] = (valence[v] == 1 || valence[v] > 2 ? 1.f : 0.f); - } - options.vertex_sharpness_attr = vertex_sharpness_id; + // Compute sharpness information + if (args.autodetect_normal_threshold_deg.has_value()) { + sharpness_options.feature_angle_threshold = + lagrange::to_radians(args.autodetect_normal_threshold_deg.value()); } + auto sharpness_results = lagrange::subdivision::compute_sharpness(mesh, sharpness_options); + subdivision_options.vertex_sharpness_attr = sharpness_results.vertex_sharpness_attr; + subdivision_options.edge_sharpness_attr = sharpness_results.edge_sharpness_attr; ////////////////////// // Mesh subdivision // @@ -171,29 +138,29 @@ int main(int argc, char** argv) if ((std::set{"auto", "bilinear", "loop", "catmark"}).count(args.scheme)) { // Convert subdiv scheme to enum if (args.scheme == "loop") { - options.scheme = lagrange::subdivision::SchemeType::Loop; + subdivision_options.scheme = lagrange::subdivision::SchemeType::Loop; } else if (args.scheme == "catmark") { - options.scheme = lagrange::subdivision::SchemeType::CatmullClark; + subdivision_options.scheme = lagrange::subdivision::SchemeType::CatmullClark; } else if (args.scheme == "bilinear") { - options.scheme = lagrange::subdivision::SchemeType::Bilinear; + subdivision_options.scheme = lagrange::subdivision::SchemeType::Bilinear; } - if (args.output_btn && normal_id.has_value()) { + if (args.output_btn && sharpness_results.normal_attr.has_value()) { // Only output a single set of normals in this example - mesh.delete_attribute(mesh.get_attribute_name(normal_id.value())); + mesh.delete_attribute(mesh.get_attribute_name(sharpness_results.normal_attr.value())); } if (args.output_btn) { - options.output_limit_normals = "normal"; + subdivision_options.output_limit_normals = "normal"; } - mesh = lagrange::subdivision::subdivide_mesh(mesh, options); + mesh = lagrange::subdivision::subdivide_mesh(mesh, subdivision_options); if (args.output_btn) { map_attribute_in_place(mesh, "normal", lagrange::AttributeElement::Indexed); } } else if (args.scheme == "sqrt") { - for (unsigned i = 0; i < options.num_levels; ++i) { + for (unsigned i = 0; i < subdivision_options.num_levels; ++i) { mesh = lagrange::subdivision::sqrt_subdivision(mesh); - if (i + 1 < options.num_levels) { + if (i + 1 < subdivision_options.num_levels) { lagrange::logger().info( "Intermediate mesh has {} vertices and {} facets", mesh.get_num_vertices(), @@ -201,9 +168,9 @@ int main(int argc, char** argv) } } } else if (args.scheme == "midpoint") { - for (unsigned i = 0; i < options.num_levels; ++i) { + for (unsigned i = 0; i < subdivision_options.num_levels; ++i) { mesh = lagrange::subdivision::midpoint_subdivision(mesh); - if (i + 1 < options.num_levels) { + if (i + 1 < subdivision_options.num_levels) { lagrange::logger().info( "Intermediate mesh has {} vertices and {} facets", mesh.get_num_vertices(), diff --git a/modules/subdivision/include/lagrange/subdivision/compute_sharpness.h b/modules/subdivision/include/lagrange/subdivision/compute_sharpness.h new file mode 100644 index 00000000..e6238239 --- /dev/null +++ b/modules/subdivision/include/lagrange/subdivision/compute_sharpness.h @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#pragma once + +#include + +#include + +namespace lagrange::subdivision { + +/// @addtogroup module-subdivision +/// @{ + +/// +/// Attribute ids returned by the `compute_sharpness` function. +/// +struct SharpnessResults +{ + /// Attribute id of the indexed normal attribute used to compute sharpness information. + std::optional normal_attr; + + /// Attribute id to use for vertex sharpness in the `subdivide_mesh` function (has `float` type). + std::optional vertex_sharpness_attr; + + /// Attribute id to use for edge sharpness in the `subdivide_mesh` function (has `float` type). + std::optional edge_sharpness_attr; +}; + +/// +/// Input options for the `compute_sharpness` function. +/// +struct SharpnessOptions +{ + /// If provided, name of the normal attribute to use as indexed normals to define sharp edges. + /// If not provided, the function will attempt to find an existing indexed normal attribute. If + /// no such attribute is found, autosmooth normals will be computed based on the + /// `feature_angle_threshold` parameter. + std::string_view normal_attribute_name; + + /// Feature angle threshold (in radians) to detect sharp edges when computing autosmooth + /// normals. By default, if no indexed normal attribute is found, no autosmooth normals will be + /// computed. + std::optional feature_angle_threshold; +}; + +/// +/// Compute subdivision options to handle sharp edges and vertices based on existing mesh +/// attributes. +/// +/// This function performs the following operations: +/// 1. Find an attribute to use as indexed normals to define sharp edges. +/// 2. If not found, compute indexed corner normals based on a user-defined feature angle threshold. +/// 3. Compute edge and vertex sharpness `float` attributes based on indexed normals topology. +/// +/// The mesh is modified in place to add the necessary attributes. If no indexed normal attribute is +/// found, and no autosmooth feature angle threshold is provided, then no sharpness information is +/// computed. +/// +/// @note Please ensure that relevant input indexed normals are properly welded before +/// calling this function. +/// +/// @param[in, out] mesh Input mesh to prepare for subdivision. +/// @param[in] options Input options for computing sharpness information. +/// +/// @tparam Scalar Mesh scalar type. +/// @tparam Index Mesh index type. +/// +/// @return Normal, edge and vertex sharpness attribute ids. +/// +template +SharpnessResults compute_sharpness( + SurfaceMesh& mesh, + const SharpnessOptions& options = {}); + +/// @} + +} // namespace lagrange::subdivision diff --git a/modules/subdivision/python/src/subdivision.cpp b/modules/subdivision/python/src/subdivision.cpp index 6708bdbf..8c52c4f7 100644 --- a/modules/subdivision/python/src/subdivision.cpp +++ b/modules/subdivision/python/src/subdivision.cpp @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +#include #include #include @@ -27,38 +28,114 @@ void populate_subdivision_module(nb::module_& m) using MeshType = SurfaceMesh; nb::enum_(m, "SchemeType", "Subdivision scheme type") - .value("Bilinear", lagrange::subdivision::SchemeType::Bilinear) - .value("CatmullClark", lagrange::subdivision::SchemeType::CatmullClark) - .value("Loop", lagrange::subdivision::SchemeType::Loop); + .value( + "Bilinear", + lagrange::subdivision::SchemeType::Bilinear, + "Bilinear scheme, useful before applying displacement.") + .value( + "CatmullClark", + lagrange::subdivision::SchemeType::CatmullClark, + "Catmull-Clark scheme for quad-dominant meshes.") + .value("Loop", lagrange::subdivision::SchemeType::Loop, "Loop scheme for triangle meshes."); nb::enum_( m, "VertexBoundaryInterpolation", "Vertex boundary interpolation rule") - .value("NoInterpolation", lagrange::subdivision::VertexBoundaryInterpolation::None) - .value("EdgeOnly", lagrange::subdivision::VertexBoundaryInterpolation::EdgeOnly) - .value("EdgeAndCorner", lagrange::subdivision::VertexBoundaryInterpolation::EdgeAndCorner); + .value( + "NoInterpolation", + lagrange::subdivision::VertexBoundaryInterpolation::None, + "Do not interpolate boundary edges.") + .value( + "EdgeOnly", + lagrange::subdivision::VertexBoundaryInterpolation::EdgeOnly, + "Interpolate boundary edges only.") + .value( + "EdgeAndCorner", + lagrange::subdivision::VertexBoundaryInterpolation::EdgeAndCorner, + "Interpolate boundary edges and corners."); nb::enum_( m, "FaceVaryingInterpolation", "Face-varying interpolation rule") - .value("Smooth", lagrange::subdivision::FaceVaryingInterpolation::None) - .value("CornersOnly", lagrange::subdivision::FaceVaryingInterpolation::CornersOnly) - .value("CornersPlus1", lagrange::subdivision::FaceVaryingInterpolation::CornersPlus1) - .value("CornersPlus2", lagrange::subdivision::FaceVaryingInterpolation::CornersPlus2) - .value("Boundaries", lagrange::subdivision::FaceVaryingInterpolation::Boundaries) - .value("All", lagrange::subdivision::FaceVaryingInterpolation::All); + .value( + "Smooth", + lagrange::subdivision::FaceVaryingInterpolation::None, + "Smooth interpolation everywhere the mesh is smooth.") + .value( + "CornersOnly", + lagrange::subdivision::FaceVaryingInterpolation::CornersOnly, + "Linear interpolation at corners only.") + .value( + "CornersPlus1", + lagrange::subdivision::FaceVaryingInterpolation::CornersPlus1, + "CornersOnly plus sharpening at junctions of 3+ regions.") + .value( + "CornersPlus2", + lagrange::subdivision::FaceVaryingInterpolation::CornersPlus2, + "CornersPlus1 plus sharpening of darts and concave corners.") + .value( + "Boundaries", + lagrange::subdivision::FaceVaryingInterpolation::Boundaries, + "Linear interpolation along all boundaries.") + .value( + "All", + lagrange::subdivision::FaceVaryingInterpolation::All, + "Linear interpolation everywhere."); nb::enum_( m, "InterpolatedAttributesSelection", "Selection tag for interpolated attributes") - .value("All", lagrange::subdivision::InterpolatedAttributes::SelectionType::All) - .value("Empty", lagrange::subdivision::InterpolatedAttributes::SelectionType::None) - .value("Selected", lagrange::subdivision::InterpolatedAttributes::SelectionType::Selected); + .value( + "All", + lagrange::subdivision::InterpolatedAttributes::SelectionType::All, + "Interpolate all compatible attributes.") + .value( + "Empty", + lagrange::subdivision::InterpolatedAttributes::SelectionType::None, + "Do not interpolate any attributes.") + .value( + "Selected", + lagrange::subdivision::InterpolatedAttributes::SelectionType::Selected, + "Interpolate only selected attributes."); - using Options = lagrange::subdivision::SubdivisionOptions; + m.def( + "compute_sharpness", + [](MeshType& mesh, + std::optional normal_attribute_name, + std::optional feature_angle_threshold) { + lagrange::subdivision::SharpnessOptions options; + options.normal_attribute_name = normal_attribute_name.value_or(""); + options.feature_angle_threshold = feature_angle_threshold; + auto res = lagrange::subdivision::compute_sharpness(mesh, options); + return std::make_tuple( + res.vertex_sharpness_attr, + res.edge_sharpness_attr, + res.normal_attr); + }, + "mesh"_a, + "normal_attribute_name"_a = nb::none(), + "feature_angle_threshold"_a = nb::none(), + R"(Computes sharpness attributes for subdivision based on existing mesh normals. + +:param mesh: The input mesh. Modified in place to add the necessary attributes. +:param normal_attribute_name: If provided, name of the normal attribute to use as indexed normals to + define sharp edges. If not provided, the function will attempt to find an existing indexed normal + attribute. If no such attribute is found, autosmooth normals will be computed based on the + `feature_angle_threshold` parameter. +:param feature_angle_threshold: Feature angle threshold (in radians) to detect sharp edges when + computing autosmooth normals. By default, if no indexed normal attribute is found, no autosmooth + normals will be computed. + +:returns: A tuple containing: + - vertex_sharpness_attr: Attribute id to use for vertex sharpness in the `subdivide_mesh` function. + - edge_sharpness_attr: Attribute id to use for edge sharpness in the `subdivide_mesh` function. + - normal_attr: Attribute id of the indexed normal attribute used to compute sharpness information. +)"); + + using SubdivOptions = lagrange::subdivision::SubdivisionOptions; m.def( "subdivide_mesh", [](const MeshType& mesh, @@ -115,14 +192,15 @@ void populate_subdivision_module(nb::module_& m) return lagrange::subdivision::subdivide_mesh(mesh, options); }, "mesh"_a, - "num_levels"_a, + "num_levels"_a = 1, "scheme"_a = nb::none(), "adaptive"_a = false, "max_edge_length"_a = nb::none(), - "vertex_boundary_interpolation"_a = Options{}.vertex_boundary_interpolation, - "face_varying_interpolation"_a = Options{}.face_varying_interpolation, - "use_limit_surface"_a = Options{}.use_limit_surface, - "interpolated_attributes_selection"_a = Options{}.interpolated_attributes.selection_type, + "vertex_boundary_interpolation"_a = SubdivOptions{}.vertex_boundary_interpolation, + "face_varying_interpolation"_a = SubdivOptions{}.face_varying_interpolation, + "use_limit_surface"_a = SubdivOptions{}.use_limit_surface, + "interpolated_attributes_selection"_a = + SubdivOptions{}.interpolated_attributes.selection_type, "interpolated_smooth_attributes"_a = nb::none(), "interpolated_linear_attributes"_a = nb::none(), "edge_sharpness_attr"_a = nb::none(), @@ -133,19 +211,22 @@ void populate_subdivision_module(nb::module_& m) "output_limit_bitangents"_a = nb::none(), R"(Evaluates the subdivision surface of a polygonal mesh. -:param mesh: The source mesh. -:param num_levels: The number of levels of subdivision to apply. -:param scheme: The subdivision scheme to use. -:param adaptive: Whether to use adaptive subdivision. -:param max_edge_length: The maximum edge length for adaptive subdivision. -:param vertex_boundary_interpolation: Vertex boundary interpolation rule. -:param face_varying_interpolation: Face-varying interpolation rule. -:param use_limit_surface: Interpolate all data to the limit surface. -:param edge_sharpness_attr: Per-edge scalar attribute denoting edge sharpness. Sharpness values must be in [0, 1] (0 means smooth, 1 means sharp). -:param vertex_sharpness_attr: Per-vertex scalar attribute denoting vertex sharpness (e.g. for boundary corners). Sharpness values must be in [0, 1] (0 means smooth, 1 means sharp). -:param face_hole_attr: Per-face integer attribute denoting face holes. A non-zero value means the facet is a hole. If a face is tagged as a hole, the limit surface will not be generated for that face. -:param output_limit_normals: Output name for a newly computed per-vertex attribute containing the normals to the limit surface. Skipped if left empty. -:param output_limit_tangents: Output name for a newly computed per-vertex attribute containing the tangents (first derivatives) to the limit surface. Skipped if left empty. +:param mesh: The source mesh. +:param num_levels: The number of levels of subdivision to apply. +:param scheme: Subdivision scheme. If None, uses Loop for triangle meshes and CatmullClark for quad-dominant meshes. +:param adaptive: Whether to use edge-adaptive refinement. +:param max_edge_length: Maximum edge length for adaptive refinement. If None, uses longest edge / num_levels. Ignored when adaptive is False. +:param vertex_boundary_interpolation: Vertex boundary interpolation rule. +:param face_varying_interpolation: Face-varying interpolation rule. +:param use_limit_surface: Interpolate all data to the limit surface. +:param interpolated_attributes_selection: Whether to interpolate all, none, or selected attributes. +:param interpolated_smooth_attributes: Attribute ids to smoothly interpolate (per-vertex or indexed). +:param interpolated_linear_attributes: Attribute ids to linearly interpolate (per-vertex only). +:param edge_sharpness_attr: Per-edge scalar attribute denoting edge sharpness. Sharpness values must be in [0, 1] (0 means smooth, 1 means sharp). +:param vertex_sharpness_attr: Per-vertex scalar attribute denoting vertex sharpness (e.g. for boundary corners). Sharpness values must be in [0, 1] (0 means smooth, 1 means sharp). +:param face_hole_attr: Per-face integer attribute denoting face holes. A non-zero value means the facet is a hole. If a face is tagged as a hole, the limit surface will not be generated for that face. +:param output_limit_normals: Output name for a newly computed per-vertex attribute containing the normals to the limit surface. Skipped if left empty. +:param output_limit_tangents: Output name for a newly computed per-vertex attribute containing the tangents (first derivatives) to the limit surface. Skipped if left empty. :param output_limit_bitangents: Output name for a newly computed per-vertex attribute containing the bitangents (second derivative) to the limit surface. Skipped if left empty. :return: The subdivided mesh.)"); diff --git a/modules/subdivision/python/tests/test_mesh_subdivision.py b/modules/subdivision/python/tests/test_mesh_subdivision.py index 16e28cc0..00d70092 100644 --- a/modules/subdivision/python/tests/test_mesh_subdivision.py +++ b/modules/subdivision/python/tests/test_mesh_subdivision.py @@ -50,5 +50,8 @@ def cube(): class TestMeshSubdivision: def test_basic(self, cube): num_levels = 2 - mesh = lagrange.subdivision.subdivide_mesh(cube, num_levels=num_levels) + vert_id, edge_id, normal_id = lagrange.subdivision.compute_sharpness(cube) + mesh = lagrange.subdivision.subdivide_mesh( + cube, num_levels=num_levels, vertex_sharpness_attr=vert_id, edge_sharpness_attr=edge_id + ) assert mesh.num_facets == cube.num_corners * 4 ** (num_levels - 1) diff --git a/modules/subdivision/src/compute_sharpness.cpp b/modules/subdivision/src/compute_sharpness.cpp new file mode 100644 index 00000000..58d670a6 --- /dev/null +++ b/modules/subdivision/src/compute_sharpness.cpp @@ -0,0 +1,91 @@ +/* + * 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 lagrange::subdivision { + +template +SharpnessResults compute_sharpness( + SurfaceMesh& mesh, + const SharpnessOptions& options) +{ + // Find an attribute to use as indexed normal if possible (defines sharp edges) + AttributeMatcher matcher; + matcher.usages = AttributeUsage::Normal; + matcher.element_types = AttributeElement::Indexed; + std::optional normal_id; + for (AttributeId id : find_matching_attributes(mesh, matcher)) { + if (options.normal_attribute_name.empty() || + mesh.get_attribute_name(id) == options.normal_attribute_name) { + normal_id = id; + break; + } + } + if (normal_id.has_value()) { + logger().debug( + "Using indexed normal attribute: {}", + mesh.get_attribute_name(normal_id.value())); + } else if (options.feature_angle_threshold.has_value()) { + logger().debug( + "Computing autosmooth normals with a threshold of {} degrees", + to_degrees(options.feature_angle_threshold.value())); + normal_id = compute_normal( + mesh, + static_cast(options.feature_angle_threshold.value())); + } + + // Finally, compute edge sharpness info based on indexed normal topology + SharpnessResults results; + if (normal_id.has_value()) { + logger().debug("Using mesh normals to set sharpness flag."); + results.normal_attr = normal_id; + + auto seam_id = compute_seam_edges(mesh, normal_id.value()); + auto edge_sharpness_id = cast_attribute(mesh, seam_id, "edge_sharpness"); + results.edge_sharpness_attr = edge_sharpness_id; + + // Set vertex sharpness to 1 for leaf and junction vertices + VertexValenceOptions v_opts; + v_opts.induced_by_attribute = mesh.get_attribute_name(seam_id); + auto valence_id = compute_vertex_valence(mesh, v_opts); + auto valence = attribute_vector_view(mesh, valence_id); + auto vertex_sharpness_id = mesh.template create_attribute( + "vertex_sharpness", + AttributeElement::Vertex, + AttributeUsage::Scalar); + auto vertex_sharpness = attribute_vector_ref(mesh, vertex_sharpness_id); + for (Index v = 0; v < mesh.get_num_vertices(); ++v) { + vertex_sharpness[v] = (valence[v] == 1 || valence[v] > 2 ? 1.f : 0.f); + } + results.vertex_sharpness_attr = vertex_sharpness_id; + } + + return results; +} + +#define LA_X_compute_sharpness(_, Scalar, Index) \ + template LA_SUBDIVISION_API SharpnessResults compute_sharpness( \ + SurfaceMesh& mesh, \ + const SharpnessOptions& options); +LA_SURFACE_MESH_X(compute_sharpness, 0) + +} // namespace lagrange::subdivision diff --git a/modules/subdivision/tests/test_mesh_subdivision.cpp b/modules/subdivision/tests/test_mesh_subdivision.cpp index ff6b8764..048dd719 100644 --- a/modules/subdivision/tests/test_mesh_subdivision.cpp +++ b/modules/subdivision/tests/test_mesh_subdivision.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -535,3 +536,216 @@ TEST_CASE("mesh_subdivision_midpoint", "[mesh][subdivision][sqrt]") REQUIRE(vertex_view(subdivided_mesh) == vertex_view(expected_mesh)); REQUIRE(facet_view(subdivided_mesh) == facet_view(expected_mesh)); } + +TEST_CASE("compute_sharpness", "[mesh][subdivision][sharpness]") +{ + using Scalar = double; + using Index = uint32_t; + + // Constants for common feature angles and sharpness thresholds + constexpr Scalar feature_angle_90_deg = 0.5; // 90 degrees in units of pi + constexpr Scalar feature_angle_45_deg = 0.25; // 45 degrees in units of pi + constexpr Scalar feature_angle_18_deg = 0.1; // 18 degrees in units of pi + constexpr float sharpness_threshold = + 0.5f; // Threshold for determining if an edge/vertex is sharp + + SECTION("with indexed normals") + { + // Create a simple cube mesh with indexed normals + auto mesh = + lagrange::testing::load_surface_mesh("open/subdivision/cube.obj"); + + // Add indexed normals with a feature angle + auto nrm_id = lagrange::compute_normal(mesh, lagrange::internal::pi * feature_angle_90_deg); + auto nrm_name = mesh.get_attribute_name(nrm_id); + + // Call compute_sharpness with the indexed normal attribute + lagrange::subdivision::SharpnessOptions options; + options.normal_attribute_name = nrm_name; + auto results = lagrange::subdivision::compute_sharpness(mesh, options); + + // Verify that the function returned attribute ids + REQUIRE(results.normal_attr.has_value()); + REQUIRE(results.edge_sharpness_attr.has_value()); + REQUIRE(results.vertex_sharpness_attr.has_value()); + + // Verify the normal attribute is the one we provided + REQUIRE(results.normal_attr.value() == nrm_id); + + // Verify edge sharpness attribute exists and has correct properties + const auto& edge_sharpness_attr = + mesh.get_attribute_base(results.edge_sharpness_attr.value()); + REQUIRE(edge_sharpness_attr.get_element_type() == lagrange::AttributeElement::Edge); + REQUIRE(edge_sharpness_attr.get_num_channels() == 1); + + // Verify vertex sharpness attribute exists and has correct properties + const auto& vertex_sharpness_attr = + mesh.get_attribute_base(results.vertex_sharpness_attr.value()); + REQUIRE(vertex_sharpness_attr.get_element_type() == lagrange::AttributeElement::Vertex); + REQUIRE(vertex_sharpness_attr.get_num_channels() == 1); + + // Verify that sharp edges are marked (cube has 12 edges, all should be sharp due to 90° + // angles) + mesh.initialize_edges(); + auto edge_sharpness = + lagrange::attribute_vector_view(mesh, results.edge_sharpness_attr.value()); + Index num_sharp_edges = 0; + for (Index e = 0; e < mesh.get_num_edges(); ++e) { + if (edge_sharpness[e] > sharpness_threshold) { + num_sharp_edges++; + } + } + REQUIRE(num_sharp_edges == 12); // All edges of a cube should be sharp + + // Verify that corner vertices are sharp (cube has 8 vertices, all should be sharp) + auto vertex_sharpness = + lagrange::attribute_vector_view(mesh, results.vertex_sharpness_attr.value()); + Index num_sharp_vertices = 0; + for (Index v = 0; v < mesh.get_num_vertices(); ++v) { + if (vertex_sharpness[v] > sharpness_threshold) { + num_sharp_vertices++; + } + } + REQUIRE(num_sharp_vertices == 8); // All vertices of a cube should be sharp + } + + SECTION("with feature angle threshold") + { + // Create a simple cube mesh without indexed normals + auto mesh = + lagrange::testing::load_surface_mesh("open/subdivision/cube.obj"); + + // Remove any existing indexed normal attributes + std::vector to_remove; + lagrange::AttributeMatcher matcher; + matcher.usages = lagrange::AttributeUsage::Normal; + matcher.element_types = lagrange::AttributeElement::Indexed; + for (auto id : lagrange::find_matching_attributes(mesh, matcher)) { + to_remove.push_back(id); + } + for (auto id : to_remove) { + mesh.delete_attribute(mesh.get_attribute_name(id)); + } + + // Call compute_sharpness with a feature angle threshold + lagrange::subdivision::SharpnessOptions options; + options.feature_angle_threshold = + lagrange::internal::pi * feature_angle_90_deg; // 90 degrees + auto results = lagrange::subdivision::compute_sharpness(mesh, options); + + // Verify that the function computed normals and sharpness attributes + REQUIRE(results.normal_attr.has_value()); + REQUIRE(results.edge_sharpness_attr.has_value()); + REQUIRE(results.vertex_sharpness_attr.has_value()); + + // Verify that an indexed normal attribute was created + const auto& normal_attr = mesh.get_attribute_base(results.normal_attr.value()); + REQUIRE(normal_attr.get_element_type() == lagrange::AttributeElement::Indexed); + } + + SECTION("without normals or feature angle") + { + // Create a simple cube mesh + auto mesh = + lagrange::testing::load_surface_mesh("open/subdivision/cube.obj"); + + // Remove any existing indexed normal attributes + std::vector to_remove; + lagrange::AttributeMatcher matcher; + matcher.usages = lagrange::AttributeUsage::Normal; + matcher.element_types = lagrange::AttributeElement::Indexed; + for (auto id : lagrange::find_matching_attributes(mesh, matcher)) { + to_remove.push_back(id); + } + for (auto id : to_remove) { + mesh.delete_attribute(mesh.get_attribute_name(id)); + } + + // Call compute_sharpness without providing normals or feature angle + lagrange::subdivision::SharpnessOptions options; + auto results = lagrange::subdivision::compute_sharpness(mesh, options); + + // Verify that no sharpness information is computed + REQUIRE(!results.normal_attr.has_value()); + REQUIRE(!results.edge_sharpness_attr.has_value()); + REQUIRE(!results.vertex_sharpness_attr.has_value()); + } + + SECTION("vertex sharpness computation - junction vertices") + { + // Create a simple quad mesh + lagrange::SurfaceMesh mesh(3); + // clang-format off + mesh.add_vertices(5, { + 0.5, 0.5, 1, // vertex 0 - center junction (valence 4) + 1, 0, 0, // vertex 1 + 1, 1, 0, // vertex 2 + 0, 1, 0, // vertex 3 + 0, 0, 0 // vertex 4 + }); + // clang-format on + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + mesh.add_triangle(0, 3, 4); + mesh.add_triangle(0, 4, 1); + + // Add indexed normals + lagrange::compute_normal(mesh, lagrange::internal::pi * feature_angle_45_deg); + + // Call compute_sharpness + lagrange::subdivision::SharpnessOptions options; + auto results = lagrange::subdivision::compute_sharpness(mesh, options); + + // Verify results + REQUIRE(results.normal_attr.has_value()); + REQUIRE(results.vertex_sharpness_attr.has_value()); + + // Check vertex sharpness - center vertex should have high valence and be sharp + auto vertex_sharpness = + lagrange::attribute_vector_view(mesh, results.vertex_sharpness_attr.value()); + + // Vertex 0 (center, valence > 2) should be sharp + REQUIRE(vertex_sharpness[0] > sharpness_threshold); + } + + SECTION("vertex sharpness computation - leaf vertices") + { + // Create a simple mesh with boundary edges + lagrange::SurfaceMesh mesh(3); + // clang-format off + mesh.add_vertices(4, { + 0, 0, 0, + 1, 0, 1, + 0.5, 1, 0, + 1.5, 0, 0 + }); + // clang-format on + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 3, 2); + + // Add indexed normals that create seams + lagrange::compute_normal(mesh, lagrange::internal::pi * feature_angle_18_deg); + + // Call compute_sharpness + lagrange::subdivision::SharpnessOptions options; + auto results = lagrange::subdivision::compute_sharpness(mesh, options); + + // Verify results + REQUIRE(results.vertex_sharpness_attr.has_value()); + + // At least some vertices should have sharpness values computed + auto vertex_sharpness = + lagrange::attribute_vector_view(mesh, results.vertex_sharpness_attr.value()); + bool has_sharp = false; + for (Index v = 0; v < mesh.get_num_vertices(); ++v) { + if (vertex_sharpness[v] > sharpness_threshold) { + has_sharp = true; + break; + } + } + REQUIRE(has_sharp); + // The specific configuration depends on the normal seams created + // Just verify the attribute is populated + REQUIRE(vertex_sharpness.size() == mesh.get_num_vertices()); + } +} diff --git a/modules/testing/CMakeLists.txt b/modules/testing/CMakeLists.txt index 0934185c..976942fe 100644 --- a/modules/testing/CMakeLists.txt +++ b/modules/testing/CMakeLists.txt @@ -24,7 +24,9 @@ target_link_libraries(lagrange_testing PUBLIC ) # 3. test-specific properties -lagrange_download_data() +if(NOT TARGET lagrange_download_data) + lagrange_download_data() +endif() add_dependencies(lagrange_testing lagrange_download_data) target_compile_definitions(lagrange_testing diff --git a/modules/texproc/examples/CMakeLists.txt b/modules/texproc/examples/CMakeLists.txt index 39d1612e..d68082a0 100644 --- a/modules/texproc/examples/CMakeLists.txt +++ b/modules/texproc/examples/CMakeLists.txt @@ -10,7 +10,7 @@ # governing permissions and limitations under the License. # -lagrange_include_modules(io image_io) +lagrange_include_modules(io image_io polyscope) lagrange_add_example(geodesic_dilation geodesic_dilation.cpp) target_link_libraries(geodesic_dilation lagrange::texproc CLI11::CLI11 lagrange::io lagrange::image_io) @@ -26,3 +26,6 @@ target_link_libraries(texture_compositing lagrange::texproc CLI11::CLI11 lagrang lagrange_add_example(texture_rasterization texture_rasterization.cpp) target_link_libraries(texture_rasterization lagrange::texproc CLI11::CLI11 lagrange::io lagrange::image_io) + +lagrange_add_example(texture_stitching_gui texture_stitching_gui.cpp) +target_link_libraries(texture_stitching_gui lagrange::texproc CLI11::CLI11 lagrange::io lagrange::image_io lagrange::polyscope) diff --git a/modules/texproc/examples/texture_stitching_gui.cpp b/modules/texproc/examples/texture_stitching_gui.cpp new file mode 100644 index 00000000..7c355e66 --- /dev/null +++ b/modules/texproc/examples/texture_stitching_gui.cpp @@ -0,0 +1,471 @@ +#include "io_helpers.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +// clang-format on + +#include + +using SurfaceMesh = lagrange::SurfaceMesh32d; +using Scalar = SurfaceMesh::Scalar; +using Index = SurfaceMesh::Index; + + +lagrange::AttributeId ensure_texcoord_id(SurfaceMesh& mesh) +{ + using namespace lagrange; + + // Get the texcoord id (and set the texcoords if they weren't already). + AttributeId texcoord_id; + + // Check if the mesh comes with UVs. + if (auto res = lagrange::find_matching_attribute(mesh, AttributeUsage::UV)) { + texcoord_id = res.value(); + } else { + la_runtime_assert(false, "Requires uv coordinates."); + } + + // Make sure the UV coordinate type is the same as that of the vertices. + if (!mesh.template is_attribute_type(texcoord_id)) { + logger().warn( + "Input uv coordinates do not have the same scalar type as the input points. " + "Casting " + "attribute."); + texcoord_id = cast_attribute_in_place(mesh, texcoord_id); + } + + // Make sure the UV coordinates are indexed. + if (mesh.get_attribute_base(texcoord_id).get_element_type() != AttributeElement::Indexed) { + logger().warn("UV coordinates are not indexed. Welding."); + texcoord_id = map_attribute_in_place(mesh, texcoord_id, AttributeElement::Indexed); + weld_indexed_attribute(mesh, texcoord_id); + } + + // Check that the number of corners is equal to (K+1) times the number of simplices. + // Check mesh is triangular. + la_runtime_assert( + mesh.get_num_corners() == mesh.get_num_facets() * (2 + 1), + "Number of corners doesn't match the number of simplices"); + la_runtime_assert(mesh.is_triangle_mesh()); + + return texcoord_id; +} + +struct OrientReturn +{ + std::string texcoord_name; + size_t num_negative_tris; + size_t num_zero_tris; + size_t num_positive_tris; + + bool all_ok() const + { + if (num_negative_tris != 0) return false; + if (num_zero_tris != 0) return false; + return num_positive_tris > 0; + } +}; + +OrientReturn add_uv_orient_attribute(SurfaceMesh& mesh, std::string_view orient_name) +{ + using namespace lagrange; + + // Create uv facet orientation attribute. + const auto orient_id = mesh.create_attribute( + orient_name, + lagrange::AttributeElement::Facet, + 1, + AttributeUsage::Scalar); + auto& orient_attr = mesh.ref_attribute(orient_id); + auto orients = matrix_ref(orient_attr); + la_runtime_assert(orients.rows() == mesh.get_num_facets()); + la_runtime_assert(orients.cols() == 1); + + // Retrieve parametrization. + const auto texcoord_id = ensure_texcoord_id(mesh); + la_runtime_assert(mesh.is_attribute_indexed(texcoord_id)); + const auto& texcoord_attr = mesh.get_indexed_attribute(texcoord_id); + const auto texcoord_indices = reshaped_view(texcoord_attr.indices(), 3); + const auto texcoord_values = matrix_view(texcoord_attr.values()); + lagrange::logger().debug( + "texcoord_indices {}x{}", + texcoord_indices.rows(), + texcoord_indices.cols()); + lagrange::logger().debug( + "texcoord_values {}x{}", + texcoord_values.rows(), + texcoord_values.cols()); + la_runtime_assert(texcoord_indices.cols() == 3); + la_runtime_assert(texcoord_indices.maxCoeff() < static_cast(texcoord_values.rows())); + la_runtime_assert(texcoord_values.cols() == 2); + + // Compute orientations. + ExactPredicatesShewchuk predicates; + std::array orient_to_counts; + orient_to_counts.fill(0); + for (Index f = 0; f < mesh.get_num_facets(); ++f) { + la_debug_assert(texcoord_indices(f, 2) < static_cast(texcoord_values.rows())); + Eigen::RowVector2d p0 = texcoord_values.row(texcoord_indices(f, 0)).template cast(); + Eigen::RowVector2d p1 = texcoord_values.row(texcoord_indices(f, 1)).template cast(); + Eigen::RowVector2d p2 = texcoord_values.row(texcoord_indices(f, 2)).template cast(); + const auto rr = predicates.orient2D(p0.data(), p1.data(), p2.data()); + orients(f) = static_cast(rr); + orient_to_counts[rr + 1] += 1; + } + + // Dump stats. + for (size_t rr = 0; rr < 3; ++rr) { + logger().debug("orient {} count {}", rr - 1, orient_to_counts[rr]); + } + + std::string name{mesh.get_attribute_name(texcoord_id)}; + + return OrientReturn{ + name, + orient_to_counts[0], + orient_to_counts[1], + orient_to_counts[2], + }; +} + +using Array3Df = lagrange::image::experimental::Array3D; +using ConstView3Df = lagrange::image::experimental::View3D; + +auto register_color_texture( + polyscope::SurfaceMesh* ps_mesh, + polyscope::SurfaceCornerParameterizationQuantity* ps_tex, + std::string label, + ConstView3Df texture) -> polyscope::SurfaceTextureColorQuantity* +{ + la_runtime_assert(ps_mesh); + la_runtime_assert(ps_tex); + la_runtime_assert(texture.extent(2) >= 3); + la_runtime_assert(texture.size() > 0); + + std::vector colors; + colors.reserve(texture.extent(0) * texture.extent(1)); + for (const auto jj : lagrange::range(texture.extent(1))) { + for (const auto ii : lagrange::range(texture.extent(0))) { + const auto rr = texture(ii, jj, 0); + const auto gg = texture(ii, jj, 1); + const auto bb = texture(ii, jj, 2); + const auto color = glm::vec3(rr, gg, bb); + colors.emplace_back(color); + } + } + + la_runtime_assert(ps_mesh); + la_runtime_assert(ps_tex); + polyscope::SurfaceTextureColorQuantity* colors_ = ps_mesh->addTextureColorQuantity( + label, + *ps_tex, + texture.extent(1), + texture.extent(0), + colors, + polyscope::ImageOrigin::UpperLeft); + la_runtime_assert(colors_); + colors_->setFilterMode(polyscope::FilterMode::Nearest); + colors_->setEnabled(true); + + return colors_; +} + +struct UiState +{ + OrientReturn orient_ret; + lagrange::texproc::StitchingOptions stitching_options; + SurfaceMesh mesh; + Array3Df input_texture; + Array3Df stitched_texture; + polyscope::SurfaceMesh* ps_mesh; + polyscope::SurfaceCornerParameterizationQuantity* ps_tex; + + bool can_run_texture_stitching() const; + void main_panel(); +}; + +bool UiState::can_run_texture_stitching() const +{ + bool can_stitch = true; + can_stitch &= mesh.get_num_facets() > 0; + can_stitch &= orient_ret.all_ok(); + can_stitch &= input_texture.size() > 0; + can_stitch &= static_cast(ps_mesh); + can_stitch &= static_cast(ps_tex); + return can_stitch; +} + +template +void ImGui_FmtText(fmt::format_string text, Args&&... args) +{ + ImGui::Text("%s", fmt::format(text, std::forward(args)...).c_str()); +} + +void UiState::main_panel() +{ + // mesh stat + ImGui_FmtText("mesh: {}v {}f", mesh.get_num_vertices(), mesh.get_num_facets()); + + // uv face orientations + ImGui_FmtText("parametrization: {}", orient_ret.texcoord_name); + ImGui_FmtText( + "uv orientation: #neg:{}, #zero:{}, #pos:{}", + orient_ret.num_negative_tris, + orient_ret.num_zero_tris, + orient_ret.num_positive_tris); + + // texture stat + ImGui_FmtText( + "texture: {}x{}x{}", + input_texture.extent(0), + input_texture.extent(1), + input_texture.extent(2)); + + ImGui::Separator(); + + const auto can_stitch = can_run_texture_stitching(); + ImGui_FmtText("texture stitching inputs: {}", can_stitch ? "valid" : "invalid / missing"); + + // trigger stitching + if (ImGui::Button("run texture stitching") && can_stitch) { + lagrange::logger().info("Running texture stitching"); + auto result_texture = input_texture; + lagrange::texproc::texture_stitching(mesh, result_texture.to_mdspan(), stitching_options); + stitched_texture = result_texture; + register_color_texture(ps_mesh, ps_tex, "stitched", result_texture.to_mdspan()); + lagrange::logger().warn("Done"); + } + + // stitching options + if (ImGui::TreeNode("texture stitching options")) { + auto& options = stitching_options; + + { + const std::array labels = { + "1", + "3", + "6 (default)", + "12", + "24", + }; + const std::array values = { + 1, + 3, + 6, + 12, + 24, + }; + la_runtime_assert(labels.size() == values.size()); + auto index = 0; + for (const auto& value : values) { + if (value == options.quadrature_samples) break; + index += 1; + } + la_runtime_assert(static_cast(index) < values.size()); + ImGui::Combo("quadrature", &index, labels.data(), static_cast(labels.size())); + options.quadrature_samples = values.at(index); + } + + { + options.stiffness_regularization_weight = + std::max(options.stiffness_regularization_weight, 1e-9); + la_runtime_assert(options.stiffness_regularization_weight > 0); + float value_log = std::log(options.stiffness_regularization_weight) / std::log(10.0f); + ImGui::SliderFloat("log stiffness regularization", &value_log, -9.0f, 0.0f); + options.stiffness_regularization_weight = std::pow(10.0f, value_log); + } + + ImGui::Checkbox("exterior only", &options.exterior_only); + + { + bool has_jitter = options.jitter_epsilon > 0; + ImGui::Checkbox("jitter", &has_jitter); + options.jitter_epsilon = has_jitter ? 1e-4 : 0.0; + } + + { + bool is_clamped = options.clamp_to_range.has_value(); + ImGui::Checkbox("clamp", &is_clamped); + options.clamp_to_range = + is_clamped ? std::make_optional(std::pair{0.0, 1.0}) : std::nullopt; + } + + ImGui::TreePop(); + } +} + +int main(int argc, char** argv) +{ + struct + { + lagrange::fs::path mesh_path; + lagrange::fs::path texture_path; + std::string orient_name = "texcoord_orient"; + int log_level = 2; // normal + } args; + + CLI::App app{argv[0]}; + app.option_defaults()->always_capture_default(); + app.add_option("mesh", args.mesh_path, "Input mesh.")->required()->check(CLI::ExistingFile); + app.add_option("texture", args.texture_path, "Input texture.")->check(CLI::ExistingFile); + app.add_option("--orient-name", args.orient_name, "UV orientation attribute."); + app.add_option("-l,--level", args.log_level, "Log level (0 = most verbose, 6 = off)."); + CLI11_PARSE(app, argc, argv) + + spdlog::set_level(static_cast(args.log_level)); + + polyscope::options::configureImGuiStyleCallback = []() { + ImGui::Spectrum::StyleColorsSpectrum(); + ImGui::Spectrum::LoadFont(); + }; + polyscope::init(); + + UiState ui_state; + polyscope::state::userCallback = [&]() { ui_state.main_panel(); }; + + const auto& mesh_path = args.mesh_path; + lagrange::logger().info("Loading input mesh: {}", mesh_path.string()); + lagrange::io::LoadOptions options; + options.stitch_vertices = true; + auto mesh = lagrange::io::load_mesh<::SurfaceMesh>(mesh_path, options); + + lagrange::triangulate_polygonal_facets(mesh); + ui_state.orient_ret = add_uv_orient_attribute(mesh, args.orient_name); + + // list mesh attributes imported by polyscope module + if (lagrange::logger().should_log(spdlog::level::debug)) + seq_foreach_named_attribute_read(mesh, [&](auto name, auto&& attr) { + using AttributeType = std::decay_t; + const bool is_indexed = AttributeType::IsIndexed; + const bool is_reserved = mesh.attr_name_is_reserved(name); + const bool all_ok = !is_reserved && !is_indexed; + lagrange::logger().debug( + "attr \"{}\" {}{}{}", + name, + is_reserved ? "reserved " : "", + is_indexed ? "indexed " : "", + all_ok ? "IMPORT" : "SKIP"); + }); + + lagrange::logger().debug( + "mesh {}v {}f {}c", + mesh.get_num_vertices(), + mesh.get_num_facets(), + mesh.get_num_corners()); + la_runtime_assert(mesh.is_triangle_mesh()); + la_runtime_assert(mesh.get_num_facets() * 3 == mesh.get_num_corners()); + ui_state.mesh = mesh; + + const auto name = mesh_path.stem().string(); + polyscope::SurfaceMesh* ps_mesh = lagrange::polyscope::register_mesh(name, mesh); + la_runtime_assert(ps_mesh); + + ps_mesh->setBackFacePolicy(::polyscope::BackFacePolicy::Custom); + ps_mesh->setBackFaceColor(glm::vec3(1.0, 0.0, 0.0)); + ps_mesh->setSmoothShade(true); + + polyscope::SurfaceCornerParameterizationQuantity* ps_tex = nullptr; + { + // register texcoord parametrization + la_runtime_assert(mesh.is_triangle_mesh()); + auto texcoord_id = ensure_texcoord_id(mesh); + lagrange::logger().info( + "Registering corner parametrization: {}", + mesh.get_attribute_name(texcoord_id)); + la_runtime_assert(mesh.is_attribute_indexed(texcoord_id)); + + const auto& texcoord_attr = mesh.get_indexed_attribute(texcoord_id); + const auto texcoord_indices = reshaped_view(texcoord_attr.indices(), 3); + const auto texcoord_values = matrix_view(texcoord_attr.values()); + lagrange::logger().debug( + "texcoord_indices {}x{}", + texcoord_indices.rows(), + texcoord_indices.cols()); + lagrange::logger().debug( + "texcoord_values {}x{}", + texcoord_values.rows(), + texcoord_values.cols()); + la_runtime_assert(texcoord_indices.cols() == 3); + la_runtime_assert(texcoord_indices.maxCoeff() < static_cast(texcoord_values.rows())); + la_runtime_assert(texcoord_values.cols() == 2); + + la_runtime_assert(ps_mesh); + la_runtime_assert(mesh.get_num_vertices() == static_cast(ps_mesh->nVertices())); + la_runtime_assert(mesh.get_num_facets() == static_cast(ps_mesh->nFaces())); + la_runtime_assert(mesh.get_num_corners() == static_cast(ps_mesh->nCorners())); + + std::vector uv_values_; + la_runtime_assert(ps_mesh->nCorners() == ps_mesh->nFaces() * 3); + la_runtime_assert(static_cast(texcoord_indices.size()) == ps_mesh->nCorners()); + uv_values_.reserve(ps_mesh->nCorners()); + for (const auto cc : lagrange::range(ps_mesh->nCorners())) { + const auto index = texcoord_indices(cc / 3, cc % 3); + la_runtime_assert(index < texcoord_values.rows()); + const auto uu = texcoord_values(index, 0); + const auto vv = texcoord_values(index, 1); + const auto uv = glm::vec2(uu, vv); + uv_values_.emplace_back(uv); + } + + ps_tex = ps_mesh->addParameterizationQuantity("texcoord", uv_values_); + la_runtime_assert(ps_tex); + ps_tex->setEnabled(true); + } + + polyscope::CurveNetwork* ps_seam_network; + { + // texproc parametrization seams + la_runtime_assert(ps_tex); + ps_seam_network = ps_tex->createCurveNetworkFromSeams("texcoord_seams"); + la_runtime_assert(ps_seam_network); + ps_seam_network->setEnabled(true); + } + + polyscope::SurfaceTextureColorQuantity* ps_input_colors = nullptr; + if (lagrange::fs::exists(args.texture_path)) { + lagrange::logger().info("Loading input texture: {}", args.texture_path.string()); + const auto texture = load_image(args.texture_path); + + lagrange::logger() + .debug("texture {}x{}x{}", texture.extent(0), texture.extent(1), texture.extent(2)); + la_runtime_assert(texture.extent(2) >= 3); + la_runtime_assert(texture.size() > 0); + ui_state.input_texture = texture; + ps_input_colors = register_color_texture(ps_mesh, ps_tex, "input", texture.to_mdspan()); + } + (void)ps_input_colors; + + la_runtime_assert(ps_mesh); + la_runtime_assert(ps_tex); + la_runtime_assert(ps_seam_network); + ui_state.ps_mesh = ps_mesh; + ui_state.ps_tex = ps_tex; + + lagrange::logger().info("Starting main loop"); + polyscope::show(); + + return 0; +} diff --git a/modules/ui/examples/CMakeLists.txt b/modules/ui/examples/CMakeLists.txt index 287340dc..ac7217cb 100644 --- a/modules/ui/examples/CMakeLists.txt +++ b/modules/ui/examples/CMakeLists.txt @@ -22,7 +22,6 @@ if(NOT TARGET lagrange_download_data) lagrange_download_data() endif() - function(lagrange_ui_add_example name) lagrange_add_example(${name} WITH_UI ${name}.cpp) target_link_libraries(${name} lagrange::ui CLI11::CLI11) diff --git a/modules/volume/CMakeLists.txt b/modules/volume/CMakeLists.txt index 48c42c3b..b0abe3a8 100644 --- a/modules/volume/CMakeLists.txt +++ b/modules/volume/CMakeLists.txt @@ -29,3 +29,7 @@ endif() if(LAGRANGE_EXAMPLES) add_subdirectory(examples) endif() + +if(LAGRANGE_MODULE_PYTHON) + add_subdirectory(python) +endif() diff --git a/modules/volume/examples/CMakeLists.txt b/modules/volume/examples/CMakeLists.txt index 09ce5d69..eda4f235 100644 --- a/modules/volume/examples/CMakeLists.txt +++ b/modules/volume/examples/CMakeLists.txt @@ -10,10 +10,35 @@ # governing permissions and limitations under the License. # include(cli11) -lagrange_include_modules(io) +lagrange_include_modules(io polyscope bvh) -lagrange_add_example(voxelize_mesh voxelize_mesh.cpp) -target_link_libraries(voxelize_mesh lagrange::volume lagrange::io CLI11::CLI11) +lagrange_add_example(voxelize_cli voxelize_cli.cpp) +target_link_libraries(voxelize_cli + lagrange::volume + lagrange::io + lagrange::bvh + CLI11::CLI11 +) +if(TARGET OpenVDB::nanovdb) + target_link_libraries(voxelize_cli OpenVDB::nanovdb) + target_compile_definitions(voxelize_cli PRIVATE NANOVDB_ENABLED) +endif() + +lagrange_add_example(voxelize_gui voxelize_gui.cpp) +target_link_libraries(voxelize_gui + lagrange::volume + lagrange::polyscope + lagrange::bvh + lagrange::io + CLI11::CLI11 +) lagrange_add_example(fill_with_spheres fill_with_spheres.cpp) target_link_libraries(fill_with_spheres lagrange::volume lagrange::io CLI11::CLI11) + +lagrange_add_example(grid_viewer grid_viewer.cpp) +target_link_libraries(grid_viewer lagrange::volume lagrange::io CLI11::CLI11 polyscope::polyscope) +if(TARGET OpenVDB::nanovdb) + target_link_libraries(grid_viewer OpenVDB::nanovdb) + target_compile_definitions(grid_viewer PRIVATE NANOVDB_ENABLED) +endif() diff --git a/modules/volume/examples/grid_viewer.cpp b/modules/volume/examples/grid_viewer.cpp new file mode 100644 index 00000000..46ef203a --- /dev/null +++ b/modules/volume/examples/grid_viewer.cpp @@ -0,0 +1,87 @@ +/* + * Copyright 2021 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include "register_grid.h" + +#include +#include +#include + +#ifdef NANOVDB_ENABLED +// clang-format off +#include +#include +#include +#include +// clang-format on +#endif + +#include + +namespace fs = lagrange::fs; + +using ConstFloatGridPtr = typename lagrange::volume::Grid::ConstPtr; + +int main(int argc, char** argv) +{ + struct + { + std::vector inputs; + } args; + + CLI::App app{argv[0]}; + app.option_defaults()->always_capture_default(); + app.add_option("input", args.inputs, "Input grids.")->required()->check(CLI::ExistingFile); + CLI11_PARSE(app, argc, argv) + + spdlog::set_level(spdlog::level::debug); + + // TODO: Handle both float and double + auto load_grid = [&](fs::path input) -> ConstFloatGridPtr { + if (input.extension() == ".vdb") { + openvdb::initialize(); + openvdb::io::File file(input.string()); + file.open(); + auto grids_ptr = file.getGrids(); + file.close(); + la_runtime_assert( + grids_ptr && grids_ptr->size() == 1, + "Input vdb must contain exactly one grid."); + return openvdb::gridPtrCast((*grids_ptr)[0]); + } else if (input.extension() == ".nvdb") { +#ifdef NANOVDB_ENABLED + auto handle = nanovdb::io::readGrid(input.string()); + auto grid_ptr = nanovdb::tools::nanoToOpenVDB(handle); + return openvdb::gridPtrCast(grid_ptr); +#else + throw lagrange::Error("NanoVDB support is not enabled in this build."); +#endif + } else { + throw lagrange::Error("Unsupported grid extension: " + input.string()); + } + }; + + polyscope::options::configureImGuiStyleCallback = []() { + ImGui::Spectrum::StyleColorsSpectrum(); + ImGui::Spectrum::LoadFont(); + }; + polyscope::init(); + + for (const auto& input : args.inputs) { + auto grid = load_grid(input); + register_grid(input.stem().string(), *grid); + } + + polyscope::show(); + + return 0; +} diff --git a/modules/volume/examples/register_grid.h b/modules/volume/examples/register_grid.h new file mode 100644 index 00000000..50a442c6 --- /dev/null +++ b/modules/volume/examples/register_grid.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// clang-format on + +using FloatGrid = lagrange::volume::Grid; + +void register_grid(std::string_view name, const FloatGrid& grid) +{ + auto bbox_index = grid.evalActiveVoxelBoundingBox(); + la_runtime_assert(!bbox_index.empty(), "Grid has no active voxels."); + auto bbox_world = grid.transform().indexToWorld(bbox_index); + + uint32_t dimX = bbox_index.dim().x(); + uint32_t dimY = bbox_index.dim().y(); + uint32_t dimZ = bbox_index.dim().z(); + glm::vec3 bbox_min( + static_cast(bbox_world.min().x()), + static_cast(bbox_world.min().y()), + static_cast(bbox_world.min().z())); + glm::vec3 bbox_max( + static_cast(bbox_world.max().x()), + static_cast(bbox_world.max().y()), + static_cast(bbox_world.max().z())); + + // register the grid + polyscope::VolumeGrid* ps_grid = + polyscope::registerVolumeGrid(std::string(name), {dimX, dimY, dimZ}, bbox_min, bbox_max); + + // add a scalar function on the grid + uint32_t num_voxels = dimX * dimY * dimZ; + std::vector values(num_voxels, 0.f); + auto o = bbox_index.min(); + struct Data + { + FloatGrid::ConstAccessor accessor; + }; + tbb::enumerable_thread_specific data([&]() { return Data{grid.getConstAccessor()}; }); + using Range3d = tbb::blocked_range3d; + const Range3d voxel_range(0, dimZ, 0, dimY, 0, dimX); + tbb::parallel_for(voxel_range, [&](const Range3d& range) { + auto rz = range.pages(); + auto ry = range.rows(); + auto rx = range.cols(); + auto& accessor = data.local().accessor; + for (int32_t k = rz.begin(); k != rz.end(); ++k) { + for (int32_t j = ry.begin(); j != ry.end(); ++j) { + for (int32_t i = rx.begin(); i != rx.end(); ++i) { + openvdb::Vec3R ijk_is(i + o.x(), j + o.y(), k + o.z()); + float value = openvdb::tools::BoxSampler::sample(accessor, ijk_is); + values[k * dimY * dimX + j * dimX + i] = value; + } + } + } + }); + lagrange::logger().info( + "Registered {} voxels. Min corner: {}, {}, {}", + num_voxels, + bbox_min.x, + bbox_min.y, + bbox_min.z); + + polyscope::VolumeGridNodeScalarQuantity* ps_scalars = + ps_grid->addNodeScalarQuantity("values", std::make_tuple(values.data(), num_voxels)); + ps_scalars->setEnabled(true); +} diff --git a/modules/volume/examples/voxelize_cli.cpp b/modules/volume/examples/voxelize_cli.cpp new file mode 100644 index 00000000..6ae2a558 --- /dev/null +++ b/modules/volume/examples/voxelize_cli.cpp @@ -0,0 +1,149 @@ +/* + * Copyright 2021 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include +#include +#include +#include +#include +#include +#include + +#ifdef NANOVDB_ENABLED +// clang-format off +#include +#include +#include +#include +// clang-format on +#endif + +#include + +namespace fs = lagrange::fs; + +using FloatGrid = lagrange::volume::Grid; + +const std::map& signing_types() +{ + static std::map _methods = { + {"FloodFill", lagrange::volume::MeshToVolumeOptions::Sign::FloodFill}, + {"WindingNumber", lagrange::volume::MeshToVolumeOptions::Sign::WindingNumber}, + {"Unsigned", lagrange::volume::MeshToVolumeOptions::Sign::Unsigned}, + }; + return _methods; +} + +int main(int argc, char** argv) +{ + struct + { + fs::path input; + fs::path output = "output.ply"; + std::optional offset_radius; + } args; + lagrange::volume::MeshToVolumeOptions m2v_opt; + lagrange::volume::VolumeToMeshOptions v2m_opt; + + CLI::App app{argv[0]}; + app.option_defaults()->always_capture_default(); + app.add_option("input", args.input, "Input mesh or vdb.")->required()->check(CLI::ExistingFile); + app.add_option("output", args.output, "Output mesh, vdb or nvdb."); + app.add_option( + "-s,--voxel-size", + m2v_opt.voxel_size, + "Voxel size. Negative means relative to bbox diagonal."); + app.add_option("-m,--method", m2v_opt.signing_method, "Grid signing method.") + ->transform(CLI::Transformer(signing_types(), CLI::ignore_case)); + app.add_option("-o,--offset", args.offset_radius, "Level-set offset radius."); + app.add_option("-v,--isovalue", v2m_opt.isovalue, "Isovalue to mesh."); + app.add_option("-a,--adaptivity", v2m_opt.adaptivity, "Mesh adaptivity between [0, 1]."); + CLI11_PARSE(app, argc, argv) + + spdlog::set_level(spdlog::level::debug); + + auto grid = [&]() -> FloatGrid::Ptr { + const auto& path = args.input; + if (path.extension() == ".vdb") { + lagrange::logger().info("Loading volume from OpenVDB grid: {}", path.string()); + openvdb::initialize(); + openvdb::io::File file(path.string()); + file.open(); + auto grids_ptr = file.getGrids(); + file.close(); + // Filter level set grids + la_runtime_assert(grids_ptr); + openvdb::GridPtrVecPtr grids_ptr_ = std::make_unique(); + for (auto grid_ptr : *grids_ptr) { + if (grid_ptr->getGridClass() == openvdb::GridClass::GRID_LEVEL_SET) + grids_ptr_->emplace_back(grid_ptr); + } + // There should be a single level set grid + la_runtime_assert(grids_ptr_->size() == 1, "Could not find a single level set grid."); + auto grid_ptr = (*grids_ptr)[0]; + const std::string& name = grid_ptr->getName(); + lagrange::logger().info("Using grid: {}", name); + return openvdb::gridPtrCast(grid_ptr); + } else { + lagrange::logger().info("Loading input mesh: {}", path.string()); + auto mesh = lagrange::io::load_mesh(path); + lagrange::logger().info("Mesh to volume conversion"); + auto raw_grid = lagrange::volume::mesh_to_volume(mesh, m2v_opt); + if (m2v_opt.signing_method == lagrange::volume::MeshToVolumeOptions::Sign::Unsigned) { + double isovalue = raw_grid->voxelSize().x() * std::sqrt(3.0); + lagrange::volume::VolumeToMeshOptions opt; + opt.isovalue = v2m_opt.isovalue + isovalue; + lagrange::logger().info( + "Extracting offset mesh at isovalue {} to ensure watertightness", + isovalue); + auto offset_mesh = + lagrange::volume::volume_to_mesh(*raw_grid, opt); + lagrange::triangulate_polygonal_facets(offset_mesh); + lagrange::logger().info("Removing interior shells"); + offset_mesh = lagrange::bvh::remove_interior_shells(offset_mesh); + lagrange::logger().info("Re-voxelizing offset mesh"); + m2v_opt.signing_method = lagrange::volume::MeshToVolumeOptions::Sign::FloodFill; + return lagrange::volume::mesh_to_volume(offset_mesh, m2v_opt); + } else if (args.offset_radius.has_value()) { + double offset_radius = args.offset_radius.value(); + lagrange::logger().info("Applying level-set offset of {}", offset_radius); + lagrange::volume::internal::offset_grid_in_place(*raw_grid, offset_radius, false); + return raw_grid; + } else { + return raw_grid; + } + } + }(); + + if (args.output.extension() == ".vdb") { + lagrange::logger().info("Saving volume to OpenVDB grid: {}", args.output.string()); + openvdb::io::File file(args.output.string()); + file.setCompression(openvdb::io::COMPRESS_BLOSC); + file.write({grid}); + file.close(); + } else if (args.output.extension() == ".nvdb") { +#ifdef NANOVDB_ENABLED + lagrange::logger().info("Saving volume to NanoVDB grid: {}", args.output.string()); + auto handle = nanovdb::tools::createNanoGrid(*grid); + nanovdb::io::writeGrid(args.output.string(), handle, nanovdb::io::Codec::BLOSC); +#else + lagrange::logger().error("NanoVDB support is not enabled in this build."); + return 1; +#endif + } else { + lagrange::logger().info("Volume to mesh conversion"); + auto mesh = lagrange::volume::volume_to_mesh(*grid, v2m_opt); + lagrange::logger().info("Saving mesh: {}", args.output.string()); + lagrange::io::save_mesh(args.output, mesh); + } + + return 0; +} diff --git a/modules/volume/examples/voxelize_gui.cpp b/modules/volume/examples/voxelize_gui.cpp new file mode 100644 index 00000000..47a04bc6 --- /dev/null +++ b/modules/volume/examples/voxelize_gui.cpp @@ -0,0 +1,161 @@ +/* + * Copyright 2021 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include "register_grid.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +// clang-format on + +#include + +namespace fs = lagrange::fs; + +const std::map& signing_types() +{ + static std::map _methods = { + {"FloodFill", lagrange::volume::MeshToVolumeOptions::Sign::FloodFill}, + {"WindingNumber", lagrange::volume::MeshToVolumeOptions::Sign::WindingNumber}, + {"Unsigned", lagrange::volume::MeshToVolumeOptions::Sign::Unsigned}, + }; + return _methods; +} + +int main(int argc, char** argv) +{ + struct + { + fs::path input; + bool redistance = false; + std::optional offset_radius; + std::optional dense_voxel_size; + } args; + lagrange::volume::MeshToVolumeOptions m2v_opt; + + CLI::App app{argv[0]}; + app.option_defaults()->always_capture_default(); + app.add_option("input", args.input, "Input mesh or vdb.")->required()->check(CLI::ExistingFile); + app.add_option( + "-s,--voxel-size", + m2v_opt.voxel_size, + "Voxel size. Negative means relative to bbox diagonal."); + app.add_option("-m,--method", m2v_opt.signing_method, "Grid signing method.") + ->transform(CLI::Transformer(signing_types(), CLI::ignore_case)); + app.add_option("-o,--offset", args.offset_radius, "Level-set offset radius."); + auto re_flag = app.add_flag( + "-r,--redistance", + args.redistance, + "Show redistanced version of the input SDF."); + app.add_option( + "-d,--dense-voxel-size", + args.dense_voxel_size, + "If set, resample the grid to a dense SDF with the given voxel size (negative means " + "relative to input voxel size).") + ->needs(re_flag); + CLI11_PARSE(app, argc, argv) + + spdlog::set_level(spdlog::level::debug); + + auto grid = [&]() -> FloatGrid::Ptr { + const auto& path = args.input; + if (path.extension() == ".vdb") { + lagrange::logger().info("Loading volume from OpenVDB grid: {}", path.string()); + openvdb::initialize(); + openvdb::io::File file(path.string()); + file.open(); + auto grids_ptr = file.getGrids(); + file.close(); + // Filter level set grids + la_runtime_assert(grids_ptr); + openvdb::GridPtrVecPtr grids_ptr_ = std::make_unique(); + for (auto grid_ptr : *grids_ptr) { + if (grid_ptr->getGridClass() == openvdb::GridClass::GRID_LEVEL_SET) + grids_ptr_->emplace_back(grid_ptr); + } + // There should be a single level set grid + la_runtime_assert(grids_ptr_->size() == 1, "Could not find a single level set grid."); + auto grid_ptr = (*grids_ptr)[0]; + const std::string& name = grid_ptr->getName(); + lagrange::logger().info("Using grid: {}", name); + return openvdb::gridPtrCast(grid_ptr); + } else { + lagrange::logger().info("Loading input mesh: {}", path.string()); + auto mesh = lagrange::io::load_mesh(path); + lagrange::triangulate_polygonal_facets(mesh); + lagrange::logger().info("Mesh to volume conversion"); + auto raw_grid = lagrange::volume::mesh_to_volume(mesh, m2v_opt); + if (m2v_opt.signing_method == lagrange::volume::MeshToVolumeOptions::Sign::Unsigned) { + double isovalue = raw_grid->voxelSize().x() * std::sqrt(3.0); + lagrange::volume::VolumeToMeshOptions v2m_opt; + v2m_opt.isovalue = isovalue; + lagrange::logger().info( + "Extracting offset mesh at isovalue {} to ensure watertightness", + isovalue); + auto offset_mesh = + lagrange::volume::volume_to_mesh(*raw_grid, v2m_opt); + lagrange::triangulate_polygonal_facets(offset_mesh); + lagrange::logger().info("Removing interior shells"); + offset_mesh = lagrange::bvh::remove_interior_shells(offset_mesh); + lagrange::logger().info("Re-voxelizing offset mesh"); + m2v_opt.signing_method = lagrange::volume::MeshToVolumeOptions::Sign::FloodFill; + return lagrange::volume::mesh_to_volume(offset_mesh, m2v_opt); + } else if (args.offset_radius.has_value()) { + double offset_radius = args.offset_radius.value(); + lagrange::logger().info("Applying level-set offset of {}", offset_radius); + lagrange::volume::internal::offset_grid_in_place(*raw_grid, offset_radius, false); + return raw_grid; + } else { + return raw_grid; + } + } + }(); + + polyscope::options::configureImGuiStyleCallback = []() { + ImGui::Spectrum::StyleColorsSpectrum(); + ImGui::Spectrum::LoadFont(); + }; + polyscope::init(); + + register_grid("input_grid", *grid); + auto extracted_mesh = lagrange::volume::volume_to_mesh(*grid); + lagrange::polyscope::register_mesh("input_mesh", extracted_mesh); + + if (args.redistance) { + lagrange::logger().info("Redistancing the SDF"); + if (args.dense_voxel_size.has_value()) { + lagrange::logger().info( + "Resampling to dense SDF with voxel size {}", + *args.dense_voxel_size); + grid = lagrange::volume::internal::resample_grid(*grid, *args.dense_voxel_size); + } + grid = lagrange::volume::internal::densify_grid(*grid); + grid = lagrange::volume::internal::redistance_grid(*grid); + register_grid("redistanced_grid", *grid); + auto sdf_mesh = lagrange::volume::volume_to_mesh(*grid); + lagrange::polyscope::register_mesh("redistanced_mesh", sdf_mesh); + } + + polyscope::show(); + + return 0; +} diff --git a/modules/volume/examples/voxelize_mesh.cpp b/modules/volume/examples/voxelize_mesh.cpp deleted file mode 100644 index ffbfc207..00000000 --- a/modules/volume/examples/voxelize_mesh.cpp +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2021 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ -#include -#include -#include -#include - -#include - -namespace fs = lagrange::fs; - -using FloatGrid = lagrange::volume::Grid; -using FloatGridPtr = typename lagrange::volume::Grid::Ptr; - -const std::map& signing_types() -{ - static std::map _methods = { - {"FloodFill", lagrange::volume::MeshToVolumeOptions::Sign::FloodFill}, - {"WindingNumber", lagrange::volume::MeshToVolumeOptions::Sign::WindingNumber}, - }; - return _methods; -} - -int main(int argc, char** argv) -{ - struct - { - fs::path input; - fs::path output = "output.obj"; - } args; - - lagrange::volume::MeshToVolumeOptions m2v_opt; - lagrange::volume::VolumeToMeshOptions v2m_opt; - - CLI::App app{argv[0]}; - app.option_defaults()->always_capture_default(); - app.add_option("input", args.input, "Input mesh.")->required()->check(CLI::ExistingFile); - app.add_option("output", args.output, "Output mesh."); - app.add_option( - "-s,--voxel-size", - m2v_opt.voxel_size, - "Voxel size. Negative means relative to bbox diagonal."); - app.add_option("-m,--method", m2v_opt.signing_method, "Grid signing method.") - ->transform(CLI::Transformer(signing_types(), CLI::ignore_case)); - app.add_option("-v,--isovalue", v2m_opt.isovalue, "Isovalue to mesh."); - app.add_option("-a,--adaptivity", v2m_opt.adaptivity, "Mesh adaptivity between [0, 1]."); - CLI11_PARSE(app, argc, argv) - - spdlog::set_level(spdlog::level::debug); - - auto grid = [&]() -> FloatGridPtr { - if (args.input.extension() == ".vdb") { - openvdb::initialize(); - openvdb::io::File file(args.input.string()); - file.open(); - auto grids_ptr = file.getGrids(); - file.close(); - la_runtime_assert( - grids_ptr && grids_ptr->size() == 1, - "Input vdb must contain exactly one grid."); - return openvdb::gridPtrCast((*grids_ptr)[0]); - } else { - lagrange::logger().info("Loading input mesh: {}", args.input.string()); - auto mesh = lagrange::io::load_mesh(args.input); - - lagrange::logger().info("Mesh to volume conversion"); - return lagrange::volume::mesh_to_volume(mesh, m2v_opt); - } - }(); - - if (args.output.extension() == ".vdb") { - lagrange::logger().info("Saving volume to: {}", args.output.string()); - openvdb::io::File file(args.output.string()); - file.setCompression(openvdb::io::COMPRESS_BLOSC); - file.write({grid}); - file.close(); - } else { - lagrange::logger().info("Volume to mesh conversion"); - auto mesh = lagrange::volume::volume_to_mesh(*grid, v2m_opt); - - lagrange::logger().info("Saving result: {}", args.output.string()); - lagrange::io::save_mesh(args.output, mesh); - } - - return 0; -} diff --git a/modules/volume/include/lagrange/volume/fill_with_spheres.h b/modules/volume/include/lagrange/volume/fill_with_spheres.h index 559bcc37..c8794448 100644 --- a/modules/volume/include/lagrange/volume/fill_with_spheres.h +++ b/modules/volume/include/lagrange/volume/fill_with_spheres.h @@ -13,8 +13,12 @@ #include +// clang-format off +#include #include #include +#include +// clang-format on #include diff --git a/modules/volume/include/lagrange/volume/internal/utils.h b/modules/volume/include/lagrange/volume/internal/utils.h new file mode 100644 index 00000000..2b61cd87 --- /dev/null +++ b/modules/volume/include/lagrange/volume/internal/utils.h @@ -0,0 +1,71 @@ +#pragma once + +#include + +// clang-format off +#include +#include +#include +#include +#include +#include +// clang-format on + +namespace lagrange::volume::internal { + +template +typename Grid::Ptr resample_grid(const Grid& grid, double voxel_size) +{ + if (voxel_size <= 0.0) { + auto vs = grid.voxelSize(); + voxel_size = (vs.x() + vs.y() + vs.z()) / 3.0 * (-voxel_size); + } + logger().info("Resampling grid to voxel size {}", voxel_size); + auto dest = Grid::create(); + + const openvdb::Vec3d offset(voxel_size / 2.0, voxel_size / 2.0, voxel_size / 2.0); + auto transform = openvdb::math::Transform::createLinearTransform(voxel_size); + transform->postTranslate(offset); + + dest->setTransform(transform); + openvdb::tools::resampleToMatch(grid, *dest); + + return dest; +} + +template +typename Grid::Ptr densify_grid(const Grid& grid) +{ + openvdb::tools::Dense dense(grid.evalActiveVoxelBoundingBox()); + openvdb::tools::copyToDense(grid, dense); + auto tmp = grid.deepCopy(); + openvdb::tools::copyFromDense(dense, *tmp, -1); + return tmp; +} + +template +typename Grid::Ptr redistance_grid(const Grid& grid) +{ + return openvdb::tools::sdfToSdf(grid); +} + +template +void offset_grid_in_place(Grid& grid, double offset_radius, bool relative) +{ + if (grid.getGridClass() != openvdb::GRID_LEVEL_SET) { + logger().warn("Offset can only be applied to signed distance fields."); + } else { + if (relative) { + auto vs = grid.voxelSize(); + double voxel_size = (vs.x() + vs.y() + vs.z()) / 3.0; + offset_radius = offset_radius * voxel_size; + } + openvdb::tools::LevelSetFilter filter(grid); + filter.offset(offset_radius); + logger().trace("Number of active voxels after offset: {}", grid.activeVoxelCount()); + filter.prune(); + logger().trace("Number of active voxels after pruning: {}", grid.activeVoxelCount()); + } +} + +} // namespace lagrange::volume::internal diff --git a/modules/volume/include/lagrange/volume/legacy/mesh_to_volume.h b/modules/volume/include/lagrange/volume/legacy/mesh_to_volume.h index 74382cf2..2a58821b 100644 --- a/modules/volume/include/lagrange/volume/legacy/mesh_to_volume.h +++ b/modules/volume/include/lagrange/volume/legacy/mesh_to_volume.h @@ -15,8 +15,13 @@ #include #include +// clang-format off +#include #include #include +#include +// clang-format on + #include namespace lagrange { diff --git a/modules/volume/include/lagrange/volume/legacy/volume_to_mesh.h b/modules/volume/include/lagrange/volume/legacy/volume_to_mesh.h index df60487c..e6f4be4b 100644 --- a/modules/volume/include/lagrange/volume/legacy/volume_to_mesh.h +++ b/modules/volume/include/lagrange/volume/legacy/volume_to_mesh.h @@ -14,8 +14,12 @@ #include #include +// clang-format off +#include #include #include +#include +// clang-format on namespace lagrange { namespace volume { diff --git a/modules/volume/include/lagrange/volume/mesh_to_volume.h b/modules/volume/include/lagrange/volume/mesh_to_volume.h index ac172240..2262eff7 100644 --- a/modules/volume/include/lagrange/volume/mesh_to_volume.h +++ b/modules/volume/include/lagrange/volume/mesh_to_volume.h @@ -32,6 +32,7 @@ struct MeshToVolumeOptions enum class Sign { FloodFill, ///< Default voxel flood-fill method used by OpenVDB. WindingNumber, ///< Fast winding number approach based on [Barill et al. 2018]. + Unsigned, ///< Do not compute sign, output unsigned distance field. }; /// Grid voxel size. If the target voxel size is too small, an exception will will be raised. A diff --git a/modules/volume/include/lagrange/volume/types.h b/modules/volume/include/lagrange/volume/types.h index 8486441d..d434cfa6 100644 --- a/modules/volume/include/lagrange/volume/types.h +++ b/modules/volume/include/lagrange/volume/types.h @@ -11,7 +11,11 @@ */ #pragma once +// clang-format off +#include #include +#include +// clang-format on namespace lagrange::volume { diff --git a/modules/volume/python/CMakeLists.txt b/modules/volume/python/CMakeLists.txt new file mode 100644 index 00000000..b2055c4a --- /dev/null +++ b/modules/volume/python/CMakeLists.txt @@ -0,0 +1,31 @@ +# +# 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. +# +lagrange_add_python_binding() + +target_link_libraries(lagrange_python PRIVATE OpenVDB::nanovdb) + +if(SKBUILD) + foreach(vdb_target IN ITEMS OpenVDB::openvdb OpenVDB::nanovdb) + get_target_property(_aliased ${vdb_target} ALIASED_TARGET) + + if(_aliased) + set(vdb_target ${_aliased}) + else() + set(vdb_target ${vdb_target}) + endif() + + install(TARGETS ${vdb_target} + DESTINATION ${SKBUILD_PLATLIB_DIR}/lagrange + COMPONENT Lagrange_Python_Runtime + ) + endforeach() +endif() diff --git a/modules/volume/python/include/lagrange/python/volume.h b/modules/volume/python/include/lagrange/python/volume.h new file mode 100644 index 00000000..dfe000ad --- /dev/null +++ b/modules/volume/python/include/lagrange/python/volume.h @@ -0,0 +1,22 @@ +/* + * 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 + +// clang-format off +#include +#include +#include +// clang-format on + +namespace lagrange::python { +void populate_volume_module(nanobind::module_& m); +} diff --git a/modules/volume/python/src/volume.cpp b/modules/volume/python/src/volume.cpp new file mode 100644 index 00000000..eb7fabe2 --- /dev/null +++ b/modules/volume/python/src/volume.cpp @@ -0,0 +1,686 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +#include +#include +#include +#include +#include +#include +#include +// clang-format on + +// We use std::istrstream which is deprecated in C++98, and will be removed in C++26. +// Once we move to C++23 we can use std::ispanstream instead. +// +// `#pragma GCC diagnostic ignored "-Wdeprecated-declarations"` doesn't work with GCC's +// backward_warning.h. One can use `#pragma GCC diagnostic ignored "-Wcpp"` to ignore +// #warning pragmas, but this only works with GCC 13+. The hacky workaround is to +// undefine a GCC-specific macro instead. +#if LAGRANGE_TARGET_COMPILER(GCC) && defined(__DEPRECATED) + #undef __DEPRECATED + #define LA_RESTORE_DEPRECATED +#endif +#include +#ifdef LA_RESTORE_DEPRECATED + #define __DEPRECATED +#endif + +namespace lagrange::python { + +namespace nb = nanobind; +using namespace nb::literals; + +namespace { + +using AllGrids = openvdb::TypeList; + +template +using MaybeConst = std::conditional_t; + +template +auto apply_or_fail_(GridPtrType&& grid, Func&& func) +{ + constexpr bool IsConst = std::is_same_v, openvdb::GridBase::ConstPtr>; + using FloatGridRef = std::add_lvalue_reference_t>; + using DoubleGridRef = std::add_lvalue_reference_t>; + static_assert( + std::is_same_v< + std::invoke_result_t, + std::invoke_result_t>, + "Func must return the same type for all grid types."); + using ReturnType = std::invoke_result_t; + if constexpr (std::is_void_v) { + // Void return type + bool ok = grid->template apply([&](auto&& real_grid) { + func(std::forward(real_grid)); + return true; + }); + if (!ok) { + throw Error("Unsupported grid type."); + } + return; + } else { + // Non-void return type. To make it work with non-default-constructible types, we + // cannot use OpenVDB's apply() function. + if (auto float_grid = openvdb::GridBase::grid(grid)) { + return func(*float_grid); + } else if (auto double_grid = openvdb::GridBase::grid(grid)) { + return func(*double_grid); + } else { + throw Error("Unsupported grid type."); + } + } +} + +template +auto apply_or_fail(const openvdb::GridBase::ConstPtr& grid, Func&& func) +{ + return apply_or_fail_(grid, std::forward(func)); +} + +template +auto apply_or_fail(openvdb::GridBase::Ptr& grid, Func&& func) +{ + return apply_or_fail_(grid, std::forward(func)); +} + +enum class Compression { + Uncompressed, + Zip, + Blosc, +}; + +uint32_t to_vdb_compression(Compression compression) +{ + switch (compression) { + case Compression::Uncompressed: return openvdb::io::COMPRESS_NONE; + case Compression::Zip: return openvdb::io::COMPRESS_ZIP; + case Compression::Blosc: return openvdb::io::COMPRESS_BLOSC; + default: throw Error("Unknown compression type."); + } +} + +nanovdb::io::Codec to_nanovdb_compression(Compression compression) +{ + switch (compression) { + case Compression::Uncompressed: return nanovdb::io::Codec::NONE; + case Compression::Zip: return nanovdb::io::Codec::ZIP; + case Compression::Blosc: return nanovdb::io::Codec::BLOSC; + default: throw Error("Unknown compression type."); + } +} + +struct GridWrapper +{ + GridWrapper(openvdb::GridBase::Ptr grid) + : m_grid(std::move(grid)) + {} + GridWrapper() = default; + GridWrapper(const GridWrapper&) = default; + GridWrapper(GridWrapper&&) = default; + GridWrapper& operator=(const GridWrapper&) = default; + GridWrapper& operator=(GridWrapper&&) = default; + openvdb::GridBase::ConstPtr grid() const { return m_grid; } + openvdb::GridBase::Ptr& grid() { return m_grid; } + +private: + openvdb::GridBase::Ptr m_grid; +}; + +template +struct GridSampler +{ + using Accessor = typename GridType::ConstAccessor; + using Sampler = openvdb::tools::GridSampler; + GridSampler(const GridType& grid) + : accessor(grid.getConstAccessor()) + , sampler(accessor, grid.transform()) + {} + Accessor accessor; + Sampler sampler; +}; + +openvdb::GridBase::Ptr grid_from_data(std::variant input_path_or_buffer) +{ + if (const auto* input_path = std::get_if(&input_path_or_buffer)) { + // Load grid from file + logger().info("Loading grid from file: {}", input_path->string()); + if (input_path->extension() == ".vdb") { + openvdb::io::File file(input_path->string()); + file.open(); + auto grids = file.getGrids(); + file.close(); + la_runtime_assert( + grids && grids->size() == 1, + "Input VDB must contain exactly one grid."); + return (*grids)[0]; + } else if (input_path->extension() == ".nvdb") { + auto handle = nanovdb::io::readGrid(input_path->string()); + return nanovdb::tools::nanoToOpenVDB(handle); + } else { + throw Error("Unsupported input file extension: " + input_path->extension().string()); + } + } else if (const auto* input_buffer = std::get_if(&input_path_or_buffer)) { + // Load grid from buffer + logger().info("Loading grid from memory buffer of size {}", input_buffer->size()); + try { + LA_IGNORE_DEPRECATION_WARNING_BEGIN + // TODO: Switch to std::ispanstream() when we can use C++23 (probably when I + // retire...) + std::istrstream istr(input_buffer->c_str(), input_buffer->size()); + LA_IGNORE_DEPRECATION_WARNING_END + openvdb::io::Stream strm(istr, false /* delayLoad */); + auto grids = strm.getGrids(); + la_runtime_assert( + grids && grids->size() == 1, + "Input VDB must contain exactly one grid."); + return (*grids)[0]; + } catch (const openvdb::IoError&) { + // Not a VDB grid, try NanoVDB + LA_IGNORE_DEPRECATION_WARNING_BEGIN + // TODO: Switch to std::ispanstream() when we can use C++23 (probably when I + // retire...) + std::istrstream istr(input_buffer->c_str(), input_buffer->size()); + LA_IGNORE_DEPRECATION_WARNING_END + try { + auto handles = nanovdb::io::readGrids(istr); + la_runtime_assert( + handles.size() == 1, + "Input NanoVDB must contain exactly one grid."); + return nanovdb::tools::nanoToOpenVDB(handles[0]); + } catch (const std::runtime_error&) { + throw Error("Invalid input grid: not a valid VDB or NanoVDB grid."); + } + } + } else { + throw Error("Invalid input grid: not a path or buffer."); + } +} + +template +void grid_to_file( + typename lagrange::volume::Grid::ConstPtr grid, + const fs::path& output_path, + Compression compression) +{ + if (output_path.extension() == ".vdb") { + openvdb::io::File file(output_path.string()); + file.setCompression(to_vdb_compression(compression)); + file.write({grid}); + file.close(); + } else if (output_path.extension() == ".nvdb") { + auto handle = nanovdb::tools::createNanoGrid(*grid); + nanovdb::io::writeGrid(output_path.string(), handle, to_nanovdb_compression(compression)); + } else { + throw Error("Unsupported output file extension: " + output_path.string()); + } +} + +void save(const GridWrapper& self, const fs::path& output_path, Compression compression) +{ + apply_or_fail(self.grid(), [&](auto&& grid) { + using RealGrid = std::decay_t; + using GridScalar = typename RealGrid::ValueType; + grid_to_file( + openvdb::gridConstPtrCast(self.grid()), + output_path, + compression); + }); +} + +template +std::string grid_to_buffer( + typename lagrange::volume::Grid::ConstPtr grid, + const std::string& ext, + Compression compression) +{ + std::ostringstream output_stream(std::ios_base::binary); + if (ext == "vdb") { + openvdb::io::Stream oss(output_stream); + oss.setCompression(to_vdb_compression(compression)); + oss.write({grid}); + } else if (ext == "nvdb") { + auto handle = nanovdb::tools::createNanoGrid(*grid); + nanovdb::io::writeGrid(output_stream, handle, to_nanovdb_compression(compression)); + } else { + throw Error("Unsupported grid extension: " + ext); + } + + return output_stream.str(); +} + +nb::bytes save_to_buffer(const GridWrapper& self, const std::string& ext, Compression compression) +{ + std::string buffer = apply_or_fail(self.grid(), [&](auto&& grid) { + using RealGrid = std::decay_t; + using GridScalar = typename RealGrid::ValueType; + return grid_to_buffer( + openvdb::gridConstPtrCast(self.grid()), + ext, + compression); + }); + return nb::bytes(buffer.data(), buffer.size()); +} + +// I had to pull this out as a separate function (and not a lambda), due to an ICE on VS 2022 +template +std::variant +sample_trilinear_index_space(const GridWrapper& self, IndexArray& idx, VecType) +{ + auto v = idx.view(); + std::variant result_var; + apply_or_fail(self.grid(), [&](auto&& grid) { + using GridType = std::decay_t; + using GridScalar = typename GridType::ValueType; + Eigen::VectorX result(v.shape(0)); + tbb::enumerable_thread_specific> data( + [&] { return GridSampler(grid); }); + tbb::parallel_for(tbb::blocked_range(0, v.shape(0)), [&](const auto& r) { + auto& sampler = data.local().sampler; + for (size_t i = r.begin(); i != r.end(); ++i) { + result(i) = sampler.isSample(VecType(v(i, 0), v(i, 1), v(i, 2))); + } + }); + result_var = std::move(result); + }); + return result_var; +}; + +} // namespace + +void populate_volume_module(nb::module_& m) +{ + using Scalar = double; + using Index = uint32_t; + + using ConstArray3i = + nb::ndarray, nb::c_contig, nb::device::cpu>; + using ConstArray3d = nb::ndarray, nb::c_contig, nb::device::cpu>; + using ConstArray3f = nb::ndarray, nb::c_contig, nb::device::cpu>; + + using Sign = lagrange::volume::MeshToVolumeOptions::Sign; + nb::enum_(m, "Sign", "Signing method used to determine inside/outside voxels") + .value("FloodFill", Sign::FloodFill, "Default voxel flood-fill method used by OpenVDB.") + .value( + "WindingNumber", + Sign::WindingNumber, + "Fast winding number method based on [Barill et al. 2018].") + .value("Unsigned", Sign::Unsigned, "Do not compute sign, output unsigned distance field."); + + nb::enum_(m, "Compression", "Compression method for VDB and NanoVDB grid IO") + .value("Uncompressed", Compression::Uncompressed, "No compression.") + .value("Zip", Compression::Zip, "Zip compression.") + .value("Blosc", Compression::Blosc, "Blosc compression."); + + auto float_type = [] { + auto np = nb::module_::import_("numpy"); + return np.attr("float32"); + }(); + + nb::class_ g(m, "Grid"); + + g.def_static( + "load", + [](std::variant input_path_or_buffer) { + GridWrapper wrapper(grid_from_data(input_path_or_buffer)); + return wrapper; + }, + "input_path_or_buffer"_a, + R"(Load a grid from a file or memory buffer. + +:param input_path_or_buffer: Input file path (str or pathlib.Path) or memory buffer (bytes). + +:returns: Loaded grid.)"); + + g.def( + "save", + &save, + "output_path"_a, + nb::arg("compression") = Compression::Blosc, + R"(Save the grid to a file. + +:param output_path: Output file path (str or pathlib.Path). +:param compression: Compression method to use. Default is Blosc.)"); + + g.def( + "to_buffer", + &save_to_buffer, + "grid_type"_a, + nb::arg("compression") = Compression::Blosc, + R"(Save the grid to a memory buffer. + +:param grid_type: Output grid type, either 'vdb' or 'nvdb'. +:param compression: Compression method to use. Default is Blosc. + +:returns: Memory buffer (bytes).)", + nb::sig( + "def to_buffer(ext: typing.Literal['vdb', 'nvdb'], compression: Compression = " + "Compression.Blosc) -> bytes")); + + g.def_prop_ro( + "voxel_size", + [](const GridWrapper& self) { + auto vs = self.grid()->voxelSize(); + Eigen::Vector3d result; + result << vs.x(), vs.y(), vs.z(); + return result; + }, + "Return the grid voxel size."); + + g.def_prop_ro( + "num_active_voxels", + [](const GridWrapper& self) { return self.grid()->activeVoxelCount(); }, + "Return the number of active voxels in the grid."); + + g.def_prop_ro( + "bbox_index", + [](const GridWrapper& self) { + const auto bbox = self.grid()->evalActiveVoxelBoundingBox(); + Eigen::Matrix result; + result << bbox.min().x(), bbox.min().y(), bbox.min().z(), bbox.max().x(), + bbox.max().y(), bbox.max().z(); + return result; + }, + "Return the axis-aligned bounding box of all active voxels in index space. If the grid is " + "empty a default bbox is returned."); + + g.def_prop_ro( + "bbox_world", + [](const GridWrapper& self) { + const auto bbox_index = self.grid()->evalActiveVoxelBoundingBox(); + auto bbox = self.grid()->transform().indexToWorld(bbox_index); + Eigen::Matrix result; + result << bbox.min().x(), bbox.min().y(), bbox.min().z(), bbox.max().x(), + bbox.max().y(), bbox.max().z(); + return result; + }, + "Return the axis-aligned bounding box of all active voxels in world space. If the grid is " + "empty a default bbox is returned."); + + g.def( + "index_to_world", + [](const GridWrapper& self, + std::variant indices) { + auto run = [&](auto&& idx, auto&& vec, auto&& result) { + using VecType = std::decay_t; + auto v = idx.view(); + result.resize(v.shape(0), 3); + const auto transform = self.grid()->transform(); + tbb::parallel_for(size_t(0), v.shape(0), [&](size_t i) { + auto pos = transform.indexToWorld(VecType(v(i, 0), v(i, 1), v(i, 2))); + result.row(i) << pos.x(), pos.y(), pos.z(); + }); + return result; + }; + return std::visit( + [&](auto&& idx) -> std::variant { + using IdxType = std::decay_t; + if constexpr (std::is_same_v) { + return run(idx, openvdb::Coord(), Eigen::MatrixX3d()); + } else if constexpr (std::is_same_v) { + return run(idx, openvdb::Vec3d(), Eigen::MatrixX3f()); + } else if constexpr (std::is_same_v) { + return run(idx, openvdb::Vec3d(), Eigen::MatrixX3d()); + } else { + throw nb::type_error("Invalid index array type."); + } + }, + indices); + }, + "indices"_a, + R"(Convert a set of voxel indices to world coordinates. + +:param indices: Input voxel indices as an (N, 3) array of integers or double. + +:returns: World coordinates as an (N, 3) array of double.)"); + + g.def( + "world_to_index", + [](const GridWrapper& self, std::variant points) { + auto run = [&](auto&& pts, auto&& result) { + auto v = pts.view(); + result.resize(v.shape(0), 3); + const auto transform = self.grid()->transform(); + tbb::parallel_for(size_t(0), v.shape(0), [&](size_t i) { + auto coord = transform.worldToIndex(openvdb::Vec3d(v(i, 0), v(i, 1), v(i, 2))); + result.row(i) << coord.x(), coord.y(), coord.z(); + }); + return result; + }; + return std::visit( + [&](auto&& pts) -> std::variant { + using PtsType = std::decay_t; + if constexpr (std::is_same_v) { + return run(pts, Eigen::MatrixX3f()); + } else if constexpr (std::is_same_v) { + return run(pts, Eigen::MatrixX3d()); + } else { + throw nb::type_error("Invalid points array type."); + } + }, + points); + }, + "points"_a, + R"(Convert a set of world coordinates to voxel indices. + +:param points: Input world coordinates as an (N, 3) array of double. + +:returns: Voxel indices as an (N, 3) array of double.)"); + + g.def( + "resample", + [](const GridWrapper& self, double voxel_size) { + GridWrapper result; + apply_or_fail(self.grid(), [&](auto&& grid) { + result.grid() = lagrange::volume::internal::resample_grid(grid, voxel_size); + }); + return result; + }, + "voxel_size"_a, + R"(Resample the grid to a new voxel size. + +:param voxel_size: New voxel size. Negative means relative to the input grid voxel size. + +:returns: Resampled grid.)"); + + g.def( + "densify", + [](const GridWrapper& self) { + GridWrapper result; + apply_or_fail(self.grid(), [&](auto&& grid) { + result.grid() = lagrange::volume::internal::densify_grid(grid); + }); + return result; + }, + R"(Densify the grid by filling in inactive voxels within the active voxel bounding box. + +:returns: Densified grid.)"); + + g.def( + "redistance", + [](const GridWrapper& self) { + GridWrapper result; + apply_or_fail(self.grid(), [&](auto&& grid) { + result.grid() = lagrange::volume::internal::redistance_grid(grid); + }); + return result; + }, + R"(Recompute the signed distance values of the grid using a fast sweeping method. + +:returns: Redistanced grid.)"); + + g.def( + "offset_in_place", + [](GridWrapper& self, double offset_radius, bool relative) { + apply_or_fail(self.grid(), [&](auto&& grid) { + lagrange::volume::internal::offset_grid_in_place(grid, offset_radius, relative); + }); + }, + "offset_radius"_a, + "relative"_a = false, + R"(Apply an offset to the signed distance field in-place. + +:param offset_radius: Offset radius. A negative value dilates the surface, a positive value erodes it. +:param relative: Whether the offset radius is relative to the grid voxel size.)"); + + g.def( + "sample_trilinear_index_space", + [](const GridWrapper& self, std::variant indices) + -> std::variant { + return std::visit( + [&](auto&& idx) { + using IdxType = std::decay_t; + if constexpr (std::is_same_v) { + return sample_trilinear_index_space(self, idx, openvdb::Coord()); + } else if constexpr (std::is_same_v) { + return sample_trilinear_index_space(self, idx, openvdb::Vec3d()); + } else if constexpr (std::is_same_v) { + return sample_trilinear_index_space(self, idx, openvdb::Vec3d()); + } else { + throw nb::type_error("Invalid index array type."); + } + }, + indices); + }, + "points"_a, + R"(Sample the grid at the given world coordinates using trilinear interpolation. + +:param points: Input index coordinates as an (N, 3) array of integers or double. + +:returns: Sampled values as an (N,) array of double.)"); + + g.def( + "sample_trilinear_world_space", + [](const GridWrapper& self, std::variant points) { + auto run = [&](auto&& pts) -> std::variant { + auto v = pts.view(); + std::variant result_var; + apply_or_fail(self.grid(), [&](auto&& grid) { + using GridType = std::decay_t; + using GridScalar = typename GridType::ValueType; + Eigen::VectorX result(v.shape(0)); + tbb::enumerable_thread_specific> data( + [&] { return GridSampler(grid); }); + tbb::parallel_for( + tbb::blocked_range(0, v.shape(0)), + [&](const auto& r) { + auto& sampler = data.local().sampler; + for (size_t i = r.begin(); i != r.end(); ++i) { + auto pos = openvdb::Vec3d(v(i, 0), v(i, 1), v(i, 2)); + result(i) = sampler.wsSample(pos); + } + }); + result_var = std::move(result); + }); + return result_var; + }; + return std::visit(run, points); + }, + "points"_a, + R"(Sample the grid at the given world coordinates using trilinear interpolation. + +:param points: Input world coordinates as an (N, 3) array of double. + +:returns: Sampled values as an (N,) array of double.)"); + + using MeshToVolumeOptions = lagrange::volume::MeshToVolumeOptions; + g.def_static( + "from_mesh", + [](const SurfaceMesh& mesh, + double voxel_size, + Sign signing_method, + nb::type_object dtype) { + lagrange::volume::MeshToVolumeOptions options; + options.voxel_size = voxel_size; + options.signing_method = signing_method; + + auto run = [&](auto&& grid_scalar) -> GridWrapper { + using GridScalar = std::decay_t; + auto grid = lagrange::volume::mesh_to_volume(mesh, options); + return GridWrapper{grid}; + }; + + auto np = nb::module_::import_("numpy"); + if (dtype.is(np.attr("float32"))) { + return run(float(0)); + } else if (dtype.is(np.attr("float64")) || dtype.is(&PyLong_Type)) { + return run(double(0)); + } else { + throw nb::type_error("Unsupported grid `dtype`!"); + } + }, + "mesh"_a, + "voxel_size"_a = MeshToVolumeOptions().voxel_size, + "signing_method"_a = MeshToVolumeOptions().signing_method, + "dtype"_a = float_type, + R"(Convert a triangle mesh to a sparse voxel grid, writing the result to a file. + +:param mesh: Input mesh. Must be a triangle mesh, a quad-mesh, or a quad-dominant mesh. +:param voxel_size: Voxel size. Negative means relative to bbox diagonal (`vs -> -vs * bbox_diag`). +:param signing_method: Method used to compute the sign of the distance field. +:param dtype: Scalar type of the output grid (float32 or float64). + +:returns: Generated sparse voxel grid.)"); + + g.def( + "to_mesh", + [](const GridWrapper& self, + double isovalue, + double adaptivity, + bool relax_disoriented_triangles, + std::optional normal_attribute_name) { + lagrange::volume::VolumeToMeshOptions options; + options.isovalue = isovalue; + options.adaptivity = adaptivity; + options.relax_disoriented_triangles = relax_disoriented_triangles; + options.normal_attribute_name = + normal_attribute_name.value_or(options.normal_attribute_name); + + SurfaceMesh mesh; + bool ok = self.grid()->apply([&](auto&& grid) { + mesh = lagrange::volume::volume_to_mesh>(grid, options); + }); + if (!ok) { + throw Error("Failed to mesh isosurface: unsupported grid type."); + } + return mesh; + }, + "isovalue"_a = lagrange::volume::VolumeToMeshOptions().isovalue, + "adaptivity"_a = lagrange::volume::VolumeToMeshOptions().adaptivity, + "relax_disoriented_triangles"_a = + lagrange::volume::VolumeToMeshOptions().relax_disoriented_triangles, + "normal_attribute_name"_a = std::nullopt, + R"(Mesh the isosurface of a sparse voxel grid. + +:param grid: Input grid to mesh. +:param isovalue: Value of the isosurface. +:param adaptivity: Surface adaptivity threshold [0 to 1]. 0 keeps the original quad mesh, while 1 simplifies the most. +:param relax_disoriented_triangles: Toggle relaxing disoriented triangles during adaptive meshing. +:param normal_attribute_name: If provided, computes vertex normals from the volume and store them in the appropriately named attribute. + +:returns: Meshed isosurface.)"); +} + +} // namespace lagrange::python diff --git a/modules/volume/python/tests/assets.py b/modules/volume/python/tests/assets.py new file mode 100644 index 00000000..88cec1a0 --- /dev/null +++ b/modules/volume/python/tests/assets.py @@ -0,0 +1,47 @@ +# +# Copyright 2022 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# +import lagrange + +import numpy as np +import pytest + + +@pytest.fixture +def cube(): + vertices = np.array( + [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + [0, 0, 1], + [1, 0, 1], + [1, 1, 1], + [0, 1, 1], + ], + dtype=float, + ) + facets = np.array( + [ + [0, 3, 2, 1], + [4, 5, 6, 7], + [1, 2, 6, 5], + [4, 7, 3, 0], + [2, 3, 7, 6], + [0, 1, 5, 4], + ], + dtype=np.uint32, + ) + mesh = lagrange.SurfaceMesh() + mesh.vertices = vertices + mesh.facets = facets + return mesh diff --git a/modules/volume/python/tests/test_volume.py b/modules/volume/python/tests/test_volume.py new file mode 100644 index 00000000..496cf54c --- /dev/null +++ b/modules/volume/python/tests/test_volume.py @@ -0,0 +1,140 @@ +# +# 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. +# +import lagrange +from lagrange.lagrange.volume import Grid # why?? +import numpy as np +import pytest +import tempfile +import pathlib + +from .assets import cube + + +class TestMeshToVolume: + def test_bbox(self, cube): + mesh = cube.clone() + for dtype in [np.float32, np.float64]: + grid = Grid.from_mesh(mesh, dtype=dtype) + assert grid.voxel_size.shape == (3,) + assert grid.num_active_voxels > 0 + assert grid.bbox_index.dtype == np.dtype(np.int32) + assert grid.bbox_world.dtype == np.dtype(np.float64) + assert grid.index_to_world(grid.bbox_index.astype(np.float64)).dtype == np.dtype( + np.float64 + ) + assert np.allclose(grid.bbox_index, grid.world_to_index(grid.bbox_world)) + assert np.allclose(grid.bbox_world, grid.index_to_world(grid.bbox_index)) + assert np.allclose( + grid.bbox_world, grid.index_to_world(grid.bbox_index.astype(np.float64)) + ) + + def test_sampling(self, cube): + mesh = cube.clone() + for grid_dtype in [np.float32, np.float64]: + for pts_dtype in [np.float32, np.float64]: + grid = Grid.from_mesh(mesh, dtype=grid_dtype) + points_index = np.array( + [ + [0, 0, 0], + [5, 5, 5], + [10, 10, 10], + [15, 15, 15], + ], + dtype=np.int32, + ) + values_is_i = grid.sample_trilinear_index_space(points_index) + values_is_s = grid.sample_trilinear_index_space(points_index.astype(pts_dtype)) + assert values_is_i.dtype == grid_dtype + assert values_is_s.dtype == grid_dtype + assert np.allclose(values_is_i, values_is_s) + points_world_i = grid.index_to_world(points_index) + points_world_s = grid.index_to_world(points_index.astype(pts_dtype)) + assert points_world_i.dtype == np.dtype(np.float64) + assert points_world_s.dtype == pts_dtype + values_ws_i = grid.sample_trilinear_world_space(points_world_i) + values_ws_s = grid.sample_trilinear_world_space(points_world_s) + assert values_ws_i.dtype == grid_dtype + assert values_ws_s.dtype == grid_dtype + assert np.allclose(values_is_i, values_ws_i) + assert np.allclose(values_ws_i, values_ws_s) + + def test_cube(self, cube): + mesh = cube.clone() + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir_path = pathlib.Path(tmp_dir) + for ext in ["vdb", "nvdb"]: + tmp_file_path = tmp_dir_path / f"out.{ext}" + for comp in [ + lagrange.volume.Compression.Uncompressed, + lagrange.volume.Compression.Zip, + lagrange.volume.Compression.Blosc, + ]: + grid = Grid.from_mesh(mesh) + grid.save(tmp_file_path, compression=comp) + buffer = grid.to_buffer(grid_type=ext, compression=comp) + assert type(buffer) is bytes + mesh1 = Grid.load(tmp_file_path).to_mesh() + mesh2 = Grid.load(buffer).to_mesh() + assert mesh1.num_vertices > 0 + assert mesh1.num_facets > 0 + assert mesh1.num_vertices == mesh2.num_vertices + assert mesh1.num_facets == mesh2.num_facets + + def test_offset(self, cube): + mesh = cube.clone() + for signing in [ + lagrange.volume.Sign.FloodFill, + lagrange.volume.Sign.WindingNumber, + lagrange.volume.Sign.Unsigned, + ]: + for dilate in [True, False]: + if dilate: + offset = -1 + else: + offset = 1 + grid = Grid.from_mesh(mesh, signing_method=signing) + active_before = grid.num_active_voxels + grid.offset_in_place(offset, relative=True) + active_after = grid.num_active_voxels + if signing == lagrange.volume.Sign.Unsigned: + assert active_before == active_after + else: + if dilate: + assert active_after > active_before + else: + assert active_after < active_before + + def test_dense(self, cube): + mesh = cube.clone() + for ext in ["vdb", "nvdb"]: + for comp in [ + lagrange.volume.Compression.Uncompressed, + lagrange.volume.Compression.Zip, + lagrange.volume.Compression.Blosc, + ]: + sparse_grid = Grid.from_mesh(mesh) + sparse_buffer = sparse_grid.to_buffer(grid_type=ext, compression=comp) + assert type(sparse_buffer) is bytes + dense_grid = sparse_grid.densify().redistance() + dense_buffer = dense_grid.to_buffer(grid_type=ext, compression=comp) + coarse_grid = dense_grid.resample(voxel_size=-2) + assert type(dense_buffer) is bytes + mesh1 = sparse_grid.to_mesh() + mesh2 = dense_grid.to_mesh() + mesh3 = coarse_grid.to_mesh() + assert mesh1.num_vertices > 0 + assert mesh1.num_facets > 0 + assert mesh3.num_vertices > 0 + assert mesh3.num_facets > 0 + assert mesh3.num_vertices < mesh1.num_vertices + assert mesh1.num_vertices == mesh2.num_vertices + assert mesh1.num_facets == mesh2.num_facets diff --git a/modules/volume/src/mesh_to_volume.cpp b/modules/volume/src/mesh_to_volume.cpp index 4e044d55..e51d5e57 100644 --- a/modules/volume/src/mesh_to_volume.cpp +++ b/modules/volume/src/mesh_to_volume.cpp @@ -19,7 +19,12 @@ #include #include +// clang-format off +#include #include +#include +#include +// clang-format on namespace lagrange::volume { @@ -80,7 +85,7 @@ class SurfaceMeshAdapter } // namespace template -auto mesh_to_volume(const SurfaceMesh& mesh, const MeshToVolumeOptions& options) -> +auto mesh_to_volume(const SurfaceMesh& mesh_, const MeshToVolumeOptions& options) -> typename Grid::Ptr { static_assert( @@ -89,6 +94,7 @@ auto mesh_to_volume(const SurfaceMesh& mesh, const MeshToVolumeOp openvdb::Grid::Type>>, "Mismatch between VDB grid types!"); + auto mesh = SurfaceMesh::stripped_copy(mesh_); la_runtime_assert(mesh.get_dimension() == 3, "Input mesh must be 3D"); if (mesh.is_hybrid()) { for (Index f = 0; f < mesh.get_num_facets(); ++f) { @@ -99,6 +105,14 @@ auto mesh_to_volume(const SurfaceMesh& mesh, const MeshToVolumeOp } } + // Winding number requires triangle meshes. To ensure consistent discretization, we triangulate + // before letting OpenVDB compute the unsigned distance field. + if (options.signing_method == MeshToVolumeOptions::Sign::WindingNumber) { + if (!mesh.is_triangle_mesh()) { + triangulate_polygonal_facets(mesh); + } + } + openvdb::initialize(); auto voxel_size = options.voxel_size; @@ -129,54 +143,43 @@ auto mesh_to_volume(const SurfaceMesh& mesh, const MeshToVolumeOp typename Grid::Ptr grid; try { if (options.signing_method == MeshToVolumeOptions::Sign::WindingNumber) { - // Two stage grid signing approach - { - // Compute unsigned distance field - logger().debug("Computing unsigned distance field grid"); - const float exterior_bandwidth = 3.0f; - const float interior_bandwidth = 3.0f; - grid = openvdb::tools::meshToVolume, MeshAdapterType>( - adapter, - *transform, - exterior_bandwidth, - interior_bandwidth, - openvdb::tools::UNSIGNED_DISTANCE_FIELD); - } - { - // Initialize fast winding number engine. - // - // TODO: Drop mesh attributes from copy to avoid remapping attributes that may be - // present in the input mesh. - logger().debug("Initializing fast winding number engine"); - auto triangle_mesh = mesh; - if (!triangle_mesh.is_triangle_mesh()) { - // Triangulate quad facets first if needed - triangulate_polygonal_facets(triangle_mesh); - } - // Apply world->index transform (query points for winding number have coords in - // index space) - for (auto p : vertex_ref(triangle_mesh).rowwise()) { - openvdb::Vec3d pos(p[0], p[1], p[2]); - pos = transform->worldToIndex(pos); - p << pos[0], pos[1], pos[2]; - } - winding::FastWindingNumber engine(triangle_mesh); - - // Iterate over all grid values (both voxel and tile, active and inactive) - logger().debug("Applying fast winding number sign to the grid"); - auto sign = [&](auto&& iter) { - const auto pos = iter.getBoundingBox().getCenter(); + // Initialize fast winding number engine. + logger().debug("Initializing fast winding number engine"); + la_debug_assert(mesh.is_triangle_mesh()); + winding::FastWindingNumber engine(mesh); + + // Use winding number to sign the distance field + logger().debug("Computing distance field with winding number signing"); + const float exterior_bandwidth = 3.0f; + const float interior_bandwidth = 3.0f; + openvdb::util::NullInterrupter null_interrupter; + grid = openvdb::tools::meshToVolume, MeshAdapterType>( + null_interrupter, + adapter, + *transform, + exterior_bandwidth, + interior_bandwidth, + 0 /* flags */, + nullptr /* polygonIndexGrid */, + [transform, &engine](openvdb::Coord ijk) { + openvdb::Vec3d pos = transform->indexToWorld(ijk); const std::array p = { static_cast(pos[0]), static_cast(pos[1]), static_cast(pos[2])}; - if (engine.is_inside(p)) { - iter.setValue(-*iter); - } - }; - openvdb::tools::foreach (grid->beginValueAll(), sign, true /* threaded */); - logger().debug("Done computing grid"); - } + return engine.is_inside(p); + }, + openvdb::tools::EVAL_EVERY_VOXEL); + } else if (options.signing_method == MeshToVolumeOptions::Sign::Unsigned) { + // Compute unsigned distance field + const float exterior_bandwidth = 3.0f; + const float interior_bandwidth = 3.0f; + grid = openvdb::tools::meshToVolume, MeshAdapterType>( + adapter, + *transform, + exterior_bandwidth, + interior_bandwidth, + openvdb::tools::UNSIGNED_DISTANCE_FIELD); } else { grid = openvdb::tools::meshToVolume, MeshAdapterType>( adapter, @@ -187,6 +190,8 @@ auto mesh_to_volume(const SurfaceMesh& mesh, const MeshToVolumeOp throw; } + logger().debug("Computed grid has {} active voxels", grid->activeVoxelCount()); + return grid; } diff --git a/modules/volume/src/sample_vertex_normal.cpp b/modules/volume/src/sample_vertex_normal.cpp index cb932b49..998abd2f 100644 --- a/modules/volume/src/sample_vertex_normal.cpp +++ b/modules/volume/src/sample_vertex_normal.cpp @@ -18,11 +18,10 @@ #include #include -#include -#include - // clang-format off #include +#include +#include #include #include #include diff --git a/modules/volume/src/volume_to_mesh.cpp b/modules/volume/src/volume_to_mesh.cpp index 94bbd8d1..0f1a7402 100644 --- a/modules/volume/src/volume_to_mesh.cpp +++ b/modules/volume/src/volume_to_mesh.cpp @@ -17,9 +17,13 @@ #include #include +// clang-format off +#include #include #include #include +#include +// clang-format on namespace lagrange::volume { From 8d4da7b8e20e3e207e7de5747f5ad443084eaeaf Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Thu, 5 Feb 2026 15:47:53 -0500 Subject: [PATCH 02/21] Enable volume in python binding. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 19ed02cc..31ea73e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,7 @@ LAGRANGE_MODULE_SCENE = true LAGRANGE_MODULE_SOLVER = true LAGRANGE_MODULE_SUBDIVISION = true LAGRANGE_MODULE_TEXPROC = true +LAGRANGE_MODULE_VOLUME = true LAGRANGE_UNIT_TESTS = false LAGRANGE_WITH_ASSIMP = true TBB_PREFER_STATIC = false From 8c838650ac2cd724274fa0cda0a6fa66dec9d5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Thu, 5 Feb 2026 13:25:15 -0800 Subject: [PATCH 03/21] Install boost targets. --- modules/volume/python/CMakeLists.txt | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/modules/volume/python/CMakeLists.txt b/modules/volume/python/CMakeLists.txt index b2055c4a..395c9561 100644 --- a/modules/volume/python/CMakeLists.txt +++ b/modules/volume/python/CMakeLists.txt @@ -28,4 +28,24 @@ if(SKBUILD) COMPONENT Lagrange_Python_Runtime ) endforeach() + + install( + TARGETS + boost_atomic + boost_chrono + boost_container + boost_context + boost_coroutine + boost_date_time + boost_filesystem + boost_graph + boost_iostreams + boost_log + boost_random + boost_serialization + boost_thread + boost_timer + DESTINATION ${SKBUILD_PLATLIB_DIR}/lagrange + COMPONENT Lagrange_Python_Runtime + ) endif() From 5ef4f6a701a4733698abc9925f1fff7d11244cee Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Thu, 5 Feb 2026 16:50:41 -0500 Subject: [PATCH 04/21] Add missing boost install command. --- modules/volume/python/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/volume/python/CMakeLists.txt b/modules/volume/python/CMakeLists.txt index b2055c4a..65911918 100644 --- a/modules/volume/python/CMakeLists.txt +++ b/modules/volume/python/CMakeLists.txt @@ -14,7 +14,7 @@ lagrange_add_python_binding() target_link_libraries(lagrange_python PRIVATE OpenVDB::nanovdb) if(SKBUILD) - foreach(vdb_target IN ITEMS OpenVDB::openvdb OpenVDB::nanovdb) + foreach(vdb_target IN ITEMS OpenVDB::openvdb OpenVDB::nanovdb boost_container boost_iostreams boost_random) get_target_property(_aliased ${vdb_target} ALIASED_TARGET) if(_aliased) From 35bbf1415889d9f877f043d748140ec4d7c444f1 Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Thu, 5 Feb 2026 16:53:01 -0500 Subject: [PATCH 05/21] Avoid installing unnecessary boost packages into wheel. --- modules/volume/python/CMakeLists.txt | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/modules/volume/python/CMakeLists.txt b/modules/volume/python/CMakeLists.txt index c145600a..4dd80ad0 100644 --- a/modules/volume/python/CMakeLists.txt +++ b/modules/volume/python/CMakeLists.txt @@ -14,7 +14,7 @@ lagrange_add_python_binding() target_link_libraries(lagrange_python PRIVATE OpenVDB::nanovdb) if(SKBUILD) - foreach(vdb_target IN ITEMS OpenVDB::openvdb OpenVDB::nanovdb boost_container boost_iostreams boost_random) + foreach(vdb_target IN ITEMS OpenVDB::openvdb OpenVDB::nanovdb) get_target_property(_aliased ${vdb_target} ALIASED_TARGET) if(_aliased) @@ -31,20 +31,9 @@ if(SKBUILD) install( TARGETS - boost_atomic - boost_chrono boost_container - boost_context - boost_coroutine - boost_date_time - boost_filesystem - boost_graph boost_iostreams - boost_log boost_random - boost_serialization - boost_thread - boost_timer DESTINATION ${SKBUILD_PLATLIB_DIR}/lagrange COMPONENT Lagrange_Python_Runtime ) From 6995a58b5539fff350b1239caa52c8211a6e025a Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Thu, 5 Feb 2026 17:34:25 -0500 Subject: [PATCH 06/21] Wheel build dependency update. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 31ea73e9..239e91f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ build-backend = "scikit_build_core.build" requires = [ + "numpy>=1.25", # needed at build time for default dtype args "scikit-build-core==0.11.6", "typing-extensions~=4.1", ] From 1f2c7fb8f7762501f946331fdc081b5c95c3d282 Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Thu, 5 Feb 2026 17:54:24 -0500 Subject: [PATCH 07/21] Avoid installing static libs into wheel. --- modules/volume/python/CMakeLists.txt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/volume/python/CMakeLists.txt b/modules/volume/python/CMakeLists.txt index 4dd80ad0..a121eba0 100644 --- a/modules/volume/python/CMakeLists.txt +++ b/modules/volume/python/CMakeLists.txt @@ -23,10 +23,13 @@ if(SKBUILD) set(vdb_target ${vdb_target}) endif() - install(TARGETS ${vdb_target} - DESTINATION ${SKBUILD_PLATLIB_DIR}/lagrange - COMPONENT Lagrange_Python_Runtime - ) + get_target_property(_type ${vdb_target} TYPE) + if(_type STREQUAL "SHARED_LIBRARY") + install(TARGETS ${vdb_target} + DESTINATION ${SKBUILD_PLATLIB_DIR}/lagrange + COMPONENT Lagrange_Python_Runtime + ) + endif() endforeach() install( From 79fd6ecbf5f00927fd24a995910191a2f90836e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Thu, 5 Feb 2026 15:49:26 -0800 Subject: [PATCH 08/21] Hide clang warning. --- modules/core/include/lagrange/utils/warnoff.h | 1 + modules/volume/python/src/volume.cpp | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/modules/core/include/lagrange/utils/warnoff.h b/modules/core/include/lagrange/utils/warnoff.h index f01eb3c0..4564228e 100644 --- a/modules/core/include/lagrange/utils/warnoff.h +++ b/modules/core/include/lagrange/utils/warnoff.h @@ -49,6 +49,7 @@ #pragma clang diagnostic ignored "-Wunused-function" #pragma clang diagnostic ignored "-Wbitwise-instead-of-logical" #pragma clang diagnostic ignored "-Wvariadic-macro-arguments-omitted" + #pragma clang diagnostic ignored "-W#warnings" #elif defined(__GNUC__) #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wpragmas" diff --git a/modules/volume/python/src/volume.cpp b/modules/volume/python/src/volume.cpp index eb7fabe2..561a1430 100644 --- a/modules/volume/python/src/volume.cpp +++ b/modules/volume/python/src/volume.cpp @@ -42,7 +42,11 @@ #undef __DEPRECATED #define LA_RESTORE_DEPRECATED #endif +// clang-format off +#include #include +#include +// clang-format on #ifdef LA_RESTORE_DEPRECATED #define __DEPRECATED #endif From 825da39b282edd81b0e46cfa53b74d435a98c7c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Thu, 5 Feb 2026 18:13:31 -0800 Subject: [PATCH 09/21] Build static boost libs. --- cmake/recipes/external/Boost.cmake | 9 --------- 1 file changed, 9 deletions(-) diff --git a/cmake/recipes/external/Boost.cmake b/cmake/recipes/external/Boost.cmake index fba630f0..4825f4aa 100644 --- a/cmake/recipes/external/Boost.cmake +++ b/cmake/recipes/external/Boost.cmake @@ -79,11 +79,6 @@ option(BOOST_IOSTREAMS_ENABLE_BZIP2 "Boost.Iostreams: Enable BZip2 support" OFF) option(BOOST_IOSTREAMS_ENABLE_LZMA "Boost.Iostreams: Enable LZMA support" OFF) option(BOOST_IOSTREAMS_ENABLE_ZSTD "Boost.Iostreams: Enable Zstd support" OFF) -if(SKBUILD) - set(OLD_BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS}) - set(BUILD_SHARED_LIBS ON) -endif() - set(BOOST_PATCHES "") if(EMSCRIPTEN) # Wasm doesn't have rounding mode control yet, so we trick Boost::interval into thinking it has. @@ -104,10 +99,6 @@ CPMAddPackage( ${BOOST_PATCHES} ) -if(SKBUILD) - set(BUILD_SHARED_LIBS ${OLD_BUILD_SHARED_LIBS}) -endif() - # Due to MKL, we may require the release runtime (/MD) even when compiling in Debug mode. # # Boost::random will call in the following line: From 442965d9493b2cb29376edd34bf7fea493524a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Thu, 5 Feb 2026 23:40:21 -0800 Subject: [PATCH 10/21] . From 6fbb65d778f14948b7ff408da41ff781363cff72 Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Fri, 6 Feb 2026 15:18:32 -0500 Subject: [PATCH 11/21] Fix header comments. --- cmake/recipes/external/DynamicVersion.cmake | 2 +- modules/bvh/include/lagrange/bvh/api.h | 2 +- modules/bvh/tests/test_interior_shells.cpp | 2 +- modules/filtering/include/lagrange/filtering/api.h | 2 +- modules/scene/python/tests/assets.py | 2 +- modules/volume/examples/grid_viewer.cpp | 2 +- modules/volume/examples/register_grid.h | 12 ++++++++++++ modules/volume/examples/voxelize_cli.cpp | 2 +- modules/volume/examples/voxelize_gui.cpp | 2 +- .../volume/include/lagrange/volume/internal/utils.h | 11 +++++++++++ modules/volume/python/CMakeLists.txt | 2 +- .../volume/python/include/lagrange/python/volume.h | 2 +- modules/volume/python/src/volume.cpp | 2 +- modules/volume/python/tests/assets.py | 2 +- modules/volume/python/tests/test_volume.py | 2 +- 15 files changed, 36 insertions(+), 13 deletions(-) diff --git a/cmake/recipes/external/DynamicVersion.cmake b/cmake/recipes/external/DynamicVersion.cmake index 0c8124e3..43edf5cd 100644 --- a/cmake/recipes/external/DynamicVersion.cmake +++ b/cmake/recipes/external/DynamicVersion.cmake @@ -1,5 +1,5 @@ # -# Copyright 2025 Adobe. All rights reserved. +# Copyright 2026 Adobe. All rights reserved. # This file is licensed to you under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. You may obtain a copy # of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/bvh/include/lagrange/bvh/api.h b/modules/bvh/include/lagrange/bvh/api.h index 495c91e9..c1010f16 100644 --- a/modules/bvh/include/lagrange/bvh/api.h +++ b/modules/bvh/include/lagrange/bvh/api.h @@ -1,5 +1,5 @@ /* - * Copyright 2025 Adobe. All rights reserved. + * 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 diff --git a/modules/bvh/tests/test_interior_shells.cpp b/modules/bvh/tests/test_interior_shells.cpp index e56a45ec..9e2358d2 100644 --- a/modules/bvh/tests/test_interior_shells.cpp +++ b/modules/bvh/tests/test_interior_shells.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/filtering/include/lagrange/filtering/api.h b/modules/filtering/include/lagrange/filtering/api.h index 1df722ba..ba33627f 100644 --- a/modules/filtering/include/lagrange/filtering/api.h +++ b/modules/filtering/include/lagrange/filtering/api.h @@ -1,5 +1,5 @@ /* - * Copyright 2025 Adobe. All rights reserved. + * 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 diff --git a/modules/scene/python/tests/assets.py b/modules/scene/python/tests/assets.py index e3e74b07..e02c99a3 100644 --- a/modules/scene/python/tests/assets.py +++ b/modules/scene/python/tests/assets.py @@ -1,5 +1,5 @@ # -# Copyright 2023 Adobe. All rights reserved. +# Copyright 2026 Adobe. All rights reserved. # This file is licensed to you under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. You may obtain a copy # of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/volume/examples/grid_viewer.cpp b/modules/volume/examples/grid_viewer.cpp index 46ef203a..7bfb2a90 100644 --- a/modules/volume/examples/grid_viewer.cpp +++ b/modules/volume/examples/grid_viewer.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2021 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/volume/examples/register_grid.h b/modules/volume/examples/register_grid.h index 50a442c6..b9c234d4 100644 --- a/modules/volume/examples/register_grid.h +++ b/modules/volume/examples/register_grid.h @@ -1,3 +1,15 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + #pragma once #include diff --git a/modules/volume/examples/voxelize_cli.cpp b/modules/volume/examples/voxelize_cli.cpp index 6ae2a558..29f4af9b 100644 --- a/modules/volume/examples/voxelize_cli.cpp +++ b/modules/volume/examples/voxelize_cli.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2021 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/volume/examples/voxelize_gui.cpp b/modules/volume/examples/voxelize_gui.cpp index 47a04bc6..dd73cd3b 100644 --- a/modules/volume/examples/voxelize_gui.cpp +++ b/modules/volume/examples/voxelize_gui.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2021 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/volume/include/lagrange/volume/internal/utils.h b/modules/volume/include/lagrange/volume/internal/utils.h index 2b61cd87..fd2c32d5 100644 --- a/modules/volume/include/lagrange/volume/internal/utils.h +++ b/modules/volume/include/lagrange/volume/internal/utils.h @@ -1,3 +1,14 @@ +/* + * 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 diff --git a/modules/volume/python/CMakeLists.txt b/modules/volume/python/CMakeLists.txt index a121eba0..0a75ae94 100644 --- a/modules/volume/python/CMakeLists.txt +++ b/modules/volume/python/CMakeLists.txt @@ -1,5 +1,5 @@ # -# Copyright 2025 Adobe. All rights reserved. +# Copyright 2026 Adobe. All rights reserved. # This file is licensed to you under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. You may obtain a copy # of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/volume/python/include/lagrange/python/volume.h b/modules/volume/python/include/lagrange/python/volume.h index dfe000ad..2ef6b2ec 100644 --- a/modules/volume/python/include/lagrange/python/volume.h +++ b/modules/volume/python/include/lagrange/python/volume.h @@ -1,5 +1,5 @@ /* - * Copyright 2025 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/volume/python/src/volume.cpp b/modules/volume/python/src/volume.cpp index 561a1430..1657df41 100644 --- a/modules/volume/python/src/volume.cpp +++ b/modules/volume/python/src/volume.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/volume/python/tests/assets.py b/modules/volume/python/tests/assets.py index 88cec1a0..934fb1fe 100644 --- a/modules/volume/python/tests/assets.py +++ b/modules/volume/python/tests/assets.py @@ -1,5 +1,5 @@ # -# Copyright 2022 Adobe. All rights reserved. +# Copyright 2026 Adobe. All rights reserved. # This file is licensed to you under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. You may obtain a copy # of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/volume/python/tests/test_volume.py b/modules/volume/python/tests/test_volume.py index 496cf54c..89f8e5df 100644 --- a/modules/volume/python/tests/test_volume.py +++ b/modules/volume/python/tests/test_volume.py @@ -1,5 +1,5 @@ # -# Copyright 2025 Adobe. All rights reserved. +# Copyright 2026 Adobe. All rights reserved. # This file is licensed to you under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. You may obtain a copy # of the License at http://www.apache.org/licenses/LICENSE-2.0 From fec8aa3a94f708f61d20977cdb5d727c71697993 Mon Sep 17 00:00:00 2001 From: Qingnan Zhou Date: Fri, 6 Feb 2026 17:16:34 -0500 Subject: [PATCH 12/21] More header updates. --- modules/bvh/include/lagrange/bvh/api.h | 2 +- modules/filtering/include/lagrange/filtering/api.h | 2 +- modules/texproc/examples/texture_stitching_gui.cpp | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/bvh/include/lagrange/bvh/api.h b/modules/bvh/include/lagrange/bvh/api.h index c1010f16..495c91e9 100644 --- a/modules/bvh/include/lagrange/bvh/api.h +++ b/modules/bvh/include/lagrange/bvh/api.h @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2025 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/filtering/include/lagrange/filtering/api.h b/modules/filtering/include/lagrange/filtering/api.h index ba33627f..1df722ba 100644 --- a/modules/filtering/include/lagrange/filtering/api.h +++ b/modules/filtering/include/lagrange/filtering/api.h @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2025 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/texproc/examples/texture_stitching_gui.cpp b/modules/texproc/examples/texture_stitching_gui.cpp index 7c355e66..48ce0433 100644 --- a/modules/texproc/examples/texture_stitching_gui.cpp +++ b/modules/texproc/examples/texture_stitching_gui.cpp @@ -1,3 +1,14 @@ +/* + * 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 "io_helpers.h" #include From 5b6afd288807ceaf510ee96de022d7347751f573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 9 Feb 2026 11:42:35 -0800 Subject: [PATCH 13/21] More disk space shenanigans. --- .github/workflows/continuous.yaml | 34 +++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index a28daef7..50d5d79f 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -57,11 +57,37 @@ jobs: docker-images: true swap-storage: true + - name: Maximize build space + uses: easimon/maximize-build-space@master + with: + root-reserve-mb: 512 + swap-size-mb: 1024 + remove-dotnet: 'true' + - name: Show disk space run: | echo "disk usage:" df -h + - name: Select build dir (Linux) + if: runner.os == 'Linux' + run: + # Find mount point with the largest available space for our build dir... + space_cwd=$(df --output=avail build | tail -1 | tr -d ' ') + space_mnt=$(df --output=avail /mnt/build 2>/dev/null | tail -1 | tr -d ' ') + if [ -n "$space_mnt" ] && [ "$space_mnt" -gt "$space_cwd" ]; then + echo "build_dir=/mnt/build" >> "$GITHUB_ENV" + echo "Selected /mnt/build (/mnt has $((space_mnt/1024/1024)) GB free vs ./build has $((space_cwd/1024/1024)) GB free)" + else + echo "build_dir=build" >> "$GITHUB_ENV" + echo "Selected ./build (./build has $((space_cwd/1024/1024)) GB free)" + fi + + - name: Select build dir (macOS) + if: runner.os == 'Linux' + run: + echo "build_dir=build" >> "$GITHUB_ENV" + - name: Checkout repository uses: actions/checkout@v4 # Note: Update to actions/checkout@6 once 6.0.2 is out, and remove the fetch-depth: 0 @@ -128,8 +154,8 @@ jobs: - name: Configure run: | - mkdir -p build - cd build + mkdir -p ${{ env.build_dir }} + cd ${{ env.build_dir }} cmake --version cmake .. -G Ninja \ -DCMAKE_BUILD_TYPE=${{ matrix.config }} \ @@ -144,7 +170,7 @@ jobs: - name: Build run: | - cmake --build build -j ${{ steps.cpu-cores.outputs.count }} + cmake --build ${{ env.build_dir }} -j ${{ steps.cpu-cores.outputs.count }} ccache --show-stats - name: Show disk space @@ -154,7 +180,7 @@ jobs: df -h - name: Tests - run: cd build; ctest --verbose -j ${{ steps.cpu-cores.outputs.count }} + run: cd ${{ env.build_dir }}; ctest --verbose -j ${{ steps.cpu-cores.outputs.count }} #################### # Windows From fdf530c754461649a80c6989e10a1e8368e298d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 9 Feb 2026 11:50:49 -0800 Subject: [PATCH 14/21] Save space on boost source download. --- cmake/recipes/external/Boost.cmake | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmake/recipes/external/Boost.cmake b/cmake/recipes/external/Boost.cmake index 4825f4aa..ac371ef1 100644 --- a/cmake/recipes/external/Boost.cmake +++ b/cmake/recipes/external/Boost.cmake @@ -92,9 +92,8 @@ endif() include(CPM) CPMAddPackage( NAME Boost - VERSION 1.84.0 - GITHUB_REPOSITORY "boostorg/boost" - GIT_TAG "boost-1.84.0" + URL https://github.com/boostorg/boost/releases/download/boost-1.84.0/boost-1.84.0.tar.xz + URL_HASH SHA256=2e64e5d79a738d0fa6fb546c6e5c2bd28f88d268a2a080546f74e5ff98f29d0e EXCLUDE_FROM_ALL ON ${BOOST_PATCHES} ) From ce382126b7616abe3922fc6e5abf803d0c5f21d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 9 Feb 2026 11:56:38 -0800 Subject: [PATCH 15/21] Multiline command. --- .github/workflows/continuous.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index 50d5d79f..5f946c98 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -71,8 +71,8 @@ jobs: - name: Select build dir (Linux) if: runner.os == 'Linux' - run: - # Find mount point with the largest available space for our build dir... + # Find mount point with the largest available space for our build dir... + run: | space_cwd=$(df --output=avail build | tail -1 | tr -d ' ') space_mnt=$(df --output=avail /mnt/build 2>/dev/null | tail -1 | tr -d ' ') if [ -n "$space_mnt" ] && [ "$space_mnt" -gt "$space_cwd" ]; then From bc810be61932629a1ce8c766a1dc71783e6edad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 9 Feb 2026 11:57:57 -0800 Subject: [PATCH 16/21] Cleanup. --- .github/workflows/continuous.yaml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index 5f946c98..9bcab6df 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -57,13 +57,6 @@ jobs: docker-images: true swap-storage: true - - name: Maximize build space - uses: easimon/maximize-build-space@master - with: - root-reserve-mb: 512 - swap-size-mb: 1024 - remove-dotnet: 'true' - - name: Show disk space run: | echo "disk usage:" From ca9fe79af7a3531ff7f49a812574a04bbd83614f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 9 Feb 2026 13:03:31 -0800 Subject: [PATCH 17/21] Fix ci. --- .github/workflows/continuous.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index 9bcab6df..02218029 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -66,18 +66,18 @@ jobs: if: runner.os == 'Linux' # Find mount point with the largest available space for our build dir... run: | - space_cwd=$(df --output=avail build | tail -1 | tr -d ' ') + space_cwd=$(df --output=avail . | tail -1 | tr -d ' ') space_mnt=$(df --output=avail /mnt/build 2>/dev/null | tail -1 | tr -d ' ') if [ -n "$space_mnt" ] && [ "$space_mnt" -gt "$space_cwd" ]; then echo "build_dir=/mnt/build" >> "$GITHUB_ENV" - echo "Selected /mnt/build (/mnt has $((space_mnt/1024/1024)) GB free vs ./build has $((space_cwd/1024/1024)) GB free)" + echo "Selected /mnt/build (/mnt has $((space_mnt/1024/1024)) GB free vs ./ has $((space_cwd/1024/1024)) GB free)" else echo "build_dir=build" >> "$GITHUB_ENV" - echo "Selected ./build (./build has $((space_cwd/1024/1024)) GB free)" + echo "Selected ./build (./ has $((space_cwd/1024/1024)) GB free)" fi - name: Select build dir (macOS) - if: runner.os == 'Linux' + if: runner.os == 'macOS' run: echo "build_dir=build" >> "$GITHUB_ENV" From 6cfb179a71389e9325eb648116ccad3954194876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 9 Feb 2026 13:10:46 -0800 Subject: [PATCH 18/21] Debugging. --- .github/workflows/continuous.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index 02218029..394c211c 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -68,6 +68,8 @@ jobs: run: | space_cwd=$(df --output=avail . | tail -1 | tr -d ' ') space_mnt=$(df --output=avail /mnt/build 2>/dev/null | tail -1 | tr -d ' ') + echo "Space available on ./: $space_cwd KB" + echo "Space available on /mnt: $space_mnt KB" if [ -n "$space_mnt" ] && [ "$space_mnt" -gt "$space_cwd" ]; then echo "build_dir=/mnt/build" >> "$GITHUB_ENV" echo "Selected /mnt/build (/mnt has $((space_mnt/1024/1024)) GB free vs ./ has $((space_cwd/1024/1024)) GB free)" From 2d69a8606cc74b326193558a0aa7cd55f6569d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 9 Feb 2026 13:27:40 -0800 Subject: [PATCH 19/21] Fix. --- .github/workflows/continuous.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index 394c211c..d718f702 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -67,7 +67,7 @@ jobs: # Find mount point with the largest available space for our build dir... run: | space_cwd=$(df --output=avail . | tail -1 | tr -d ' ') - space_mnt=$(df --output=avail /mnt/build 2>/dev/null | tail -1 | tr -d ' ') + space_mnt=$(df --output=avail /mnt 2>/dev/null | tail -1 | tr -d ' ') echo "Space available on ./: $space_cwd KB" echo "Space available on /mnt: $space_mnt KB" if [ -n "$space_mnt" ] && [ "$space_mnt" -gt "$space_cwd" ]; then From d71842cdcd5f7a486755c0185437f770feeacb69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 9 Feb 2026 13:39:12 -0800 Subject: [PATCH 20/21] Fix. --- .github/workflows/continuous.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index d718f702..70a90811 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -73,6 +73,8 @@ jobs: if [ -n "$space_mnt" ] && [ "$space_mnt" -gt "$space_cwd" ]; then echo "build_dir=/mnt/build" >> "$GITHUB_ENV" echo "Selected /mnt/build (/mnt has $((space_mnt/1024/1024)) GB free vs ./ has $((space_cwd/1024/1024)) GB free)" + sudo mkdir -p /mnt/build + sudo chown $USER /mnt/build else echo "build_dir=build" >> "$GITHUB_ENV" echo "Selected ./build (./ has $((space_cwd/1024/1024)) GB free)" From 1b481ace2012adf70524d129f3c59c02f9bca340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Je=CC=81re=CC=81mie=20Dumas?= Date: Mon, 9 Feb 2026 14:00:34 -0800 Subject: [PATCH 21/21] Fix. --- .github/workflows/continuous.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index 70a90811..cd34b855 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -151,10 +151,8 @@ jobs: - name: Configure run: | - mkdir -p ${{ env.build_dir }} - cd ${{ env.build_dir }} cmake --version - cmake .. -G Ninja \ + cmake -B ${{ env.build_dir }} -S . -G Ninja \ -DCMAKE_BUILD_TYPE=${{ matrix.config }} \ -DLAGRANGE_JENKINS=ON \ -DLAGRANGE_ALL=ON \