diff --git a/.github/workflows/continuous.yaml b/.github/workflows/continuous.yaml index bfa9289e..96fcd092 100644 --- a/.github/workflows/continuous.yaml +++ b/.github/workflows/continuous.yaml @@ -4,6 +4,9 @@ on: pull_request: branches: - main + push: + branches: + - main concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -21,26 +24,26 @@ jobs: Unix: name: ${{ matrix.os }} (${{ matrix.compiler }}, ${{ matrix.config }}, ${{ matrix.sanitizer }}Sanitizer) runs-on: ${{ matrix.os }} + env: + CCACHE_COMPRESSLEVEL: 5 strategy: fail-fast: false matrix: - os: [ubuntu-24.04, macos-15-intel, macos-15] - config: [RelWithDebInfo, Debug] - compiler: [gcc, apple, llvm] - sanitizer: ["Address", "Thread"] # TODO: Add Memory+Undefined Sanitizer - exclude: - - os: macos-15-intel - compiler: gcc - - os: macos-15-intel - sanitizer: Thread - - os: macos-15-intel - config: Debug - - os: macos-15 - compiler: gcc - - os: ubuntu-24.04 - compiler: apple + include: + # Linux release builds exercise tests disabled in Debug mode, and ASan also enables LSan. + - { os: ubuntu-24.04, compiler: gcc, config: RelWithDebInfo, sanitizer: Address, split_debug: ON } + - { os: ubuntu-24.04, compiler: gcc, config: RelWithDebInfo, sanitizer: Thread } + - { os: ubuntu-24.04, compiler: llvm, config: Debug, sanitizer: Address } + - { os: ubuntu-24.04, compiler: llvm, config: Debug, sanitizer: Thread } + + # Keep AppleClang release coverage on Intel and ASan/TSan coverage on Apple Silicon. + - { os: macos-15-intel, compiler: apple, config: RelWithDebInfo, sanitizer: Address } + - { os: macos-15, compiler: apple, config: RelWithDebInfo, sanitizer: Address } + - { os: macos-15, compiler: apple, config: RelWithDebInfo, sanitizer: Thread } + - { os: macos-15, compiler: llvm, config: Debug, sanitizer: Address } + - { os: macos-15, compiler: llvm, config: Debug, sanitizer: Thread } steps: - - name: Free Disk Space + - name: Free Disk Space (Linux) if: runner.os == 'Linux' uses: jlumbroso/free-disk-space@main with: @@ -57,6 +60,25 @@ jobs: docker-images: true swap-storage: true + - name: Free Disk Space (macOS) + if: runner.os == 'macOS' + run: | + echo "Disk usage before cleanup:" + df -h + sudo rm -rf /Library/Developer/CoreSimulator + sudo rm -rf "$HOME/Library/Developer/CoreSimulator" + sudo rm -rf /System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime + sudo rm -rf /System/Library/AssetsV2/com_apple_MobileAsset_xrOSSimulatorRuntime + sudo rm -rf /System/Library/AssetsV2/com_apple_MobileAsset_watchOSSimulatorRuntime + sudo rm -rf /System/Library/AssetsV2/com_apple_MobileAsset_appleTVOSSimulatorRuntime + sudo rm -rf "$ANDROID_HOME" + sudo rm -rf /usr/local/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + echo "Disk usage after cleanup:" + df -h + - name: Set swap space if: runner.os == 'Linux' && matrix.compiler == 'gcc' && matrix.config == 'RelWithDebInfo' uses: pierotofy/set-swap-space@fc79b3f67fa8a838184ce84a674ca12238d2c761 @@ -133,7 +155,11 @@ jobs: - name: Ccache uses: hendrikmuhs/ccache-action@v1.2 with: - key: ${{ matrix.os }}-${{ matrix.compiler }}-${{ matrix.sanitizer }}-${{ matrix.config }} + key: ${{ matrix.os }}-${{ matrix.compiler }}-${{ matrix.config }}-${{ matrix.sanitizer }} + # Keep more objects, and do not prune untouched entries because cancelled jobs + # would save an incomplete cache as the newest restore candidate. + max-size: 750M + verbose: 1 - name: Setup Xcode version if: matrix.os == 'macos-15-intel' @@ -153,11 +179,11 @@ jobs: - name: Select embree isa (macOS x64) if: runner.os == 'macOS' && runner.arch == 'X64' - run: echo "embree_max_isa=DEFAULT" >> $GITHUB_ENV + run: echo "embree_max_isa=SSE2" >> $GITHUB_ENV - name: Select embree isa (macOS arm64) if: runner.os == 'macOS' && runner.arch == 'ARM64' - run: echo "embree_max_isa=NONE" >> $GITHUB_ENV + run: echo "embree_max_isa=NEON" >> $GITHUB_ENV - name: Get number of CPU cores uses: SimenB/github-actions-cpu-cores@v1 @@ -171,13 +197,35 @@ jobs: -DLAGRANGE_JENKINS=ON \ -DLAGRANGE_ALL=ON \ -DLAGRANGE_LIMIT_PARALLELISM=ON \ + -DLAGRANGE_LIMIT_GITHUB_ACTIONS_PARALLELISM=ON \ -DOPENVDB_CORE_SHARED=ON \ -DOPENVDB_CORE_STATIC=OFF \ - -DUSE_EXPLICIT_INSTANTIATION=OFF \ + -DUSE_EXPLICIT_INSTANTIATION=ON \ -DLAGRANGE_POLYSCOPE_MOCK_BACKEND=ON \ + -DLAGRANGE_USE_SPLIT_DEBUG_INFO=${{ matrix.split_debug }} \ -DEMBREE_MAX_ISA=${{ env.embree_max_isa }} \ -DUSE_SANITIZER="${{ matrix.sanitizer }}" + - name: Build Embree + if: runner.os == 'Linux' || matrix.compiler == 'llvm' + run: cmake --build ${{ env.build_dir }} --target embree -j 2 + + - name: Show disk space + if: always() + run: | + echo "disk usage:" + df -h + + - name: Build OpenVDB + if: runner.os == 'Linux' || matrix.compiler == 'llvm' + run: cmake --build ${{ env.build_dir }} --target openvdb_shared -j 2 + + - name: Show disk space + if: always() + run: | + echo "disk usage:" + df -h + - name: Build run: cmake --build ${{ env.build_dir }} -j ${{ steps.cpu-cores.outputs.count }} @@ -190,6 +238,12 @@ jobs: - name: Tests run: cd ${{ env.build_dir }}; ctest --verbose -j ${{ steps.cpu-cores.outputs.count }} + - name: Clean and report ccache + if: always() + run: | + ccache --cleanup + ccache --show-stats --verbose + #################### # Windows #################### @@ -199,6 +253,7 @@ jobs: runs-on: ${{ matrix.os }} env: SCCACHE_GHA_ENABLED: "true" + SCCACHE_CACHE_ZSTD_LEVEL: "5" strategy: fail-fast: false matrix: @@ -250,7 +305,7 @@ jobs: id: cpu-cores - name: Sccache - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 - name: Set x64 vars if: matrix.os == 'windows-2025' diff --git a/CMakeLists.txt b/CMakeLists.txt index 56321d21..9d2ed5cd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -258,11 +258,13 @@ option(LAGRANGE_EXAMPLES "Build all examples" option(LAGRANGE_FMT_EIGEN_FIX "Avoid MSVC C1001 error releated to fmt and eigen" ${LAGRANGE_FMT_EIGEN_FIX_DEFAULT}) option(LAGRANGE_INSTALL "Enable installation" ${LAGRANGE_TOPLEVEL_PROJECT}) option(LAGRANGE_LIMIT_PARALLELISM "Limit parallelism according to available cpu/memory" OFF) +option(LAGRANGE_LIMIT_GITHUB_ACTIONS_PARALLELISM "Limit heavy compile jobs on GitHub Actions" OFF) option(LAGRANGE_MORE_WARNINGS "Increase the level of warnings when compiling" OFF) option(LAGRANGE_PROPAGATE_WARNINGS "Propagate warning flags to dependent targets" ON) option(LAGRANGE_PERFORMANCE_TESTS "Build all performance tests" OFF) option(LAGRANGE_UNIT_TESTS "Build all unit tests" ${LAGRANGE_TOPLEVEL_PROJECT}) option(LAGRANGE_USE_PCH "Enable precompiled headers" OFF) +option(LAGRANGE_USE_SPLIT_DEBUG_INFO "Store debug information in separate compressed files" OFF) option(LAGRANGE_USE_WASM_EXCEPTIONS "Use -fwasm-exception flag with Emscripten" ON) option(LAGRANGE_USE_WASM_THREADS "Enable threads (-pthread) with Emscripten" ON) option(LAGRANGE_WITH_TRACY "Build tracy client with Lagrange" OFF) @@ -441,6 +443,9 @@ set_property(GLOBAL PROPERTY USE_FOLDERS ON) if(LAGRANGE_LIMIT_PARALLELISM) lagrange_limit_parallelism() endif() +if(LAGRANGE_LIMIT_GITHUB_ACTIONS_PARALLELISM) + lagrange_limit_github_actions_parallelism() +endif() set(TBB_ENABLE_WASM_THREADS ${LAGRANGE_USE_WASM_THREADS}) diff --git a/cmake/lagrange/lagrange_filter_flags.cmake b/cmake/lagrange/lagrange_filter_flags.cmake index 5a9f574b..d59c5bd9 100644 --- a/cmake/lagrange/lagrange_filter_flags.cmake +++ b/cmake/lagrange/lagrange_filter_flags.cmake @@ -9,7 +9,7 @@ # OF ANY KIND, either express or implied. See the License for the specific language # governing permissions and limitations under the License. # -function(lagrange_filter_flags flags) +function(lagrange_filter_compile_flags flags) include(CheckCXXCompilerFlag) set(output_flags) foreach(FLAG IN ITEMS ${${flags}}) @@ -23,3 +23,23 @@ function(lagrange_filter_flags flags) endforeach() set(${flags} ${output_flags} PARENT_SCOPE) endfunction() + +function(lagrange_filter_link_flags flags) + include(CheckLinkerFlag) + set(output_flags) + foreach(FLAG IN ITEMS ${${flags}}) + string(REPLACE "=" "-" FLAG_VAR "${FLAG}") + if(NOT DEFINED IS_LINK_SUPPORTED_${FLAG_VAR}) + set(check_flags "${FLAG}") + if(MSVC) + # MSVC may ignore unsupported linker options instead of failing. + list(APPEND check_flags /WX) + endif() + check_linker_flag(CXX "${check_flags}" IS_LINK_SUPPORTED_${FLAG_VAR}) + endif() + if(IS_LINK_SUPPORTED_${FLAG_VAR}) + list(APPEND output_flags $<$:${FLAG}>) + endif() + endforeach() + set(${flags} ${output_flags} PARENT_SCOPE) +endfunction() diff --git a/cmake/lagrange/lagrange_global_flags.cmake b/cmake/lagrange/lagrange_global_flags.cmake index 4f2db768..32e23ef9 100644 --- a/cmake/lagrange/lagrange_global_flags.cmake +++ b/cmake/lagrange/lagrange_global_flags.cmake @@ -35,7 +35,7 @@ if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "MSVC") set(LAGRANGE_GLOBAL_FLAGS /EHsc # Compatibility with Boost::hana ) - lagrange_filter_flags(LAGRANGE_GLOBAL_FLAGS) + lagrange_filter_compile_flags(LAGRANGE_GLOBAL_FLAGS) message(STATUS "Adding global flags: ${LAGRANGE_GLOBAL_FLAGS}") add_compile_options(${LAGRANGE_GLOBAL_FLAGS}) else() @@ -44,18 +44,36 @@ else() -fdiagnostics-color=always # GCC -fcolor-diagnostics # Clang ) - lagrange_filter_flags(LAGRANGE_GLOBAL_FLAGS) + lagrange_filter_compile_flags(LAGRANGE_GLOBAL_FLAGS) message(STATUS "Adding global flags: ${LAGRANGE_GLOBAL_FLAGS}") add_compile_options(${LAGRANGE_GLOBAL_FLAGS}) endif() +if(LAGRANGE_USE_SPLIT_DEBUG_INFO) + include(lagrange_filter_flags) + set(LAGRANGE_SPLIT_DEBUG_FLAGS + -gsplit-dwarf + -gz=zstd + ) + lagrange_filter_compile_flags(LAGRANGE_SPLIT_DEBUG_FLAGS) + message(STATUS "Adding split debug flags: ${LAGRANGE_SPLIT_DEBUG_FLAGS}") + add_compile_options(${LAGRANGE_SPLIT_DEBUG_FLAGS}) + + set(LAGRANGE_SPLIT_DEBUG_LINK_FLAGS + LINKER:--compress-debug-sections=zstd + ) + lagrange_filter_link_flags(LAGRANGE_SPLIT_DEBUG_LINK_FLAGS) + message(STATUS "Adding split debug link flags: ${LAGRANGE_SPLIT_DEBUG_LINK_FLAGS}") + add_link_options(${LAGRANGE_SPLIT_DEBUG_LINK_FLAGS}) +endif() + if(LAGRANGE_WITH_TRACY) include(lagrange_filter_flags) set(LAGRANGE_GLOBAL_FLAGS "-fno-omit-frame-pointer" "-g" ) - lagrange_filter_flags(LAGRANGE_GLOBAL_FLAGS) + lagrange_filter_compile_flags(LAGRANGE_GLOBAL_FLAGS) message(STATUS "Adding global flags: ${LAGRANGE_GLOBAL_FLAGS}") add_compile_options(${LAGRANGE_GLOBAL_FLAGS}) endif() diff --git a/cmake/lagrange/lagrange_limit_parallelism.cmake b/cmake/lagrange/lagrange_limit_parallelism.cmake index 8dd9d046..2cf9c7e5 100644 --- a/cmake/lagrange/lagrange_limit_parallelism.cmake +++ b/cmake/lagrange/lagrange_limit_parallelism.cmake @@ -58,6 +58,19 @@ function(lagrange_limit_parallelism) endif() endfunction() +function(lagrange_limit_github_actions_parallelism) + set(num_heavy_compile_jobs 2) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux" + AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang" + AND USE_SANITIZER MATCHES "([Aa]ddress)" + AND USE_SANITIZER MATCHES "([Uu]ndefined)") + set(num_heavy_compile_jobs 1) + endif() + + message(STATUS "Parallelism: Limiting heavy compile pool to ${num_heavy_compile_jobs}") + set_property(GLOBAL APPEND PROPERTY JOB_POOLS pool-heavy-compile=${num_heavy_compile_jobs}) +endfunction() + # If this file is run in script mode, it echoes the number of physical cores for use as # the -j flag for cmake --build and ctest. Link parallelism is not relevant here — it is # enforced by Ninja job pools set during the configure step. diff --git a/cmake/lagrange/lagrange_warnings.cmake b/cmake/lagrange/lagrange_warnings.cmake index 21f46527..349b0055 100644 --- a/cmake/lagrange/lagrange_warnings.cmake +++ b/cmake/lagrange/lagrange_warnings.cmake @@ -60,7 +60,7 @@ if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "MSVC") "/permissive-" ) endif() - lagrange_filter_flags(options) + lagrange_filter_compile_flags(options) target_compile_options(lagrange_warnings INTERFACE ${options}) else() # For non-MSVC compilers, see @@ -217,7 +217,7 @@ else() endif() endif() - lagrange_filter_flags(options) + lagrange_filter_compile_flags(options) target_compile_options(lagrange_warnings INTERFACE ${options}) endif() diff --git a/cmake/recipes/external/OpenVDB.cmake b/cmake/recipes/external/OpenVDB.cmake index c0bae13e..cbf41161 100644 --- a/cmake/recipes/external/OpenVDB.cmake +++ b/cmake/recipes/external/OpenVDB.cmake @@ -176,6 +176,11 @@ function(openvdb_import_target) # Inject real Boost dependencies instead of dummy Boost:headers one foreach(name IN ITEMS openvdb_static openvdb_shared) if(TARGET ${name}) + if(LAGRANGE_LIMIT_GITHUB_ACTIONS_PARALLELISM + AND CMAKE_SYSTEM_NAME STREQUAL "Linux" + AND CMAKE_GENERATOR MATCHES "^Ninja") + set_property(TARGET ${name} PROPERTY JOB_POOL_COMPILE pool-heavy-compile) + endif() target_link_libraries(${name} PUBLIC Boost::algorithm diff --git a/cmake/recipes/external/embree.cmake b/cmake/recipes/external/embree.cmake index a323f51c..6e926f93 100644 --- a/cmake/recipes/external/embree.cmake +++ b/cmake/recipes/external/embree.cmake @@ -129,6 +129,14 @@ function(embree_import_target) unignore_package(TBB) + # Embree has several memory-intensive translation units. Limit their concurrency on + # Linux when the project's memory-aware parallelism policy is enabled. + if(LAGRANGE_LIMIT_GITHUB_ACTIONS_PARALLELISM + AND CMAKE_SYSTEM_NAME STREQUAL "Linux" + AND CMAKE_GENERATOR MATCHES "^Ninja") + set_property(TARGET embree PROPERTY JOB_POOL_COMPILE pool-heavy-compile) + endif() + # Disable warnings if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU") # Embree's subgrid.h is known for causing array subscript out of bound @@ -146,6 +154,26 @@ function(embree_import_target) target_compile_options(embree PRIVATE "-Wno-unused-but-set-variable") endif() + # Suppress kernel dispatch function casts and AccelSet downcasts, not all UBSan checks. + # Keep exclusions private; GCC has no function-pointer sanitizer. + if(USE_SANITIZER MATCHES "([Uu]ndefined)") + foreach(target IN ITEMS + embree + embree_sse42 + embree_avx + embree_avx2 + embree_avx512 + embree_apx + ) + if(TARGET ${target}) + target_compile_options(${target} PRIVATE + $<$:-fno-sanitize=vptr> + $<$:-fno-sanitize=function> + ) + endif() + endforeach() + endif() + # Now we need to do some juggling to propagate the include directory properties # along with the `embree` target add_library(embree::embree INTERFACE IMPORTED GLOBAL) diff --git a/cmake/recipes/external/ufbx.cmake b/cmake/recipes/external/ufbx.cmake index 7ac2c981..b5d7d522 100644 --- a/cmake/recipes/external/ufbx.cmake +++ b/cmake/recipes/external/ufbx.cmake @@ -32,6 +32,12 @@ target_include_directories(ufbx PUBLIC set_target_properties(ufbx PROPERTIES FOLDER third_party) set_target_properties(ufbx PROPERTIES POSITION_INDEPENDENT_CODE ON) +# Select UBSan-safe paths explicitly: GCC 13 has no UBSan detection macro. +# https://github.com/ufbx/ufbx/issues/231 +if(USE_SANITIZER MATCHES "([Uu]ndefined)") + target_compile_definitions(ufbx PRIVATE UFBX_UBSAN) +endif() + # Install rules set(CMAKE_INSTALL_DEFAULT_COMPONENT_NAME ufbx) install(DIRECTORY ${ufbx_SOURCE_DIR} DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) diff --git a/modules/core/src/SurfaceMesh.cpp b/modules/core/src/SurfaceMesh.cpp index 9874f50d..e9d02b43 100644 --- a/modules/core/src/SurfaceMesh.cpp +++ b/modules/core/src/SurfaceMesh.cpp @@ -38,6 +38,7 @@ // clang-format on #include +#include #include #include @@ -3656,6 +3657,26 @@ SurfaceMeshInfo from_surface_mesh(const SurfaceMesh& mesh) return info; } +namespace { + +// Copy possibly unaligned bytes directly into owned typed attribute storage. +template +void copy_attribute_bytes(Attribute& attr, span bytes, bool resize) +{ + if (bytes.empty()) return; + const size_t element_size = attr.get_num_channels() * sizeof(ValueType); + la_runtime_assert(bytes.size() % element_size == 0); + const size_t num_elements = bytes.size() / element_size; + if (resize) { + attr.resize_elements(num_elements); + } else { + la_runtime_assert(attr.get_num_elements() == num_elements); + } + std::memcpy(attr.ref_all().data(), bytes.data(), bytes.size()); +} + +} // namespace + template SurfaceMesh to_surface_mesh(const SurfaceMeshInfo& info) { @@ -3697,40 +3718,33 @@ SurfaceMesh to_surface_mesh(const SurfaceMeshInfo& info) if (ai->is_indexed) { switch (value_type) { -#define LA_X_restore_indexed(_, ValueType) \ - case make_attribute_value_type(): { \ - auto values = span( \ - reinterpret_cast(ai->values_bytes.data()), \ - ai->values_bytes.size() / sizeof(ValueType)); \ - auto indices = span( \ - reinterpret_cast(ai->indices_bytes.data()), \ - ai->indices_bytes.size() / sizeof(Index)); \ - id = mesh.template create_attribute_internal( \ - ai->name, \ - element, \ - usage, \ - ai->values_num_channels, \ - values, \ - indices); \ - break; \ +#define LA_X_restore_indexed(_, ValueType) \ + case make_attribute_value_type(): { \ + id = mesh.template create_attribute_internal( \ + ai->name, \ + element, \ + usage, \ + ai->values_num_channels); \ + auto& attr = mesh.m_attributes->template write_indexed(id); \ + copy_attribute_bytes(attr.values(), ai->values_bytes, true); \ + copy_attribute_bytes(attr.indices(), ai->indices_bytes, false); \ + break; \ } LA_ATTRIBUTE_X(restore_indexed, 0) #undef LA_X_restore_indexed } } else { switch (value_type) { -#define LA_X_restore_attr(_, ValueType) \ - case make_attribute_value_type(): { \ - auto data = span( \ - reinterpret_cast(ai->data_bytes.data()), \ - ai->data_bytes.size() / sizeof(ValueType)); \ - id = mesh.template create_attribute_internal( \ - ai->name, \ - element, \ - usage, \ - ai->num_channels, \ - data); \ - break; \ +#define LA_X_restore_attr(_, ValueType) \ + case make_attribute_value_type(): { \ + id = mesh.template create_attribute_internal( \ + ai->name, \ + element, \ + usage, \ + ai->num_channels); \ + auto& attr = mesh.m_attributes->template write(id); \ + copy_attribute_bytes(attr, ai->data_bytes, element == AttributeElement::Value); \ + break; \ } LA_ATTRIBUTE_X(restore_attr, 0) #undef LA_X_restore_attr diff --git a/modules/core/tests/test_surface_mesh_info.cpp b/modules/core/tests/test_surface_mesh_info.cpp new file mode 100644 index 00000000..895fde9f --- /dev/null +++ b/modules/core/tests/test_surface_mesh_info.cpp @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace { + +template +void check_unaligned_attribute_round_trip() +{ + lagrange::SurfaceMesh mesh; + mesh.add_vertices(3, {0, 0, 0, 1, 0, 0, 0, 1, 0}); + mesh.add_triangle(0, 1, 2); + + // Include 64-bit non-indexed values, scalar-valued indexed data, and mesh indices. + const std::vector labels = {11, 22, 33}; + mesh.template create_attribute( + "labels", + lagrange::AttributeElement::Vertex, + lagrange::AttributeUsage::Scalar, + 1, + lagrange::span(labels.data(), labels.size())); + + const std::vector uv_values = {0, 0, 1, 0, 0, 1}; + const std::vector uv_indices = {2, 0, 1}; + mesh.template create_attribute( + "uv", + lagrange::AttributeElement::Indexed, + lagrange::AttributeUsage::UV, + 2, + lagrange::span(uv_values.data(), uv_values.size()), + lagrange::span(uv_indices.data(), uv_indices.size())); + + bool unalign_data = false; + bool unalign_values = false; + bool unalign_indices = false; + SECTION("non-indexed data") + { + unalign_data = true; + } + SECTION("indexed values") + { + unalign_values = true; + } + SECTION("indexed indices") + { + unalign_indices = true; + } + SECTION("all attribute buffers") + { + unalign_data = unalign_values = unalign_indices = true; + } + + auto info = lagrange::internal::from_surface_mesh(mesh); + std::vector> storage; + storage.reserve(info.attributes.size() * 3); + auto unaligned_copy = [&](lagrange::span bytes) { + if (bytes.empty()) return bytes; + auto& buffer = storage.emplace_back( + (bytes.size() + sizeof(std::max_align_t)) / sizeof(std::max_align_t)); + auto* data = reinterpret_cast(buffer.data()) + 1; + REQUIRE(reinterpret_cast(data) % alignof(std::max_align_t) == 1); + std::memcpy(data, bytes.data(), bytes.size()); + return lagrange::span(data, bytes.size()); + }; + + // Byte spans need not be aligned for their value types. Run UBSan with + // halt_on_error=1: x86 can otherwise accept the old unaligned typed reads. + for (auto& attr : info.attributes) { + if (unalign_data) attr.data_bytes = unaligned_copy(attr.data_bytes); + if (unalign_values) attr.values_bytes = unaligned_copy(attr.values_bytes); + if (unalign_indices) attr.indices_bytes = unaligned_copy(attr.indices_bytes); + } + + auto result = lagrange::internal::to_surface_mesh(info); + storage.clear(); // Restoration must own its attribute data, not retain the byte spans. + lagrange::testing::check_meshes_equal(mesh, result); +} + +} // namespace + +TEST_CASE("SurfaceMeshInfo: unaligned byte round-trip", "[core][surface_mesh_info]") +{ + SECTION("float, uint32_t") + { + check_unaligned_attribute_round_trip(); + } + SECTION("double, uint32_t") + { + check_unaligned_attribute_round_trip(); + } + SECTION("float, uint64_t") + { + check_unaligned_attribute_round_trip(); + } + SECTION("double, uint64_t") + { + check_unaligned_attribute_round_trip(); + } +}